Compare commits

..

No commits in common. "96fdf64060dfa22647004df60ebd4b827abf95d6" and "6c879987bf214427020b26b5b56d1dfed03ccfb3" have entirely different histories.

4 changed files with 6 additions and 554 deletions

View File

@ -74,11 +74,9 @@ router.post('/upload', upload.single('file'), async (req, res) => {
}
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
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype, targetFolder);
const uploadResult = await s3Service.uploadFile(buffer, originalname, mimetype);
// Extract audio metadata
const metadata = await audioMetadataService.extractMetadata(buffer, originalname);
@ -95,62 +93,6 @@ router.post('/upload', upload.single('file'), async (req, res) => {
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({
message: 'File uploaded successfully',
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' });
}
const { markForScan = false } = req.body;
const results = [];
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();
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) {
console.error(`Error uploading ${file.originalname}:`, error);
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
*/
@ -349,9 +187,6 @@ router.post('/sync-s3', async (req, res) => {
status: 'started'
});
// Invalidate folder cache since sync might change folder structure
invalidateFolderCache();
} catch (error) {
console.error('❌ Error starting S3 sync job:', error);
res.status(500).json({ error: 'Failed to start S3 sync job' });
@ -492,9 +327,6 @@ router.delete('/:id', async (req, res) => {
// 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);

View File

@ -79,15 +79,10 @@ export class S3Service {
async uploadFile(
file: Buffer,
originalName: string,
contentType: string,
targetFolder?: string
contentType: string
): Promise<UploadResult> {
const fileExtension = originalName.split('.').pop();
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
const safeFolder = cleaned;
const key = safeFolder
? `${safeFolder}/${uuidv4()}.${fileExtension}`
: `${uuidv4()}.${fileExtension}`;
const key = `music/${uuidv4()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: this.bucketName,
@ -144,42 +139,6 @@ export class S3Service {
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
*/

View File

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

View File

@ -12,13 +12,9 @@ import {
AlertTitle,
AlertDescription,
Icon,
Input,
Checkbox,
Spinner,
useToast,
} from '@chakra-ui/react';
import { FiUpload, FiMusic, FiCheck, FiX } from 'react-icons/fi';
import FolderBrowser from './FolderBrowser';
interface UploadProgress {
fileName: string;
@ -34,17 +30,9 @@ interface MusicUploadProps {
export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) => {
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
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 onDrop = useCallback(async (acceptedFiles: File[]) => {
console.log('onDrop called with files:', acceptedFiles);
console.log('Current targetFolder:', targetFolder);
console.log('Current markForScan:', markForScan);
setIsUploading(true);
const newProgress: UploadProgress[] = acceptedFiles.map(file => ({
fileName: file.name,
@ -62,8 +50,6 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
try {
const formData = new FormData();
formData.append('file', file);
formData.append('targetFolder', targetFolder);
formData.append('markForScan', String(markForScan));
const response = await fetch('/api/music/upload', {
method: 'POST',
@ -117,45 +103,16 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
onUploadComplete?.(results);
}
}, [onUploadComplete, toast, targetFolder, markForScan]);
}, [onUploadComplete, toast]);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'audio/*': ['.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.wma', '.opus'],
},
maxSize: 100 * 1024 * 1024, // 100MB
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 = () => {
setUploadProgress([]);
@ -163,66 +120,6 @@ export const MusicUpload: React.FC<MusicUploadProps> = ({ onUploadComplete }) =>
return (
<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
{...getRootProps()}
border="2px dashed"