From ebb434c30ac441f7f6cc95baae02d78d5f4eeb00 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 7 Dec 2025 22:33:36 +0000 Subject: [PATCH] Add Subsonic API support for compatibility with CarPlay apps like Amperfy and play:Sub --- SUBSONIC_SETUP.md | 180 ++++++++++++++++++++ src/routes/subsonic.js | 371 +++++++++++++++++++++++++++++++++++++++++ src/server.js | 4 + tasks.md | 7 +- 4 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 SUBSONIC_SETUP.md create mode 100644 src/routes/subsonic.js 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