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;