diff --git a/SUBSONIC_SETUP.md b/SUBSONIC_SETUP.md
new file mode 100644
index 0000000..bb915c8
--- /dev/null
+++ b/SUBSONIC_SETUP.md
@@ -0,0 +1,180 @@
+# Subsonic API Setup Guide
+
+Your server now supports the Subsonic API! This means you can use existing music apps with CarPlay support.
+
+## Recommended Apps
+
+### iOS Apps with CarPlay Support
+
+1. **Amperfy** (FREE, Open Source)
+ - Download: https://apps.apple.com/app/amperfy/id1530633066
+ - Best free option
+ - Full CarPlay support
+ - Offline caching
+
+2. **play:Sub** ($4.99)
+ - Download: https://apps.apple.com/app/play-sub/id955329386
+ - Polished interface
+ - Excellent CarPlay integration
+ - Worth the price
+
+3. **Substreamer** ($4.99)
+ - Download: https://apps.apple.com/app/substreamer/id1012991665
+ - Another solid option
+
+## Server Configuration
+
+### Default Credentials
+```
+Username: admin
+Password: admin
+```
+
+**⚠️ IMPORTANT: Change these credentials!**
+
+Edit `src/routes/subsonic.js` and update:
+```javascript
+const USERS = {
+ admin: { password: 'your-secure-password', salt: '' }
+};
+```
+
+### Server URL
+```
+http://your-server-ip:3000/rest
+```
+
+Example: `http://192.168.1.100:3000/rest`
+
+## Setup Instructions
+
+### 1. Start Your Server
+```bash
+npm install
+npm run scan # Scan your audio library
+npm start # Start the server
+```
+
+### 2. Find Your Server IP
+```bash
+# On macOS/Linux
+ifconfig | grep "inet "
+
+# On Windows
+ipconfig
+```
+
+Look for your local IP (usually 192.168.x.x)
+
+### 3. Configure Your App
+
+#### For Amperfy:
+1. Open Amperfy
+2. Tap "Add Server"
+3. Enter:
+ - **Server URL**: `http://YOUR-IP:3000/rest`
+ - **Username**: `admin`
+ - **Password**: `admin`
+4. Tap "Connect"
+5. Wait for library to sync
+
+#### For play:Sub:
+1. Open play:Sub
+2. Tap "+" to add server
+3. Enter:
+ - **Server Address**: `http://YOUR-IP:3000`
+ - **Username**: `admin`
+ - **Password**: `admin`
+ - **Port**: `3000`
+ - **Path**: `/rest`
+4. Tap "Done"
+
+### 4. Test CarPlay
+1. Connect your iPhone to CarPlay (cable or wireless)
+2. Open the music app on CarPlay display
+3. Browse your library
+4. Play music!
+
+## Supported Features
+
+✅ Browse by artist, album, folder
+✅ Search tracks
+✅ Playlists
+✅ Album artwork
+✅ Audio streaming
+✅ Random songs
+✅ CarPlay integration (via apps)
+
+## Troubleshooting
+
+### Can't Connect
+- Make sure server is running (`npm start`)
+- Check firewall settings
+- Verify IP address is correct
+- Try `http://localhost:3000/rest` if on same machine
+
+### No Music Showing
+- Run `npm run scan` to scan your library
+- Check that audio files are in `audio_library/` folder
+- Look at server logs for errors
+
+### Authentication Failed
+- Double-check username/password
+- Make sure you're using the credentials from `src/routes/subsonic.js`
+
+### CarPlay Not Working
+- Make sure you're using an app with CarPlay support (Amperfy, play:Sub)
+- Check that CarPlay is enabled in iPhone Settings
+- Try disconnecting and reconnecting CarPlay
+
+## Security Notes
+
+### For Local Network Only
+Current setup is fine for home network use.
+
+### For Internet Access
+If exposing to internet:
+1. Change default password immediately
+2. Use HTTPS (add SSL certificate)
+3. Consider adding proper user authentication
+4. Use a reverse proxy (nginx)
+5. Set up firewall rules
+
+## API Endpoints
+
+Your server now responds to Subsonic API calls:
+
+- `/rest/ping.view` - Test connection
+- `/rest/getMusicFolders.view` - Get folders
+- `/rest/getIndexes.view` - Get artists
+- `/rest/getAlbum.view` - Get album details
+- `/rest/stream.view` - Stream audio
+- `/rest/getCoverArt.view` - Get artwork
+- `/rest/getPlaylists.view` - Get playlists
+- `/rest/search3.view` - Search
+
+All endpoints require authentication via query parameters:
+- `u` - username
+- `p` - password (or `t` + `s` for token auth)
+
+## Next Steps
+
+1. Install Amperfy or play:Sub on your iPhone
+2. Configure with your server details
+3. Let it sync your library
+4. Connect to CarPlay and enjoy!
+
+## Need Help?
+
+Check server logs for errors:
+```bash
+npm start
+# Watch the console output
+```
+
+Test the API directly:
+```bash
+curl "http://localhost:3000/rest/ping.view?u=admin&p=admin"
+```
+
+Should return XML with `status="ok"`
diff --git a/src/routes/subsonic.js b/src/routes/subsonic.js
new file mode 100644
index 0000000..5e20ebb
--- /dev/null
+++ b/src/routes/subsonic.js
@@ -0,0 +1,371 @@
+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 } = req.query;
+
+ if (!username) {
+ return sendSubsonicError(res, 10, 'Required parameter is missing');
+ }
+
+ const user = USERS[username];
+ if (!user) {
+ 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;
+ } 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;
+ }
+
+ if (!authenticated) {
+ return sendSubsonicError(res, 40, 'Wrong username or password');
+ }
+
+ 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
+router.get('/ping.view', (req, res) => {
+ 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 from first track
+ const firstTrack = await get(`
+ SELECT album, album_artist, year, genre
+ FROM tracks
+ LIMIT 1
+ `);
+
+ const tracks = await all(`
+ SELECT * FROM tracks
+ ORDER BY disc_number, track_number, title
+ `);
+
+ const coverArt = tracks[0]?.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) {
+ 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;
diff --git a/src/server.js b/src/server.js
index c9b76e8..c1eb8c4 100644
--- a/src/server.js
+++ b/src/server.js
@@ -6,6 +6,7 @@ import tracksRouter from './routes/tracks.js';
import playlistsRouter from './routes/playlists.js';
import albumsRouter from './routes/albums.js';
import uploadRouter from './routes/upload.js';
+import subsonicRouter from './routes/subsonic.js';
import { initDatabase } from './database/db.js';
const __filename = fileURLToPath(import.meta.url);
@@ -27,6 +28,9 @@ app.use('/api/playlists', playlistsRouter);
app.use('/api/albums', albumsRouter);
app.use('/api/upload', uploadRouter);
+// Subsonic API
+app.use('/rest', subsonicRouter);
+
// Serve audio files
app.use('/audio', express.static(path.join(process.cwd(), 'audio_library')));
app.use('/artwork', express.static(path.join(process.cwd(), 'artwork')));
diff --git a/tasks.md b/tasks.md
index 384f6d6..d471280 100644
--- a/tasks.md
+++ b/tasks.md
@@ -14,10 +14,13 @@
- [x] File upload endpoint
- [x] Album grouping and management
+## In Progress
+- [x] Subsonic API implementation
+
## Todo
-- [ ] Search functionality
-- [ ] User authentication
+- [ ] Enhanced user authentication
- [ ] Multiple user support
- [ ] Advanced filtering
- [ ] Caching layer
- [ ] Transcoding support
+- [ ] HTTPS/SSL support