From 58bb44dfb0f372e04d4000377179726b35c0fbcf Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 7 Dec 2025 22:27:47 +0000 Subject: [PATCH] Initial CarPlay audio server implementation with file management, playlists, and album/single classification --- .env.example | 2 + .gitignore | 6 ++ README.md | 180 +++++++++++++++++++++++++++++++++++ package.json | 25 +++++ server-api-example.md | 82 ++++++++++++++++ src/database/db.js | 87 +++++++++++++++++ src/routes/albums.js | 105 ++++++++++++++++++++ src/routes/playlists.js | 173 +++++++++++++++++++++++++++++++++ src/routes/tracks.js | 157 ++++++++++++++++++++++++++++++ src/routes/upload.js | 92 ++++++++++++++++++ src/scripts/scanLibrary.js | 16 ++++ src/server.js | 42 ++++++++ src/services/audioScanner.js | 174 +++++++++++++++++++++++++++++++++ tasks.md | 23 +++++ 14 files changed, 1164 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 server-api-example.md create mode 100644 src/database/db.js create mode 100644 src/routes/albums.js create mode 100644 src/routes/playlists.js create mode 100644 src/routes/tracks.js create mode 100644 src/routes/upload.js create mode 100644 src/scripts/scanLibrary.js create mode 100644 src/server.js create mode 100644 src/services/audioScanner.js create mode 100644 tasks.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3965f93 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=3000 +LIBRARY_PATH=./audio_library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9d7384 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.db +.env +audio_library/ +uploads/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e469fca --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# CarPlay Audio Server + +A Node.js server application for managing and streaming audio files to CarPlay-compatible clients. + +## Features + +### Audio Management +- šŸŽµ Automatic library scanning with metadata extraction +- šŸ“ Folder-based organization +- šŸŽ¼ Album vs Single classification +- šŸŽØ Artwork extraction and serving +- šŸ“ ID3 tag parsing (title, artist, album, year, genre, etc.) +- šŸ“¤ File upload support + +### Organization +- šŸ“€ Album management with track grouping +- šŸŽÆ Singles collection +- šŸ“‹ Custom playlists +- šŸ—‚ļø Folder browsing + +### Streaming +- šŸš— CarPlay-compatible REST API +- šŸŽ§ Direct audio file streaming +- šŸ–¼ļø Album artwork delivery +- ⚔ Efficient file serving + +## Requirements +- Node.js 16+ +- npm or yarn + +## Installation + +```bash +# Install dependencies +npm install + +# Create audio library directory +mkdir -p audio_library + +# Copy your audio files to audio_library/ +# Organize them in folders as you like +``` + +## Usage + +### 1. Scan Your Library + +```bash +# Scan default library (./audio_library) +npm run scan + +# Scan custom path +node src/scripts/scanLibrary.js /path/to/your/music +``` + +This will: +- Find all audio files (mp3, m4a, flac, wav, aac, ogg) +- Extract metadata from ID3 tags +- Detect albums vs singles +- Extract and save artwork +- Create database entries + +### 2. Start the Server + +```bash +# Production +npm start + +# Development (with auto-reload) +npm run dev +``` + +Server runs on `http://localhost:3000` + +## API Endpoints + +### Tracks +- `GET /api/tracks` - Get all tracks + - Query params: `?album=`, `?artist=`, `?folder=`, `?single=true/false` +- `GET /api/tracks/:id` - Get single track +- `PATCH /api/tracks/:id` - Update track metadata +- `GET /api/tracks/folders/list` - List all folders + +### Albums +- `GET /api/albums` - Get all albums +- `GET /api/albums/:id` - Get album with tracks +- `GET /api/albums/singles/list` - Get all singles + +### Playlists +- `GET /api/playlists` - Get all playlists +- `POST /api/playlists` - Create playlist +- `GET /api/playlists/:id` - Get playlist with tracks +- `POST /api/playlists/:id/tracks` - Add track to playlist +- `DELETE /api/playlists/:id/tracks/:trackId` - Remove track +- `DELETE /api/playlists/:id` - Delete playlist + +### Upload +- `POST /api/upload` - Upload audio file + - Form data: `audio` (file), `isSingle` (boolean) + +### Static Files +- `/audio/*` - Stream audio files +- `/artwork/*` - Serve album artwork + +## Library Organization + +The server supports flexible organization: + +``` +audio_library/ +ā”œā”€ā”€ Artist Name/ +│ ā”œā”€ā”€ Album Name/ +│ │ ā”œā”€ā”€ 01 - Track.mp3 +│ │ └── 02 - Track.mp3 +│ └── Singles/ +│ └── Single Track.mp3 +└── Various Artists/ + └── Compilation Album/ +``` + +### Album vs Single Detection + +Files are marked as singles if: +- Located in a folder named "singles" or "single" +- No album tag in metadata +- You manually mark them via API + +Update track classification: +```bash +curl -X PATCH http://localhost:3000/api/tracks/:id \ + -H "Content-Type: application/json" \ + -d '{"isSingle": true}' +``` + +## CarPlay Client Integration + +Your CarPlay client should: + +1. Fetch tracks: `GET /api/tracks` +2. Get stream URL from response +3. Play using AVPlayer with the `streamURL` +4. Display artwork from `artworkURL` + +Example response: +```json +{ + "tracks": [ + { + "id": "abc123", + "title": "Song Title", + "artist": "Artist Name", + "album": "Album Name", + "duration": 180, + "streamURL": "http://localhost:3000/audio/path/to/file.mp3", + "artworkURL": "http://localhost:3000/artwork/abc123.jpg" + } + ] +} +``` + +## Project Structure + +``` +ā”œā”€ā”€ src/ +│ ā”œā”€ā”€ server.js # Main server +│ ā”œā”€ā”€ database/ +│ │ └── db.js # SQLite database +│ ā”œā”€ā”€ routes/ +│ │ ā”œā”€ā”€ tracks.js # Track endpoints +│ │ ā”œā”€ā”€ albums.js # Album endpoints +│ │ ā”œā”€ā”€ playlists.js # Playlist endpoints +│ │ └── upload.js # Upload endpoint +│ ā”œā”€ā”€ services/ +│ │ └── audioScanner.js # Library scanner +│ └── scripts/ +│ └── scanLibrary.js # CLI scanner +ā”œā”€ā”€ audio_library/ # Your audio files +ā”œā”€ā”€ artwork/ # Extracted artwork +└── audio_server.db # SQLite database +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..05c8315 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "carplay-audio-server", + "version": "1.0.0", + "description": "Audio streaming server for CarPlay with file management, playlists, and album organization", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "scan": "node src/scripts/scanLibrary.js" + }, + "keywords": ["carplay", "audio", "streaming", "server"], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "music-metadata": "^8.1.4", + "sqlite3": "^5.1.6", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/server-api-example.md b/server-api-example.md new file mode 100644 index 0000000..bbf9fa5 --- /dev/null +++ b/server-api-example.md @@ -0,0 +1,82 @@ +# Server API Example + +Your server should provide an API endpoint that returns audio track information. Here's what the app expects: + +## Endpoint: GET /api/tracks + +Returns a JSON array of audio tracks with their streaming URLs. + +### Response Format: + +```json +{ + "tracks": [ + { + "id": "unique-track-id-1", + "title": "Song Title", + "artist": "Artist Name", + "album": "Album Name", + "duration": 180, + "streamURL": "http://your-server.com/audio/track1.mp3", + "artworkURL": "http://your-server.com/artwork/track1.jpg" + }, + { + "id": "unique-track-id-2", + "title": "Another Song", + "artist": "Another Artist", + "album": "Another Album", + "duration": 240, + "streamURL": "http://your-server.com/audio/track2.mp3", + "artworkURL": "http://your-server.com/artwork/track2.jpg" + } + ] +} +``` + +### Field Descriptions: + +- `id`: Unique identifier for the track +- `title`: Track title +- `artist`: Artist name +- `album`: Album name (optional) +- `duration`: Track duration in seconds +- `streamURL`: Direct URL to the audio file (mp3, m4a, etc.) +- `artworkURL`: URL to album artwork image (optional) + +## Audio File Requirements: + +- Supported formats: MP3, M4A, AAC, WAV +- Files should be accessible via HTTP/HTTPS +- Consider using HTTP range requests for better streaming performance +- HTTPS recommended for production + +## Example Server Implementation (Node.js/Express): + +```javascript +app.get('/api/tracks', (req, res) => { + const tracks = [ + { + id: '1', + title: 'My Song', + artist: 'My Artist', + album: 'My Album', + duration: 180, + streamURL: 'http://localhost:3000/audio/song1.mp3', + artworkURL: 'http://localhost:3000/artwork/song1.jpg' + } + ]; + + res.json({ tracks }); +}); + +app.use('/audio', express.static('path/to/audio/files')); +app.use('/artwork', express.static('path/to/artwork/files')); +``` + +## Configuration: + +Update the `baseURL` in `CarPlayAudioApp/Services/AudioService.swift`: + +```swift +private let baseURL = "http://your-server.com/api" +``` diff --git a/src/database/db.js b/src/database/db.js new file mode 100644 index 0000000..6909aa0 --- /dev/null +++ b/src/database/db.js @@ -0,0 +1,87 @@ +import sqlite3 from 'sqlite3'; +import { promisify } from 'util'; + +const db = new sqlite3.Database('./audio_server.db'); + +const run = promisify(db.run.bind(db)); +const get = promisify(db.get.bind(db)); +const all = promisify(db.all.bind(db)); + +export async function initDatabase() { + await run(` + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + artist TEXT, + album TEXT, + album_artist TEXT, + track_number INTEGER, + disc_number INTEGER, + year INTEGER, + genre TEXT, + duration REAL, + file_path TEXT NOT NULL, + file_size INTEGER, + format TEXT, + bitrate INTEGER, + sample_rate INTEGER, + is_single BOOLEAN DEFAULT 0, + folder_path TEXT, + artwork_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await run(` + CREATE TABLE IF NOT EXISTS albums ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + artist TEXT, + year INTEGER, + genre TEXT, + artwork_path TEXT, + track_count INTEGER DEFAULT 0, + is_compilation BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await run(` + CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await run(` + CREATE TABLE IF NOT EXISTS playlist_tracks ( + playlist_id TEXT, + track_id TEXT, + position INTEGER, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (playlist_id, track_id), + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, + FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE CASCADE + ) + `); + + await run(` + CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album); + `); + + await run(` + CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist); + `); + + await run(` + CREATE INDEX IF NOT EXISTS idx_tracks_folder ON tracks(folder_path); + `); + + console.log('āœ… Database initialized'); +} + +export { db, run, get, all }; diff --git a/src/routes/albums.js b/src/routes/albums.js new file mode 100644 index 0000000..7151c8d --- /dev/null +++ b/src/routes/albums.js @@ -0,0 +1,105 @@ +import express from 'express'; +import { all, get } from '../database/db.js'; + +const router = express.Router(); + +// Get all albums +router.get('/', async (req, res) => { + try { + const { artist } = req.query; + + let query = 'SELECT * FROM albums WHERE 1=1'; + const params = []; + + if (artist) { + query += ' AND artist LIKE ?'; + params.push(`%${artist}%`); + } + + query += ' ORDER BY artist, year DESC, name'; + + const albums = await all(query, params); + + const albumsWithUrls = albums.map(album => ({ + id: album.id, + name: album.name, + artist: album.artist, + year: album.year, + genre: album.genre, + trackCount: album.track_count, + isCompilation: album.is_compilation === 1, + artworkURL: album.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${album.artwork_path}` : null + })); + + res.json({ albums: albumsWithUrls }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get album with tracks +router.get('/:id', async (req, res) => { + try { + const album = await get('SELECT * FROM albums WHERE id = ?', [req.params.id]); + + if (!album) { + return res.status(404).json({ error: 'Album not found' }); + } + + const tracks = await all( + 'SELECT * FROM tracks WHERE album = ? AND album_artist = ? ORDER BY disc_number, track_number', + [album.name, album.artist] + ); + + const tracksWithUrls = tracks.map(track => ({ + id: track.id, + title: track.title, + artist: track.artist, + trackNumber: track.track_number, + discNumber: track.disc_number, + duration: track.duration, + streamURL: `${req.protocol}://${req.get('host')}/audio/${track.file_path}`, + artworkURL: track.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${track.artwork_path}` : null + })); + + res.json({ + id: album.id, + name: album.name, + artist: album.artist, + year: album.year, + genre: album.genre, + trackCount: album.track_count, + artworkURL: album.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${album.artwork_path}` : null, + tracks: tracksWithUrls + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get singles +router.get('/singles/list', async (req, res) => { + try { + const tracks = await all(` + SELECT * FROM tracks + WHERE is_single = 1 + ORDER BY artist, year DESC, title + `); + + const tracksWithUrls = tracks.map(track => ({ + id: track.id, + title: track.title, + artist: track.artist, + year: track.year, + duration: track.duration, + streamURL: `${req.protocol}://${req.get('host')}/audio/${track.file_path}`, + artworkURL: track.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${track.artwork_path}` : null + })); + + res.json({ singles: tracksWithUrls }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/src/routes/playlists.js b/src/routes/playlists.js new file mode 100644 index 0000000..a5abf81 --- /dev/null +++ b/src/routes/playlists.js @@ -0,0 +1,173 @@ +import express from 'express'; +import { all, get, run } from '../database/db.js'; +import crypto from 'crypto'; + +const router = express.Router(); + +// Get all playlists +router.get('/', async (req, res) => { + try { + const playlists = await all('SELECT * FROM playlists ORDER BY name'); + + const playlistsWithCounts = await Promise.all( + playlists.map(async (playlist) => { + const result = await get( + 'SELECT COUNT(*) as count FROM playlist_tracks WHERE playlist_id = ?', + [playlist.id] + ); + return { + id: playlist.id, + name: playlist.name, + description: playlist.description, + trackCount: result.count, + createdAt: playlist.created_at, + updatedAt: playlist.updated_at + }; + }) + ); + + res.json({ playlists: playlistsWithCounts }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Create playlist +router.post('/', async (req, res) => { + try { + const { name, description } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Playlist name is required' }); + } + + const id = crypto.randomUUID(); + + await run( + 'INSERT INTO playlists (id, name, description) VALUES (?, ?, ?)', + [id, name, description || null] + ); + + res.status(201).json({ + id, + name, + description, + message: 'Playlist created' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get playlist with tracks +router.get('/:id', async (req, res) => { + try { + const playlist = await get('SELECT * FROM playlists WHERE id = ?', [req.params.id]); + + if (!playlist) { + return res.status(404).json({ error: 'Playlist not found' }); + } + + const tracks = await all(` + SELECT t.*, pt.position + FROM tracks t + JOIN playlist_tracks pt ON t.id = pt.track_id + WHERE pt.playlist_id = ? + ORDER BY pt.position + `, [req.params.id]); + + const tracksWithUrls = tracks.map(track => ({ + id: track.id, + title: track.title, + artist: track.artist, + album: track.album, + duration: track.duration, + position: track.position, + streamURL: `${req.protocol}://${req.get('host')}/audio/${track.file_path}`, + artworkURL: track.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${track.artwork_path}` : null + })); + + res.json({ + id: playlist.id, + name: playlist.name, + description: playlist.description, + trackCount: tracks.length, + tracks: tracksWithUrls + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Add track to playlist +router.post('/:id/tracks', async (req, res) => { + try { + const { trackId } = req.body; + + if (!trackId) { + return res.status(400).json({ error: 'Track ID is required' }); + } + + // Check if track exists + const track = await get('SELECT id FROM tracks WHERE id = ?', [trackId]); + if (!track) { + return res.status(404).json({ error: 'Track not found' }); + } + + // Get next position + const result = await get( + 'SELECT MAX(position) as max_pos FROM playlist_tracks WHERE playlist_id = ?', + [req.params.id] + ); + const position = (result.max_pos || 0) + 1; + + await run( + 'INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)', + [req.params.id, trackId, position] + ); + + await run( + 'UPDATE playlists SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [req.params.id] + ); + + res.json({ success: true, message: 'Track added to playlist' }); + } catch (error) { + if (error.message.includes('UNIQUE constraint')) { + res.status(400).json({ error: 'Track already in playlist' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +// Remove track from playlist +router.delete('/:id/tracks/:trackId', async (req, res) => { + try { + await run( + 'DELETE FROM playlist_tracks WHERE playlist_id = ? AND track_id = ?', + [req.params.id, req.params.trackId] + ); + + await run( + 'UPDATE playlists SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [req.params.id] + ); + + res.json({ success: true, message: 'Track removed from playlist' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete playlist +router.delete('/:id', async (req, res) => { + try { + await run('DELETE FROM playlists WHERE id = ?', [req.params.id]); + res.json({ success: true, message: 'Playlist deleted' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/src/routes/tracks.js b/src/routes/tracks.js new file mode 100644 index 0000000..16fdb0e --- /dev/null +++ b/src/routes/tracks.js @@ -0,0 +1,157 @@ +import express from 'express'; +import { all, get, run } from '../database/db.js'; +import path from 'path'; + +const router = express.Router(); + +// Get all tracks +router.get('/', async (req, res) => { + try { + const { album, artist, folder, single } = req.query; + + let query = 'SELECT * FROM tracks WHERE 1=1'; + const params = []; + + if (album) { + query += ' AND album = ?'; + params.push(album); + } + if (artist) { + query += ' AND artist LIKE ?'; + params.push(`%${artist}%`); + } + if (folder) { + query += ' AND folder_path LIKE ?'; + params.push(`${folder}%`); + } + if (single !== undefined) { + query += ' AND is_single = ?'; + params.push(single === 'true' ? 1 : 0); + } + + query += ' ORDER BY artist, album, disc_number, track_number'; + + const tracks = await all(query, params); + + const tracksWithUrls = tracks.map(track => ({ + id: track.id, + title: track.title, + artist: track.artist, + album: track.album, + albumArtist: track.album_artist, + trackNumber: track.track_number, + discNumber: track.disc_number, + year: track.year, + genre: track.genre, + duration: track.duration, + isSingle: track.is_single === 1, + streamURL: `${req.protocol}://${req.get('host')}/audio/${track.file_path}`, + artworkURL: track.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${track.artwork_path}` : null, + format: track.format, + bitrate: track.bitrate + })); + + res.json({ tracks: tracksWithUrls }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get single track +router.get('/:id', async (req, res) => { + try { + const track = await get('SELECT * FROM tracks WHERE id = ?', [req.params.id]); + + if (!track) { + return res.status(404).json({ error: 'Track not found' }); + } + + res.json({ + id: track.id, + title: track.title, + artist: track.artist, + album: track.album, + albumArtist: track.album_artist, + trackNumber: track.track_number, + discNumber: track.disc_number, + year: track.year, + genre: track.genre, + duration: track.duration, + isSingle: track.is_single === 1, + streamURL: `${req.protocol}://${req.get('host')}/audio/${track.file_path}`, + artworkURL: track.artwork_path ? `${req.protocol}://${req.get('host')}/artwork/${track.artwork_path}` : null, + format: track.format, + bitrate: track.bitrate + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Update track metadata (mark as single/album, etc.) +router.patch('/:id', async (req, res) => { + try { + const { isSingle, title, artist, album, year, genre } = req.body; + const updates = []; + const params = []; + + if (isSingle !== undefined) { + updates.push('is_single = ?'); + params.push(isSingle ? 1 : 0); + } + if (title) { + updates.push('title = ?'); + params.push(title); + } + if (artist) { + updates.push('artist = ?'); + params.push(artist); + } + if (album) { + updates.push('album = ?'); + params.push(album); + } + if (year) { + updates.push('year = ?'); + params.push(year); + } + if (genre) { + updates.push('genre = ?'); + params.push(genre); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No updates provided' }); + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(req.params.id); + + await run( + `UPDATE tracks SET ${updates.join(', ')} WHERE id = ?`, + params + ); + + res.json({ success: true, message: 'Track updated' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get folders +router.get('/folders/list', async (req, res) => { + try { + const folders = await all(` + SELECT DISTINCT folder_path, COUNT(*) as track_count + FROM tracks + GROUP BY folder_path + ORDER BY folder_path + `); + + res.json({ folders }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/src/routes/upload.js b/src/routes/upload.js new file mode 100644 index 0000000..55c0a06 --- /dev/null +++ b/src/routes/upload.js @@ -0,0 +1,92 @@ +import express from 'express'; +import multer from 'multer'; +import path from 'path'; +import { parseFile } from 'music-metadata'; +import crypto from 'crypto'; +import { run } from '../database/db.js'; + +const router = express.Router(); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadPath = path.join(process.cwd(), 'audio_library', 'uploads'); + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${file.originalname}`; + cb(null, uniqueName); + } +}); + +const upload = multer({ + storage, + fileFilter: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (['.mp3', '.m4a', '.flac', '.wav', '.aac'].includes(ext)) { + cb(null, true); + } else { + cb(new Error('Only audio files are allowed')); + } + }, + limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit +}); + +router.post('/', upload.single('audio'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const { isSingle } = req.body; + const filePath = req.file.path; + const metadata = await parseFile(filePath); + + const id = crypto.createHash('md5').update(filePath).digest('hex'); + const relativePath = path.relative(path.join(process.cwd(), 'audio_library'), filePath); + + const track = { + id, + title: metadata.common.title || path.basename(req.file.originalname, path.extname(req.file.originalname)), + artist: metadata.common.artist || 'Unknown Artist', + album: metadata.common.album || (isSingle === 'true' ? 'Singles' : 'Unknown Album'), + album_artist: metadata.common.albumartist || metadata.common.artist, + track_number: metadata.common.track?.no || null, + year: metadata.common.year || null, + genre: metadata.common.genre?.[0] || null, + duration: metadata.format.duration || 0, + file_path: relativePath, + file_size: req.file.size, + format: metadata.format.container || path.extname(req.file.originalname).slice(1), + is_single: isSingle === 'true' ? 1 : 0, + folder_path: 'uploads' + }; + + await run(` + INSERT INTO tracks ( + id, title, artist, album, album_artist, track_number, + year, genre, duration, file_path, file_size, format, + is_single, folder_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + track.id, track.title, track.artist, track.album, track.album_artist, + track.track_number, track.year, track.genre, track.duration, + track.file_path, track.file_size, track.format, track.is_single, + track.folder_path + ]); + + res.status(201).json({ + success: true, + message: 'File uploaded successfully', + track: { + id: track.id, + title: track.title, + artist: track.artist, + album: track.album + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/src/scripts/scanLibrary.js b/src/scripts/scanLibrary.js new file mode 100644 index 0000000..fb5eeba --- /dev/null +++ b/src/scripts/scanLibrary.js @@ -0,0 +1,16 @@ +import { AudioScanner } from '../services/audioScanner.js'; +import { initDatabase } from '../database/db.js'; +import path from 'path'; + +const libraryPath = process.argv[2] || path.join(process.cwd(), 'audio_library'); + +console.log('šŸŽµ CarPlay Audio Library Scanner'); +console.log('================================\n'); + +await initDatabase(); + +const scanner = new AudioScanner(libraryPath); +await scanner.scanLibrary(); + +console.log('\nāœ… Library scan complete!'); +process.exit(0); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..c9b76e8 --- /dev/null +++ b/src/server.js @@ -0,0 +1,42 @@ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { fileURLToPath } from 'url'; +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 { initDatabase } from './database/db.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Initialize database +await initDatabase(); + +// Routes +app.use('/api/tracks', tracksRouter); +app.use('/api/playlists', playlistsRouter); +app.use('/api/albums', albumsRouter); +app.use('/api/upload', uploadRouter); + +// Serve audio files +app.use('/audio', express.static(path.join(process.cwd(), 'audio_library'))); +app.use('/artwork', express.static(path.join(process.cwd(), 'artwork'))); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', message: 'CarPlay Audio Server is running' }); +}); + +app.listen(PORT, () => { + console.log(`šŸŽµ CarPlay Audio Server running on port ${PORT}`); + console.log(`šŸ“ Audio library: ${path.join(process.cwd(), 'audio_library')}`); +}); diff --git a/src/services/audioScanner.js b/src/services/audioScanner.js new file mode 100644 index 0000000..fcfde23 --- /dev/null +++ b/src/services/audioScanner.js @@ -0,0 +1,174 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { parseFile } from 'music-metadata'; +import crypto from 'crypto'; +import { run, all } from '../database/db.js'; + +const AUDIO_EXTENSIONS = ['.mp3', '.m4a', '.flac', '.wav', '.aac', '.ogg']; + +export class AudioScanner { + constructor(libraryPath) { + this.libraryPath = libraryPath; + } + + async scanLibrary() { + console.log(`šŸ” Scanning library: ${this.libraryPath}`); + const files = await this.findAudioFiles(this.libraryPath); + console.log(`šŸ“ Found ${files.length} audio files`); + + let processed = 0; + for (const filePath of files) { + try { + await this.processAudioFile(filePath); + processed++; + if (processed % 10 === 0) { + console.log(`ā³ Processed ${processed}/${files.length} files`); + } + } catch (error) { + console.error(`āŒ Error processing ${filePath}:`, error.message); + } + } + + await this.updateAlbums(); + console.log(`āœ… Scan complete! Processed ${processed} files`); + } + + async findAudioFiles(dir, fileList = []) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + await this.findAudioFiles(fullPath, fileList); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (AUDIO_EXTENSIONS.includes(ext)) { + fileList.push(fullPath); + } + } + } + + return fileList; + } + + async processAudioFile(filePath) { + const metadata = await parseFile(filePath); + const stats = await fs.stat(filePath); + const relativePath = path.relative(this.libraryPath, filePath); + const folderPath = path.dirname(relativePath); + + const id = this.generateId(filePath); + const isSingle = this.detectSingle(metadata, folderPath); + + const track = { + id, + title: metadata.common.title || path.basename(filePath, path.extname(filePath)), + artist: metadata.common.artist || 'Unknown Artist', + album: metadata.common.album || (isSingle ? 'Singles' : 'Unknown Album'), + album_artist: metadata.common.albumartist || metadata.common.artist, + track_number: metadata.common.track?.no || null, + disc_number: metadata.common.disk?.no || null, + year: metadata.common.year || null, + genre: metadata.common.genre?.[0] || null, + duration: metadata.format.duration || 0, + file_path: relativePath, + file_size: stats.size, + format: metadata.format.container || path.extname(filePath).slice(1), + bitrate: metadata.format.bitrate || null, + sample_rate: metadata.format.sampleRate || null, + is_single: isSingle ? 1 : 0, + folder_path: folderPath, + artwork_path: null + }; + + await this.saveTrack(track); + + // Extract and save artwork if available + if (metadata.common.picture && metadata.common.picture.length > 0) { + await this.saveArtwork(id, metadata.common.picture[0]); + } + } + + detectSingle(metadata, folderPath) { + // Check if folder name contains "singles" or "single" + if (/singles?/i.test(folderPath)) { + return true; + } + + // If no album tag, likely a single + if (!metadata.common.album) { + return true; + } + + // Could add more heuristics here + return false; + } + + async saveTrack(track) { + await run(` + INSERT OR REPLACE INTO tracks ( + id, title, artist, album, album_artist, track_number, disc_number, + year, genre, duration, file_path, file_size, format, bitrate, + sample_rate, is_single, folder_path, artwork_path, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, [ + track.id, track.title, track.artist, track.album, track.album_artist, + track.track_number, track.disc_number, track.year, track.genre, + track.duration, track.file_path, track.file_size, track.format, + track.bitrate, track.sample_rate, track.is_single, track.folder_path, + track.artwork_path + ]); + } + + async saveArtwork(trackId, picture) { + const artworkDir = path.join(process.cwd(), 'artwork'); + await fs.mkdir(artworkDir, { recursive: true }); + + const ext = picture.format.split('/')[1] || 'jpg'; + const filename = `${trackId}.${ext}`; + const artworkPath = path.join(artworkDir, filename); + + await fs.writeFile(artworkPath, picture.data); + + await run('UPDATE tracks SET artwork_path = ? WHERE id = ?', [filename, trackId]); + } + + async updateAlbums() { + // Group tracks by album and create album entries + const albums = await all(` + SELECT + album, + album_artist, + year, + genre, + COUNT(*) as track_count, + MAX(artwork_path) as artwork_path + FROM tracks + WHERE is_single = 0 + GROUP BY album, album_artist + `); + + for (const album of albums) { + const albumId = this.generateId(`${album.album}-${album.album_artist}`); + + await run(` + INSERT OR REPLACE INTO albums ( + id, name, artist, year, genre, track_count, artwork_path + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, [ + albumId, + album.album, + album.album_artist, + album.year, + album.genre, + album.track_count, + album.artwork_path + ]); + } + } + + generateId(input) { + return crypto.createHash('md5').update(input).digest('hex'); + } +} diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..384f6d6 --- /dev/null +++ b/tasks.md @@ -0,0 +1,23 @@ +# CarPlay Audio Server - Tasks + +## Completed Tasks +- [x] Server application setup (Node.js/Express) +- [x] Audio file management system +- [x] Folder/directory scanning +- [x] Metadata extraction (ID3 tags) +- [x] Album vs Single classification +- [x] Playlist management +- [x] REST API for CarPlay clients +- [x] Audio streaming endpoints +- [x] Database for track metadata (SQLite) +- [x] Artwork extraction and serving +- [x] File upload endpoint +- [x] Album grouping and management + +## Todo +- [ ] Search functionality +- [ ] User authentication +- [ ] Multiple user support +- [ ] Advanced filtering +- [ ] Caching layer +- [ ] Transcoding support