Add Subsonic API support for compatibility with CarPlay apps like Amperfy and play:Sub
This commit is contained in:
180
SUBSONIC_SETUP.md
Normal file
180
SUBSONIC_SETUP.md
Normal file
@@ -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"`
|
||||||
371
src/routes/subsonic.js
Normal file
371
src/routes/subsonic.js
Normal file
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="${API_VERSION}">
|
||||||
|
${data}
|
||||||
|
</subsonic-response>`;
|
||||||
|
res.type('application/xml').send(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to send Subsonic error
|
||||||
|
function sendSubsonicError(res, code, message) {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="${API_VERSION}">
|
||||||
|
<error code="${code}" message="${message}"/>
|
||||||
|
</subsonic-response>`;
|
||||||
|
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, `
|
||||||
|
<license valid="true" email="user@example.com" licenseExpires="2099-12-31T23:59:59"/>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get music folders
|
||||||
|
router.get('/getMusicFolders.view', authenticateSubsonic, async (req, res) => {
|
||||||
|
sendSubsonicResponse(res, `
|
||||||
|
<musicFolders>
|
||||||
|
<musicFolder id="1" name="Music Library"/>
|
||||||
|
</musicFolders>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 = '<indexes lastModified="0" ignoredArticles="The El La Los Las Le Les">\n';
|
||||||
|
|
||||||
|
Object.keys(grouped).sort().forEach(letter => {
|
||||||
|
indexXml += ` <index name="${letter}">\n`;
|
||||||
|
grouped[letter].forEach(artist => {
|
||||||
|
indexXml += ` <artist id="${artist.id}" name="${escapeXml(artist.name)}" albumCount="${artist.albumCount}"/>\n`;
|
||||||
|
});
|
||||||
|
indexXml += ` </index>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
indexXml += '</indexes>';
|
||||||
|
|
||||||
|
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 = `<artist id="${id}" name="Artist" albumCount="${albums.length}">\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 += ` <album id="${albumId}" name="${escapeXml(album.album)}" artist="${escapeXml(album.album_artist || 'Unknown')}" `;
|
||||||
|
artistXml += `coverArt="${coverArt}" songCount="${album.track_count}" created="2024-01-01T00:00:00" `;
|
||||||
|
artistXml += `year="${album.year || ''}" genre=""/>\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
artistXml += '</artist>';
|
||||||
|
|
||||||
|
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 = `<album id="${id}" name="${escapeXml(firstTrack?.album || 'Unknown')}" `;
|
||||||
|
albumXml += `artist="${escapeXml(firstTrack?.album_artist || 'Unknown')}" `;
|
||||||
|
albumXml += `coverArt="${coverArt}" songCount="${tracks.length}" `;
|
||||||
|
albumXml += `duration="${Math.floor(tracks.reduce((sum, t) => 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 += '</album>';
|
||||||
|
|
||||||
|
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 = '<randomSongs>\n';
|
||||||
|
tracks.forEach(track => {
|
||||||
|
songsXml += buildSongXml(track, req);
|
||||||
|
});
|
||||||
|
songsXml += '</randomSongs>';
|
||||||
|
|
||||||
|
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 = '<playlists>\n';
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
const trackCount = await get(
|
||||||
|
'SELECT COUNT(*) as count FROM playlist_tracks WHERE playlist_id = ?',
|
||||||
|
[playlist.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
playlistsXml += ` <playlist id="${playlist.id}" name="${escapeXml(playlist.name)}" `;
|
||||||
|
playlistsXml += `songCount="${trackCount.count}" duration="0" `;
|
||||||
|
playlistsXml += `created="${playlist.created_at}" changed="${playlist.updated_at}" owner="admin"/>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistsXml += '</playlists>';
|
||||||
|
|
||||||
|
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 = `<playlist id="${playlist.id}" name="${escapeXml(playlist.name)}" `;
|
||||||
|
playlistXml += `songCount="${tracks.length}" duration="0" owner="admin">\n`;
|
||||||
|
|
||||||
|
tracks.forEach(track => {
|
||||||
|
playlistXml += buildSongXml(track, req);
|
||||||
|
});
|
||||||
|
|
||||||
|
playlistXml += '</playlist>';
|
||||||
|
|
||||||
|
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 = '<searchResult3>\n';
|
||||||
|
tracks.forEach(track => {
|
||||||
|
searchXml += buildSongXml(track, req);
|
||||||
|
});
|
||||||
|
searchXml += '</searchResult3>';
|
||||||
|
|
||||||
|
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 = ` <song id="${track.id}" parent="${albumId}" `;
|
||||||
|
xml += `title="${escapeXml(track.title)}" album="${escapeXml(track.album || '')}" `;
|
||||||
|
xml += `artist="${escapeXml(track.artist || '')}" track="${track.track_number || ''}" `;
|
||||||
|
xml += `year="${track.year || ''}" genre="${escapeXml(track.genre || '')}" `;
|
||||||
|
xml += `coverArt="${coverArt}" size="${track.file_size || 0}" `;
|
||||||
|
xml += `contentType="audio/${track.format || 'mpeg'}" suffix="${track.format || 'mp3'}" `;
|
||||||
|
xml += `duration="${Math.floor(track.duration || 0)}" bitRate="${Math.floor((track.bitrate || 0) / 1000)}" `;
|
||||||
|
xml += `path="${escapeXml(track.file_path)}" isDir="false" `;
|
||||||
|
xml += `albumId="${albumId}" artistId="${artistId}" type="music"/>\n`;
|
||||||
|
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to escape XML
|
||||||
|
function escapeXml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return unsafe.toString()
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -6,6 +6,7 @@ import tracksRouter from './routes/tracks.js';
|
|||||||
import playlistsRouter from './routes/playlists.js';
|
import playlistsRouter from './routes/playlists.js';
|
||||||
import albumsRouter from './routes/albums.js';
|
import albumsRouter from './routes/albums.js';
|
||||||
import uploadRouter from './routes/upload.js';
|
import uploadRouter from './routes/upload.js';
|
||||||
|
import subsonicRouter from './routes/subsonic.js';
|
||||||
import { initDatabase } from './database/db.js';
|
import { initDatabase } from './database/db.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -27,6 +28,9 @@ app.use('/api/playlists', playlistsRouter);
|
|||||||
app.use('/api/albums', albumsRouter);
|
app.use('/api/albums', albumsRouter);
|
||||||
app.use('/api/upload', uploadRouter);
|
app.use('/api/upload', uploadRouter);
|
||||||
|
|
||||||
|
// Subsonic API
|
||||||
|
app.use('/rest', subsonicRouter);
|
||||||
|
|
||||||
// Serve audio files
|
// Serve audio files
|
||||||
app.use('/audio', express.static(path.join(process.cwd(), 'audio_library')));
|
app.use('/audio', express.static(path.join(process.cwd(), 'audio_library')));
|
||||||
app.use('/artwork', express.static(path.join(process.cwd(), 'artwork')));
|
app.use('/artwork', express.static(path.join(process.cwd(), 'artwork')));
|
||||||
|
|||||||
7
tasks.md
7
tasks.md
@@ -14,10 +14,13 @@
|
|||||||
- [x] File upload endpoint
|
- [x] File upload endpoint
|
||||||
- [x] Album grouping and management
|
- [x] Album grouping and management
|
||||||
|
|
||||||
|
## In Progress
|
||||||
|
- [x] Subsonic API implementation
|
||||||
|
|
||||||
## Todo
|
## Todo
|
||||||
- [ ] Search functionality
|
- [ ] Enhanced user authentication
|
||||||
- [ ] User authentication
|
|
||||||
- [ ] Multiple user support
|
- [ ] Multiple user support
|
||||||
- [ ] Advanced filtering
|
- [ ] Advanced filtering
|
||||||
- [ ] Caching layer
|
- [ ] Caching layer
|
||||||
- [ ] Transcoding support
|
- [ ] Transcoding support
|
||||||
|
- [ ] HTTPS/SSL support
|
||||||
|
|||||||
Reference in New Issue
Block a user