Compare commits
9 Commits
6c879987bf
...
96fdf64060
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96fdf64060 | ||
|
|
cf39a3c2b1 | ||
|
|
675c1f8d8f | ||
|
|
7557eddeb4 | ||
|
|
10e38fae4d | ||
|
|
a49e628d93 | ||
|
|
e4ee7230d1 | ||
|
|
762ae0730a | ||
|
|
aae57ec55f |
@ -74,9 +74,11 @@ 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);
|
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder);
|
||||||
|
|
||||||
// Extract audio metadata
|
// Extract audio metadata
|
||||||
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
|
||||||
@ -93,6 +95,62 @@ 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,
|
||||||
@ -112,6 +170,7 @@ 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[]) {
|
||||||
@ -136,6 +195,61 @@ 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 });
|
||||||
@ -165,6 +279,54 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -187,6 +349,9 @@ 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' });
|
||||||
@ -327,6 +492,9 @@ 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,10 +79,15 @@ 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 key = `music/${uuidv4()}.${fileExtension}`;
|
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
|
||||||
|
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,
|
||||||
@ -139,6 +144,42 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
236
packages/frontend/src/components/FolderBrowser.tsx
Normal file
236
packages/frontend/src/components/FolderBrowser.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
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,9 +12,13 @@ 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;
|
||||||
@ -30,9 +34,17 @@ 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,
|
||||||
@ -44,12 +56,14 @@ 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',
|
||||||
@ -103,23 +117,112 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
|
|||||||
|
|
||||||
onUploadComplete?.(results);
|
onUploadComplete?.(results);
|
||||||
}
|
}
|
||||||
}, [onUploadComplete, toast]);
|
}, [onUploadComplete, toast, targetFolder, markForScan]);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive, fileRejections } = 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