Compare commits
No commits in common. "96fdf64060dfa22647004df60ebd4b827abf95d6" and "6c879987bf214427020b26b5b56d1dfed03ccfb3" have entirely different histories.
96fdf64060
...
6c879987bf
@ -74,11 +74,9 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { buffer, originalname, mimetype } = req.file;
|
const { buffer, originalname, mimetype } = req.file;
|
||||||
const targetFolder = typeof req.body?.targetFolder === 'string' ? req.body.targetFolder : undefined;
|
|
||||||
const markForScan = String(req.body?.markForScan || '').toLowerCase() === 'true';
|
|
||||||
|
|
||||||
// Upload to S3
|
// Upload to S3
|
||||||
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder);
|
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype);
|
||||||
|
|
||||||
// Extract audio metadata
|
// Extract audio metadata
|
||||||
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
||||||
@ -95,62 +93,6 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||||||
|
|
||||||
await musicFile.save();
|
await musicFile.save();
|
||||||
|
|
||||||
// Invalidate folder cache since we added a new file
|
|
||||||
invalidateFolderCache();
|
|
||||||
|
|
||||||
// Optionally add to a special playlist for scanning
|
|
||||||
if (markForScan) {
|
|
||||||
try {
|
|
||||||
const { Playlist } = await import('../models/Playlist.js');
|
|
||||||
const { Song } = await import('../models/Song.js');
|
|
||||||
|
|
||||||
// Find or create the "To Be Scanned" playlist
|
|
||||||
let toScanPlaylist = await Playlist.findOne({ name: 'To Be Scanned' });
|
|
||||||
if (!toScanPlaylist) {
|
|
||||||
toScanPlaylist = new Playlist({
|
|
||||||
id: 'to-be-scanned',
|
|
||||||
name: 'To Be Scanned',
|
|
||||||
type: 'playlist',
|
|
||||||
tracks: [],
|
|
||||||
order: 0
|
|
||||||
});
|
|
||||||
await toScanPlaylist.save();
|
|
||||||
console.log('✅ Created "To Be Scanned" playlist');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create stub song with temporary id if needed
|
|
||||||
const tempId = `stub-${musicFile._id.toString()}`;
|
|
||||||
let existingStub = await Song.findOne({ id: tempId });
|
|
||||||
if (!existingStub) {
|
|
||||||
const stub = new Song({
|
|
||||||
id: tempId,
|
|
||||||
title: musicFile.title || musicFile.originalName,
|
|
||||||
artist: musicFile.artist || '',
|
|
||||||
album: musicFile.album || '',
|
|
||||||
totalTime: musicFile.duration ? String(Math.round(musicFile.duration / 1000)) : '',
|
|
||||||
location: '',
|
|
||||||
s3File: {
|
|
||||||
musicFileId: musicFile._id,
|
|
||||||
s3Key: musicFile.s3Key,
|
|
||||||
s3Url: musicFile.s3Url,
|
|
||||||
hasS3File: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await stub.save();
|
|
||||||
console.log('✅ Created stub song:', tempId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stub song to playlist if not already there
|
|
||||||
if (!toScanPlaylist.tracks.includes(tempId)) {
|
|
||||||
toScanPlaylist.tracks.push(tempId);
|
|
||||||
await toScanPlaylist.save();
|
|
||||||
console.log('✅ Added song to "To Be Scanned" playlist');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ Failed to mark uploaded file for scanning:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'File uploaded successfully',
|
message: 'File uploaded successfully',
|
||||||
musicFile,
|
musicFile,
|
||||||
@ -170,7 +112,6 @@ router.post('/batch-upload', upload.array('files', 10), async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No files uploaded' });
|
return res.status(400).json({ error: 'No files uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { markForScan = false } = req.body;
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const file of req.files as Express.Multer.File[]) {
|
for (const file of req.files as Express.Multer.File[]) {
|
||||||
@ -195,61 +136,6 @@ router.post('/batch-upload', upload.array('files', 10), async (req, res) => {
|
|||||||
|
|
||||||
await musicFile.save();
|
await musicFile.save();
|
||||||
results.push({ success: true, musicFile });
|
results.push({ success: true, musicFile });
|
||||||
|
|
||||||
// Optionally add to "To Be Scanned" playlist
|
|
||||||
if (markForScan) {
|
|
||||||
try {
|
|
||||||
const { Playlist } = await import('../models/Playlist.js');
|
|
||||||
const { Song } = await import('../models/Song.js');
|
|
||||||
|
|
||||||
// Find or create the "To Be Scanned" playlist
|
|
||||||
let toScanPlaylist = await Playlist.findOne({ name: 'To Be Scanned' });
|
|
||||||
if (!toScanPlaylist) {
|
|
||||||
toScanPlaylist = new Playlist({
|
|
||||||
name: 'To Be Scanned',
|
|
||||||
type: 'playlist',
|
|
||||||
tracks: [],
|
|
||||||
order: 0
|
|
||||||
});
|
|
||||||
await toScanPlaylist.save();
|
|
||||||
console.log('✅ Created "To Be Scanned" playlist (batch)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create stub song with temporary id if needed
|
|
||||||
const tempId = `stub-${musicFile._id.toString()}`;
|
|
||||||
let existingStub = await Song.findOne({ id: tempId });
|
|
||||||
if (!existingStub) {
|
|
||||||
const stub = new Song({
|
|
||||||
id: tempId,
|
|
||||||
title: musicFile.title || musicFile.originalName,
|
|
||||||
artist: musicFile.artist || '',
|
|
||||||
album: musicFile.album || '',
|
|
||||||
totalTime: musicFile.duration ? String(Math.round(musicFile.duration / 1000)) : '',
|
|
||||||
location: '',
|
|
||||||
s3File: {
|
|
||||||
musicFileId: musicFile._id,
|
|
||||||
s3Key: musicFile.s3Key,
|
|
||||||
s3Url: musicFile.s3Url,
|
|
||||||
hasS3File: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await stub.save();
|
|
||||||
console.log('✅ Created stub song (batch):', tempId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stub song to playlist if not already there
|
|
||||||
if (!toScanPlaylist.tracks.includes(tempId)) {
|
|
||||||
toScanPlaylist.tracks.push(tempId);
|
|
||||||
await toScanPlaylist.save();
|
|
||||||
console.log('✅ Added song to "To Be Scanned" playlist (batch)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ Failed to mark uploaded file for scanning (batch):', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate folder cache since we added a new file
|
|
||||||
invalidateFolderCache();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error uploading ${file.originalname}:`, error);
|
console.error(`Error uploading ${file.originalname}:`, error);
|
||||||
results.push({ success: false, fileName: file.originalname, error: (error as Error).message });
|
results.push({ success: false, fileName: file.originalname, error: (error as Error).message });
|
||||||
@ -279,54 +165,6 @@ router.get('/files', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* List folders in the S3 bucket for folder selection
|
|
||||||
*/
|
|
||||||
// Cache for folder listing to improve performance
|
|
||||||
let folderCache: { folders: string[]; timestamp: number } | null = null;
|
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
// Function to invalidate folder cache
|
|
||||||
const invalidateFolderCache = () => {
|
|
||||||
folderCache = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
router.get('/folders', async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Check if we have a valid cached result
|
|
||||||
if (folderCache && (Date.now() - folderCache.timestamp) < CACHE_DURATION) {
|
|
||||||
return res.json({ folders: folderCache.folders });
|
|
||||||
}
|
|
||||||
|
|
||||||
const folders = await s3Service.listAllFolders('');
|
|
||||||
const result = ['', ...folders];
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
folderCache = {
|
|
||||||
folders: result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json({ folders: result });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching S3 folders:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch S3 folders' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force refresh folder cache
|
|
||||||
*/
|
|
||||||
router.post('/folders/refresh', async (req, res) => {
|
|
||||||
try {
|
|
||||||
invalidateFolderCache();
|
|
||||||
res.json({ message: 'Folder cache refreshed successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing folder cache:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to refresh folder cache' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync S3 files with database - now uses background job system
|
* Sync S3 files with database - now uses background job system
|
||||||
*/
|
*/
|
||||||
@ -349,9 +187,6 @@ router.post('/sync-s3', async (req, res) => {
|
|||||||
status: 'started'
|
status: 'started'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate folder cache since sync might change folder structure
|
|
||||||
invalidateFolderCache();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error starting S3 sync job:', error);
|
console.error('❌ Error starting S3 sync job:', error);
|
||||||
res.status(500).json({ error: 'Failed to start S3 sync job' });
|
res.status(500).json({ error: 'Failed to start S3 sync job' });
|
||||||
@ -492,9 +327,6 @@ router.delete('/:id', async (req, res) => {
|
|||||||
// Delete from database
|
// Delete from database
|
||||||
await MusicFile.findByIdAndDelete(req.params.id);
|
await MusicFile.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
// Invalidate folder cache since we removed a file
|
|
||||||
invalidateFolderCache();
|
|
||||||
|
|
||||||
res.json({ message: 'Music file deleted successfully' });
|
res.json({ message: 'Music file deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete error:', error);
|
console.error('Delete error:', error);
|
||||||
|
|||||||
@ -79,15 +79,10 @@ export class S3Service {
|
|||||||
async uploadFile(
|
async uploadFile(
|
||||||
file: Buffer,
|
file: Buffer,
|
||||||
originalName: string,
|
originalName: string,
|
||||||
contentType: string,
|
contentType: string
|
||||||
targetFolder?: string
|
|
||||||
): Promise<UploadResult> {
|
): Promise<UploadResult> {
|
||||||
const fileExtension = originalName.split('.').pop();
|
const fileExtension = originalName.split('.').pop();
|
||||||
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
|
const key = `music/${uuidv4()}.${fileExtension}`;
|
||||||
const safeFolder = cleaned;
|
|
||||||
const key = safeFolder
|
|
||||||
? `${safeFolder}/${uuidv4()}.${fileExtension}`
|
|
||||||
: `${uuidv4()}.${fileExtension}`;
|
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
@ -144,42 +139,6 @@ export class S3Service {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List all folders (prefixes) in the bucket. Recursively collects nested prefixes.
|
|
||||||
*/
|
|
||||||
async listAllFolders(prefix: string = ''): Promise<string[]> {
|
|
||||||
const folders = new Set<string>();
|
|
||||||
const queue: string[] = [prefix];
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const currentPrefix = queue.shift() || '';
|
|
||||||
let continuationToken: string | undefined;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const command = new ListObjectsV2Command({
|
|
||||||
Bucket: this.bucketName,
|
|
||||||
Prefix: currentPrefix,
|
|
||||||
Delimiter: '/',
|
|
||||||
ContinuationToken: continuationToken,
|
|
||||||
});
|
|
||||||
const response = await this.client.send(command);
|
|
||||||
const common = (response.CommonPrefixes || []).map(cp => cp.Prefix).filter(Boolean) as string[];
|
|
||||||
for (const p of common) {
|
|
||||||
// Normalize: strip trailing slash
|
|
||||||
const normalized = p.replace(/\/+$/, '');
|
|
||||||
if (!folders.has(normalized)) {
|
|
||||||
folders.add(normalized);
|
|
||||||
// Continue deeper
|
|
||||||
queue.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continuationToken = response.NextContinuationToken;
|
|
||||||
} while (continuationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(folders).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a presigned URL for secure file access
|
* Generate a presigned URL for secure file access
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,236 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Text,
|
|
||||||
IconButton,
|
|
||||||
Collapse,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
|
||||||
import { FiFolder } from 'react-icons/fi';
|
|
||||||
|
|
||||||
interface FolderNode {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
children: FolderNode[];
|
|
||||||
isExpanded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FolderBrowserProps {
|
|
||||||
folders: string[];
|
|
||||||
selectedFolder: string;
|
|
||||||
onFolderSelect: (folder: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FolderBrowser: React.FC<FolderBrowserProps> = ({
|
|
||||||
folders,
|
|
||||||
selectedFolder,
|
|
||||||
onFolderSelect,
|
|
||||||
}) => {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [folderTree, setFolderTree] = useState<FolderNode[]>([]);
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Convert flat folder list to tree structure
|
|
||||||
useEffect(() => {
|
|
||||||
const tree: FolderNode[] = [];
|
|
||||||
const folderMap = new Map<string, FolderNode>();
|
|
||||||
|
|
||||||
// Add root node
|
|
||||||
const rootNode: FolderNode = { name: '(root)', path: '', children: [] };
|
|
||||||
tree.push(rootNode);
|
|
||||||
folderMap.set('', rootNode);
|
|
||||||
|
|
||||||
// Process each folder path
|
|
||||||
folders.forEach(folder => {
|
|
||||||
if (folder === '') return; // Skip root as it's already added
|
|
||||||
|
|
||||||
const parts = folder.split('/');
|
|
||||||
let currentPath = '';
|
|
||||||
|
|
||||||
parts.forEach((part) => {
|
|
||||||
const parentPath = currentPath;
|
|
||||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
||||||
|
|
||||||
if (!folderMap.has(currentPath)) {
|
|
||||||
const newNode: FolderNode = {
|
|
||||||
name: part,
|
|
||||||
path: currentPath,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
folderMap.set(currentPath, newNode);
|
|
||||||
|
|
||||||
if (parentPath === '') {
|
|
||||||
// Top level folder
|
|
||||||
tree.push(newNode);
|
|
||||||
} else {
|
|
||||||
// Subfolder
|
|
||||||
const parent = folderMap.get(parentPath);
|
|
||||||
if (parent) {
|
|
||||||
parent.children.push(newNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setFolderTree(tree);
|
|
||||||
}, [folders]);
|
|
||||||
|
|
||||||
const toggleFolder = (path: string) => {
|
|
||||||
const newExpanded = new Set(expandedFolders);
|
|
||||||
if (newExpanded.has(path)) {
|
|
||||||
newExpanded.delete(path);
|
|
||||||
} else {
|
|
||||||
newExpanded.add(path);
|
|
||||||
}
|
|
||||||
setExpandedFolders(newExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isExpanded = (path: string) => expandedFolders.has(path);
|
|
||||||
|
|
||||||
const renderFolderNode = (node: FolderNode, level: number = 0): React.ReactNode => {
|
|
||||||
const hasChildren = node.children.length > 0;
|
|
||||||
const expanded = isExpanded(node.path);
|
|
||||||
const isSelected = selectedFolder === node.path;
|
|
||||||
|
|
||||||
// Filter children based on search query
|
|
||||||
const filteredChildren = node.children.filter(child =>
|
|
||||||
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
child.path.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// If searching and this node doesn't match, only show if it has matching children
|
|
||||||
if (searchQuery && !node.name.toLowerCase().includes(searchQuery.toLowerCase()) && filteredChildren.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={node.path}>
|
|
||||||
<HStack
|
|
||||||
spacing={2}
|
|
||||||
py={1}
|
|
||||||
px={2}
|
|
||||||
cursor="pointer"
|
|
||||||
bg={isSelected ? 'blue.600' : 'transparent'}
|
|
||||||
_hover={{ bg: isSelected ? 'blue.600' : 'gray.700' }}
|
|
||||||
borderRadius="md"
|
|
||||||
onClick={() => onFolderSelect(node.path)}
|
|
||||||
minH="32px"
|
|
||||||
>
|
|
||||||
<Box w={`${level * 20}px`} />
|
|
||||||
|
|
||||||
{hasChildren ? (
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={expanded ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleFolder(node.path);
|
|
||||||
}}
|
|
||||||
aria-label={expanded ? 'Collapse folder' : 'Expand folder'}
|
|
||||||
color="gray.300"
|
|
||||||
_hover={{ bg: 'gray.700', color: 'white' }}
|
|
||||||
_active={{ bg: 'gray.600' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box w="32px" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box color={hasChildren ? 'yellow.400' : 'gray.400'}>
|
|
||||||
<FiFolder />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
fontSize="sm"
|
|
||||||
fontWeight={isSelected ? 'bold' : 'normal'}
|
|
||||||
color={isSelected ? 'white' : 'gray.200'}
|
|
||||||
flex={1}
|
|
||||||
noOfLines={1}
|
|
||||||
>
|
|
||||||
{node.name}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{hasChildren && (
|
|
||||||
<Collapse in={expanded}>
|
|
||||||
<VStack align="stretch" spacing={0}>
|
|
||||||
{filteredChildren.map(child => renderFolderNode(child, level + 1))}
|
|
||||||
</VStack>
|
|
||||||
</Collapse>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredTree = searchQuery
|
|
||||||
? folderTree.filter(node =>
|
|
||||||
node.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
node.path.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
node.children.some(child =>
|
|
||||||
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
child.path.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: folderTree;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<InputGroup mb={3}>
|
|
||||||
<InputLeftElement>
|
|
||||||
<FiFolder />
|
|
||||||
</InputLeftElement>
|
|
||||||
<Input
|
|
||||||
placeholder="Search folders..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
bg="gray.800"
|
|
||||||
borderColor="gray.700"
|
|
||||||
_focus={{ borderColor: 'blue.500' }}
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
maxH="300px"
|
|
||||||
overflowY="auto"
|
|
||||||
border="1px solid"
|
|
||||||
borderColor="gray.700"
|
|
||||||
borderRadius="md"
|
|
||||||
bg="gray.900"
|
|
||||||
sx={{
|
|
||||||
'&::-webkit-scrollbar': {
|
|
||||||
width: '8px',
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-track': {
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-thumb': {
|
|
||||||
background: 'gray.600',
|
|
||||||
borderRadius: '4px',
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-thumb:hover': {
|
|
||||||
background: 'gray.500',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VStack align="stretch" spacing={0}>
|
|
||||||
{filteredTree.map(node => renderFolderNode(node))}
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{searchQuery && filteredTree.length === 0 && (
|
|
||||||
<Text color="gray.500" textAlign="center" py={4}>
|
|
||||||
No folders match your search
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FolderBrowser;
|
|
||||||
@ -12,13 +12,9 @@ import {
|
|||||||
AlertTitle,
|
AlertTitle,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
Icon,
|
Icon,
|
||||||
Input,
|
|
||||||
Checkbox,
|
|
||||||
Spinner,
|
|
||||||
useToast,
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
|
||||||
import FolderBrowser from './FolderBrowser';
|
|
||||||
|
|
||||||
interface UploadProgress {
|
interface UploadProgress {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -34,17 +30,9 @@ interface MusicUploadProps {
|
|||||||
export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) => {
|
export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) => {
|
||||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [targetFolder, setTargetFolder] = useState<string>('uploads');
|
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
|
||||||
const [isLoadingFolders, setIsLoadingFolders] = useState(true);
|
|
||||||
const [markForScan, setMarkForScan] = useState<boolean>(true);
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||||
console.log('onDrop called with files:', acceptedFiles);
|
|
||||||
console.log('Current targetFolder:', targetFolder);
|
|
||||||
console.log('Current markForScan:', markForScan);
|
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
const newProgress: UploadProgress[] = acceptedFiles.map(file => ({
|
const newProgress: UploadProgress[] = acceptedFiles.map(file => ({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@ -56,14 +44,12 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
|||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||||
const file = acceptedFiles[i];
|
const file = acceptedFiles[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('targetFolder', targetFolder);
|
|
||||||
formData.append('markForScan', String(markForScan));
|
|
||||||
|
|
||||||
const response = await fetch('/api/music/upload', {
|
const response = await fetch('/api/music/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -117,112 +103,23 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
|||||||
|
|
||||||
onUploadComplete?.(results);
|
onUploadComplete?.(results);
|
||||||
}
|
}
|
||||||
}, [onUploadComplete, toast, targetFolder, markForScan]);
|
}, [onUploadComplete, toast]);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: {
|
accept: {
|
||||||
'audio/*': ['.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.wma', '.opus'],
|
'audio/*': ['.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.wma', '.opus'],
|
||||||
},
|
},
|
||||||
maxSize: 100 * 1024 * 1024, // 100MB
|
maxSize: 100 * 1024 * 1024, // 100MB
|
||||||
multiple: true,
|
multiple: true,
|
||||||
onDropRejected: (rejectedFiles) => {
|
|
||||||
console.log('Files rejected:', rejectedFiles);
|
|
||||||
rejectedFiles.forEach(({ file, errors }) => {
|
|
||||||
console.error(`File ${file.name} rejected:`, errors);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load folders for dropdown
|
|
||||||
React.useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
setIsLoadingFolders(true);
|
|
||||||
const res = await fetch('/api/music/folders');
|
|
||||||
if (!res.ok) throw new Error('Failed to load folders');
|
|
||||||
const data = await res.json();
|
|
||||||
const items = Array.isArray(data.folders) ? data.folders : [];
|
|
||||||
setFolders(items);
|
|
||||||
if (items.length > 0 && targetFolder === 'uploads') {
|
|
||||||
// default target: root (empty) if available, else first item
|
|
||||||
const defaultChoice = items.find((f: string) => f === '') || items[0];
|
|
||||||
setTargetFolder(defaultChoice.replace(/^\/+/, ''));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore, keep text input fallback
|
|
||||||
} finally {
|
|
||||||
setIsLoadingFolders(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetUploads = () => {
|
const resetUploads = () => {
|
||||||
setUploadProgress([]);
|
setUploadProgress([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack spacing={4} align="stretch" w="full">
|
<VStack spacing={4} align="stretch" w="full">
|
||||||
<Box>
|
|
||||||
<Text color="gray.300" fontSize="sm" mb={3}>Target S3 folder</Text>
|
|
||||||
{targetFolder && (
|
|
||||||
<Text color="blue.400" fontSize="sm" mb={2}>
|
|
||||||
Selected: {targetFolder === '' ? '(root)' : targetFolder}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{isLoadingFolders ? (
|
|
||||||
<Box
|
|
||||||
p={6}
|
|
||||||
textAlign="center"
|
|
||||||
bg="gray.800"
|
|
||||||
border="1px solid"
|
|
||||||
borderColor="gray.700"
|
|
||||||
borderRadius="md"
|
|
||||||
>
|
|
||||||
<Spinner size="md" color="blue.400" mb={3} />
|
|
||||||
<Text color="gray.400" fontSize="sm">Loading folder structure...</Text>
|
|
||||||
</Box>
|
|
||||||
) : folders.length > 0 ? (
|
|
||||||
<FolderBrowser
|
|
||||||
folders={folders}
|
|
||||||
selectedFolder={targetFolder}
|
|
||||||
onFolderSelect={(folder) => {
|
|
||||||
console.log('Folder selected:', folder);
|
|
||||||
setTargetFolder(folder);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={targetFolder}
|
|
||||||
onChange={(e) => setTargetFolder(e.target.value)}
|
|
||||||
placeholder="e.g. Prep/ToScan"
|
|
||||||
bg="gray.800"
|
|
||||||
borderColor="gray.700"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Checkbox isChecked={markForScan} onChange={(e) => setMarkForScan(e.target.checked)} colorScheme="blue">
|
|
||||||
Add to "To Be Scanned"
|
|
||||||
</Checkbox>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{fileRejections.length > 0 && (
|
|
||||||
<Alert status="error" bg="red.900" borderColor="red.700" color="red.100">
|
|
||||||
<AlertIcon color="red.300" />
|
|
||||||
<Box>
|
|
||||||
<AlertTitle color="red.100">File Rejected</AlertTitle>
|
|
||||||
<AlertDescription color="red.200">
|
|
||||||
{fileRejections.map(({ file, errors }) => (
|
|
||||||
<Text key={file.name}>
|
|
||||||
{file.name}: {errors.map(e => e.message).join(', ')}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</AlertDescription>
|
|
||||||
</Box>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Box
|
<Box
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
border="2px dashed"
|
border="2px dashed"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user