import express from 'express'; import crypto from 'crypto'; import { all, get } from '../database/db.js'; const router = express.Router(); // Subsonic API version const API_VERSION = '1.16.1'; // Simple auth for now - you can enhance this later const USERS = { admin: { password: 'admin', salt: '' } // Change these! }; // Middleware to handle Subsonic authentication function authenticateSubsonic(req, res, next) { const { u: username, p: password, t: token, s: salt, c: client } = req.query; console.log(`🔐 Subsonic auth attempt - User: ${username}, Client: ${client || 'unknown'}`); if (!username) { console.log('❌ Auth failed: No username provided'); return sendSubsonicError(res, 10, 'Required parameter is missing'); } const user = USERS[username]; if (!user) { console.log(`❌ Auth failed: User '${username}' not found`); return sendSubsonicError(res, 40, 'Wrong username or password'); } // Check authentication let authenticated = false; if (token && salt) { // Token-based auth: token = md5(password + salt) const expectedToken = crypto.createHash('md5').update(user.password + salt).digest('hex'); authenticated = token === expectedToken; console.log(`🔑 Token auth: ${authenticated ? 'SUCCESS' : 'FAILED'}`); } else if (password) { // Plain password or enc: prefix const plainPassword = password.startsWith('enc:') ? Buffer.from(password.slice(4), 'hex').toString() : password; authenticated = plainPassword === user.password; console.log(`🔑 Password auth: ${authenticated ? 'SUCCESS' : 'FAILED'}`); } else { console.log('❌ Auth failed: No password or token provided'); } if (!authenticated) { console.log('❌ Authentication failed'); return sendSubsonicError(res, 40, 'Wrong username or password'); } console.log('✅ Authentication successful'); req.subsonicUser = username; next(); } // Helper to send Subsonic XML response function sendSubsonicResponse(res, data) { const xml = ` ${data} `; res.type('application/xml').send(xml); } // Helper to send Subsonic error function sendSubsonicError(res, code, message) { const xml = ` `; res.type('application/xml').send(xml); } // Ping - test connectivity (no auth required for testing) router.get('/ping.view', (req, res) => { console.log('📡 Ping received'); sendSubsonicResponse(res, ''); }); // Get license info router.get('/getLicense.view', authenticateSubsonic, (req, res) => { sendSubsonicResponse(res, ` `); }); // Get music folders router.get('/getMusicFolders.view', authenticateSubsonic, async (req, res) => { sendSubsonicResponse(res, ` `); }); // Get indexes (artists) router.get('/getIndexes.view', authenticateSubsonic, async (req, res) => { try { const artists = await all(` SELECT DISTINCT artist, COUNT(*) as album_count FROM tracks GROUP BY artist ORDER BY artist `); // Group by first letter const grouped = {}; artists.forEach(artist => { const firstChar = (artist.artist || 'Unknown')[0].toUpperCase(); const letter = /[A-Z]/.test(firstChar) ? firstChar : '#'; if (!grouped[letter]) grouped[letter] = []; const artistId = crypto.createHash('md5').update(`artist-${artist.artist}`).digest('hex'); grouped[letter].push({ id: artistId, name: artist.artist, albumCount: artist.album_count }); }); let indexXml = '\n'; Object.keys(grouped).sort().forEach(letter => { indexXml += ` \n`; grouped[letter].forEach(artist => { indexXml += ` \n`; }); indexXml += ` \n`; }); indexXml += ''; sendSubsonicResponse(res, indexXml); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Get artist router.get('/getArtist.view', authenticateSubsonic, async (req, res) => { try { const { id } = req.query; // Extract artist name from ID (we encoded it) const albums = await all(` SELECT DISTINCT album, album_artist, year, (SELECT artwork_path FROM tracks t2 WHERE t2.album = tracks.album LIMIT 1) as artwork_path, COUNT(*) as track_count FROM tracks GROUP BY album, album_artist ORDER BY year DESC, album `); let artistXml = `\n`; albums.forEach(album => { const albumId = crypto.createHash('md5').update(`album-${album.album}-${album.album_artist}`).digest('hex'); const coverArt = album.artwork_path ? albumId : ''; artistXml += ` \n`; }); artistXml += ''; sendSubsonicResponse(res, artistXml); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Get album router.get('/getAlbum.view', authenticateSubsonic, async (req, res) => { try { const { id } = req.query; // Get album info - we need to find tracks that match this album ID // The album ID is a hash of "album-{album_name}-{album_artist}" // So we need to get all tracks and find which album matches this ID const allTracks = await all(`SELECT DISTINCT album, album_artist FROM tracks`); let targetAlbum = null; let targetArtist = null; for (const track of allTracks) { const testId = crypto.createHash('md5').update(`album-${track.album}-${track.album_artist}`).digest('hex'); if (testId === id) { targetAlbum = track.album; targetArtist = track.album_artist; break; } } if (!targetAlbum) { return sendSubsonicError(res, 70, 'Album not found'); } // Get tracks for this specific album const tracks = await all(` SELECT * FROM tracks WHERE album = ? AND album_artist = ? ORDER BY disc_number, track_number, title `, [targetAlbum, targetArtist]); if (tracks.length === 0) { return sendSubsonicError(res, 70, 'Album has no tracks'); } const firstTrack = tracks[0]; const coverArt = firstTrack.artwork_path ? id : ''; let albumXml = ` sum + (t.duration || 0), 0))}" `; albumXml += `created="2024-01-01T00:00:00" year="${firstTrack.year || ''}" genre="${escapeXml(firstTrack.genre || '')}">\n`; tracks.forEach(track => { albumXml += buildSongXml(track, req); }); albumXml += ''; sendSubsonicResponse(res, albumXml); } catch (error) { console.error('Error in getAlbum:', error); sendSubsonicError(res, 0, error.message); } }); // Get random songs router.get('/getRandomSongs.view', authenticateSubsonic, async (req, res) => { try { const size = parseInt(req.query.size) || 10; const tracks = await all(` SELECT * FROM tracks ORDER BY RANDOM() LIMIT ? `, [size]); let songsXml = '\n'; tracks.forEach(track => { songsXml += buildSongXml(track, req); }); songsXml += ''; sendSubsonicResponse(res, songsXml); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Stream audio file router.get('/stream.view', authenticateSubsonic, async (req, res) => { try { const { id } = req.query; const track = await get('SELECT * FROM tracks WHERE id = ?', [id]); if (!track) { return sendSubsonicError(res, 70, 'Song not found'); } const filePath = `${process.cwd()}/audio_library/${track.file_path}`; res.sendFile(filePath); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Get cover art router.get('/getCoverArt.view', authenticateSubsonic, async (req, res) => { try { const { id } = req.query; // Try to find artwork by album ID or track ID const track = await get('SELECT artwork_path FROM tracks WHERE id = ? OR id IN (SELECT id FROM tracks LIMIT 1)', [id]); if (!track || !track.artwork_path) { return res.status(404).send('Cover art not found'); } const artworkPath = `${process.cwd()}/artwork/${track.artwork_path}`; res.sendFile(artworkPath); } catch (error) { res.status(404).send('Cover art not found'); } }); // Get playlists router.get('/getPlaylists.view', authenticateSubsonic, async (req, res) => { try { const playlists = await all('SELECT * FROM playlists ORDER BY name'); let playlistsXml = '\n'; for (const playlist of playlists) { const trackCount = await get( 'SELECT COUNT(*) as count FROM playlist_tracks WHERE playlist_id = ?', [playlist.id] ); playlistsXml += ` \n`; } playlistsXml += ''; sendSubsonicResponse(res, playlistsXml); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Get playlist router.get('/getPlaylist.view', authenticateSubsonic, async (req, res) => { try { const { id } = req.query; const playlist = await get('SELECT * FROM playlists WHERE id = ?', [id]); if (!playlist) { return sendSubsonicError(res, 70, 'Playlist not found'); } const tracks = await all(` SELECT t.* FROM tracks t JOIN playlist_tracks pt ON t.id = pt.track_id WHERE pt.playlist_id = ? ORDER BY pt.position `, [id]); let playlistXml = `\n`; tracks.forEach(track => { playlistXml += buildSongXml(track, req); }); playlistXml += ''; sendSubsonicResponse(res, playlistXml); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Search router.get('/search3.view', authenticateSubsonic, async (req, res) => { try { const { query } = req.query; const searchTerm = `%${query}%`; const tracks = await all(` SELECT * FROM tracks WHERE title LIKE ? OR artist LIKE ? OR album LIKE ? LIMIT 20 `, [searchTerm, searchTerm, searchTerm]); let searchXml = '\n'; tracks.forEach(track => { searchXml += buildSongXml(track, req); }); searchXml += ''; sendSubsonicResponse(res, searchXml); } catch (error) { sendSubsonicError(res, 0, error.message); } }); // Helper to build song XML function buildSongXml(track, req) { const albumId = crypto.createHash('md5').update(`album-${track.album}-${track.album_artist}`).digest('hex'); const artistId = crypto.createHash('md5').update(`artist-${track.artist}`).digest('hex'); const coverArt = track.artwork_path ? track.id : ''; let xml = ` \n`; return xml; } // Helper to escape XML function escapeXml(unsafe) { if (!unsafe) return ''; return unsafe.toString() .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export default router;