diff --git a/api/src/routes/playback.js b/api/src/routes/playback.js index 900b2a3..ef61e57 100644 --- a/api/src/routes/playback.js +++ b/api/src/routes/playback.js @@ -102,6 +102,40 @@ router.post('/skip', async (req, res) => { } }); +// Go to previous track +router.post('/previous', async (req, res) => { + try { + const response = await fetch(`${req.app.locals.botUrl}/previous`, { method: 'POST' }); + const result = await response.json(); + + // Get updated state immediately and broadcast + try { + const stateResponse = await fetch(`${req.app.locals.botUrl}/state`); + const state = await stateResponse.json(); + + if (state.currentTrack) { + req.app.locals.broadcast('trackChange', { + track: state.currentTrack, + queueLength: state.queueLength + }); + } + + req.app.locals.broadcast('playbackUpdate', { + state: state.state, + position: state.position, + volume: state.volume + }); + } catch (broadcastError) { + // Polling will catch it + } + + res.json(result); + } catch (error) { + req.app.locals.logger.error(`Error going to previous track: ${error.message}`); + res.status(500).json({ error: 'Failed to go to previous track' }); + } +}); + // Set volume router.post('/volume', async (req, res) => { try { diff --git a/api/src/routes/queue.js b/api/src/routes/queue.js index f9ef82c..ab09ad0 100644 --- a/api/src/routes/queue.js +++ b/api/src/routes/queue.js @@ -2,13 +2,13 @@ import express from 'express'; const router = express.Router(); -// Get current queue +// Get current queue (full library in playback order) router.get('/', async (req, res) => { try { const response = await fetch(`${req.app.locals.botUrl}/state`); const state = await response.json(); res.json({ - tracks: state.queue || [], + tracks: state.fullQueue || [], currentTrack: state.currentTrack, shuffled: state.shuffled || false }); @@ -33,4 +33,41 @@ router.post('/shuffle', async (req, res) => { } }); +// Reorder queue +router.post('/reorder', async (req, res) => { + try { + const { from, to } = req.body; + const response = await fetch(`${req.app.locals.botUrl}/queue/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from, to }) + }); + const result = await response.json(); + req.app.locals.broadcast('queueUpdate', { queue: result.queue }); + res.json(result); + } catch (error) { + req.app.locals.logger.error(`Error reordering queue: ${error.message}`); + res.status(500).json({ error: 'Failed to reorder queue' }); + } +}); + + +// Add track to queue +router.post('/add', async (req, res) => { + try { + const { trackId } = req.body; + const response = await fetch(`${req.app.locals.botUrl}/queue/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ trackId }) + }); + const result = await response.json(); + req.app.locals.broadcast('queueUpdate', { queue: result.queue }); + res.json(result); + } catch (error) { + req.app.locals.logger.error(`Error adding to queue: ${error.message}`); + res.status(500).json({ error: 'Failed to add to queue' }); + } +}); + export default router; diff --git a/api/src/routes/tracks.js b/api/src/routes/tracks.js index 52e8498..9213367 100644 --- a/api/src/routes/tracks.js +++ b/api/src/routes/tracks.js @@ -74,7 +74,8 @@ router.get('/', async (req, res) => { title: file.replace('.mp3', ''), artist: 'Unknown', album: '', - duration: 0 + duration: 0, + hasArt: false }; try { @@ -83,7 +84,8 @@ router.get('/', async (req, res) => { title: parsed.common.title || metadata.title, artist: parsed.common.artist || metadata.artist, album: parsed.common.album || '', - duration: Math.floor(parsed.format.duration || 0) + duration: Math.floor(parsed.format.duration || 0), + hasArt: parsed.common.picture && parsed.common.picture.length > 0 }; } catch (error) { // Use defaults if metadata parsing fails @@ -94,7 +96,6 @@ router.get('/', async (req, res) => { filename: file, filepath: file, ...metadata, - hasArt: false, size: stats.size }); } @@ -106,6 +107,31 @@ router.get('/', async (req, res) => { } }); +// Get album art for a track +router.get('/:id/art', async (req, res) => { + try { + const filepath = path.join(MUSIC_DIRECTORY, req.params.id); + + if (!fs.existsSync(filepath)) { + return res.status(404).json({ error: 'Track not found' }); + } + + const parsed = await parseFile(filepath); + + if (!parsed.common.picture || parsed.common.picture.length === 0) { + return res.status(404).json({ error: 'No album art found' }); + } + + const picture = parsed.common.picture[0]; + res.set('Content-Type', picture.format || 'image/jpeg'); + res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours + res.send(picture.data); + } catch (error) { + req.app.locals.logger.error(`Error getting album art: ${error.message}`); + res.status(500).json({ error: 'Failed to get album art' }); + } +}); + // Upload tracks router.post('/upload', upload.array('files', MAX_BATCH_SIZE), async (req, res) => { const uploaded = []; diff --git a/bot/src/index.js b/bot/src/index.js index 2e258b6..8460ab0 100644 --- a/bot/src/index.js +++ b/bot/src/index.js @@ -32,6 +32,7 @@ let currentState = { position: 0, volume: 100, queue: [], + history: [], shuffled: false }; @@ -39,6 +40,7 @@ let connection = null; let player = null; let startTime = 0; let currentResource = null; +const MAX_HISTORY = 50; // Keep last 50 tracks in history // Discord client const client = new Client({ @@ -75,7 +77,22 @@ function initializeQueue() { } // Play next track in queue -function playNext() { +function playNext(skipToNext = false) { + // When skipping manually, add current track to history + // When track finishes naturally, move current to end of queue (continuous loop) + if (currentState.currentTrack) { + if (skipToNext) { + // Manual skip - add to history for "previous" functionality + currentState.history.unshift(currentState.currentTrack); + if (currentState.history.length > MAX_HISTORY) { + currentState.history.pop(); + } + } else { + // Natural finish - move to end of queue for continuous loop + currentState.queue.push(currentState.currentTrack); + } + } + if (currentState.queue.length === 0) { logger.info('Queue empty, reloading library'); initializeQueue(); @@ -109,6 +126,43 @@ function playNext() { } } +// Play previous track from history +function playPrevious() { + if (currentState.history.length === 0) { + logger.info('No previous track in history'); + return false; + } + + // Put current track back at the front of the queue + if (currentState.currentTrack) { + currentState.queue.unshift(currentState.currentTrack); + } + + // Get previous track from history + const track = currentState.history.shift(); + currentState.currentTrack = track; + currentState.position = 0; + currentState.state = 'playing'; + startTime = Date.now(); + + logger.info(`Playing previous track: ${track.title}`); + + try { + currentResource = createAudioResource(track.filepath, { + inlineVolume: true + }); + currentResource.volume.setVolume(currentState.volume / 100); + player.play(currentResource); + return true; + } catch (error) { + logger.error(`Error playing previous track: ${error.message}`); + currentResource = null; + // If previous track fails, continue to next + playNext(); + return false; + } +} + // Connect to voice channel async function connectToVoice() { try { @@ -177,10 +231,17 @@ app.get('/health', (req, res) => { app.get('/state', (req, res) => { const elapsed = currentState.state === 'playing' ? Math.floor((Date.now() - startTime) / 1000) : 0; + + // Queue includes current track + remaining tracks + const fullQueue = currentState.currentTrack + ? [currentState.currentTrack, ...currentState.queue] + : [...currentState.queue]; + res.json({ ...currentState, position: currentState.position + elapsed, - queueLength: currentState.queue.length + queueLength: currentState.queue.length, + fullQueue: fullQueue }); }); @@ -208,10 +269,58 @@ app.post('/pause', (req, res) => { app.post('/skip', (req, res) => { logger.info('Skipping to next track'); - playNext(); + + // Move current track to end of queue instead of history + if (currentState.currentTrack) { + currentState.queue.push(currentState.currentTrack); + } + + // Get next track from queue + if (currentState.queue.length > 0) { + const track = currentState.queue.shift(); + currentState.currentTrack = track; + currentState.position = 0; + currentState.state = 'playing'; + startTime = Date.now(); + + try { + currentResource = createAudioResource(track.filepath, { + inlineVolume: true + }); + currentResource.volume.setVolume(currentState.volume / 100); + player.play(currentResource); + } catch (error) { + logger.error(`Error playing track: ${error.message}`); + currentResource = null; + } + } + res.json({ success: true }); }); +app.post('/previous', (req, res) => { + logger.info('Playing previous track'); + const success = playPrevious(); + res.json({ success, hasHistory: currentState.history.length > 0 }); +}); + +app.post('/seek', (req, res) => { + const { position } = req.body; + + if (typeof position !== 'number' || position < 0) { + return res.status(400).json({ error: 'Invalid position' }); + } + + // Note: Discord.js voice doesn't support seeking within a track + // This would require re-creating the audio resource from the seek position + // For now, we'll return an error indicating this limitation + logger.warn('Seek requested but not supported by Discord.js voice'); + res.status(501).json({ + error: 'Seek not supported', + message: 'Discord.js voice library does not support seeking within tracks' + }); +}); + app.post('/volume', (req, res) => { const { volume } = req.body; if (volume >= 0 && volume <= 100) { @@ -238,6 +347,70 @@ app.post('/queue/shuffle', (req, res) => { res.json({ success: true, shuffled: currentState.shuffled }); }); +app.post('/queue/reorder', (req, res) => { + const { from, to } = req.body; + + // Full queue includes current track + remaining queue + const fullQueue = currentState.currentTrack + ? [currentState.currentTrack, ...currentState.queue] + : [...currentState.queue]; + + if (typeof from !== 'number' || typeof to !== 'number' || + from < 0 || to < 0 || + from >= fullQueue.length || to >= fullQueue.length) { + return res.status(400).json({ error: 'Invalid indices' }); + } + + // Move the track + const [track] = fullQueue.splice(from, 1); + fullQueue.splice(to, 0, track); + + // Update state: first track becomes current, rest becomes queue + if (fullQueue.length > 0) { + currentState.currentTrack = fullQueue[0]; + currentState.queue = fullQueue.slice(1); + + // If we moved the current track and it's playing, restart it + if (from === 0 && currentState.state === 'playing') { + currentState.position = 0; + startTime = Date.now(); + + try { + currentResource = createAudioResource(currentState.currentTrack.filepath, { + inlineVolume: true + }); + currentResource.volume.setVolume(currentState.volume / 100); + player.play(currentResource); + } catch (error) { + logger.error(`Error replaying track: ${error.message}`); + } + } + } + + logger.info(`Queue reordered: moved track from ${from} to ${to}`); + res.json({ success: true, queue: fullQueue }); +}); + + +app.post('/queue/add', (req, res) => { + const { trackId } = req.body; + + if (!trackId) { + return res.status(400).json({ error: 'Track ID required' }); + } + + const library = loadMusicLibrary(); + const track = library.find(t => t.id === trackId); + + if (!track) { + return res.status(404).json({ error: 'Track not found' }); + } + + currentState.queue.push(track); + logger.info(`Added ${track.title} to queue`); + res.json({ success: true, queue: currentState.queue }); +}); + app.get('/channels', async (req, res) => { try { const guild = await client.guilds.fetch(DISCORD_GUILD_ID); diff --git a/web/src/App.jsx b/web/src/App.jsx index d97ec60..139fc6d 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -9,42 +9,43 @@ import useWebSocket from './hooks/useWebSocket'; const API_URL = import.meta.env.VITE_API_URL || '/api'; function App() { - const [tracks, setTracks] = useState([]); const [currentTrack, setCurrentTrack] = useState(null); const [playbackState, setPlaybackState] = useState('stopped'); const [position, setPosition] = useState(0); const [volume, setVolume] = useState(100); + const [tracks, setTracks] = useState([]); const [loading, setLoading] = useState(true); + const loadQueue = async () => { + try { + const response = await fetch(`${API_URL}/queue`); + const data = await response.json(); + setTracks(data.tracks || []); + setLoading(false); + } catch (error) { + console.error('Failed to load queue:', error); + setLoading(false); + } + }; + const { connected } = useWebSocket((event, data) => { if (event === 'trackChange') { setCurrentTrack(data.track); + loadQueue(); } else if (event === 'playbackUpdate') { setPlaybackState(data.state); if (data.position !== undefined) setPosition(data.position); if (data.volume !== undefined) setVolume(data.volume); - } else if (event === 'libraryUpdate') { - loadTracks(); + } else if (event === 'libraryUpdate' || event === 'queueUpdate') { + loadQueue(); } }); useEffect(() => { - loadTracks(); loadPlaybackState(); + loadQueue(); }, []); - const loadTracks = async () => { - try { - const response = await fetch(`${API_URL}/tracks`); - const data = await response.json(); - setTracks(data); - } catch (error) { - console.error('Failed to load tracks:', error); - } finally { - setLoading(false); - } - }; - const loadPlaybackState = async () => { try { const response = await fetch(`${API_URL}/playback`); @@ -84,6 +85,14 @@ function App() { } }; + const handlePrevious = async () => { + try { + await fetch(`${API_URL}/playback/previous`, { method: 'POST' }); + } catch (error) { + console.error('Failed to go to previous:', error); + } + }; + const handleVolumeChange = async (newVolume) => { try { await fetch(`${API_URL}/playback/volume`, { @@ -106,13 +115,13 @@ function App() { }; const handleUploadComplete = () => { - loadTracks(); + loadQueue(); }; const handleDeleteTrack = async (trackId) => { try { await fetch(`${API_URL}/tracks/${trackId}`, { method: 'DELETE' }); - setTracks(tracks.filter(t => t.id !== trackId)); + loadQueue(); } catch (error) { console.error('Failed to delete track:', error); } @@ -143,6 +152,7 @@ function App() { onPlay={handlePlay} onPause={handlePause} onSkip={handleSkip} + onPrevious={handlePrevious} onVolumeChange={handleVolumeChange} onShuffle={handleShuffle} /> @@ -158,6 +168,7 @@ function App() { currentTrack={currentTrack} loading={loading} onDelete={handleDeleteTrack} + onReload={loadQueue} /> diff --git a/web/src/components/Controls.jsx b/web/src/components/Controls.jsx index 7be7e5e..e2edded 100644 --- a/web/src/components/Controls.jsx +++ b/web/src/components/Controls.jsx @@ -1,8 +1,16 @@ -export default function Controls({ state, volume, onPlay, onPause, onSkip, onVolumeChange, onShuffle }) { +export default function Controls({ state, volume, onPlay, onPause, onSkip, onPrevious, onVolumeChange, onShuffle }) { return (
+ + {state === 'playing' ? (
- -
- - ))} + ); + })} );