Initial CarPlay audio server implementation with file management, playlists, and album/single classification
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PORT=3000
|
||||||
|
LIBRARY_PATH=./audio_library
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
audio_library/
|
||||||
|
uploads/
|
||||||
|
.DS_Store
|
||||||
180
README.md
Normal file
180
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
25
package.json
Normal file
25
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
82
server-api-example.md
Normal file
82
server-api-example.md
Normal file
@@ -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"
|
||||||
|
```
|
||||||
87
src/database/db.js
Normal file
87
src/database/db.js
Normal file
@@ -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 };
|
||||||
105
src/routes/albums.js
Normal file
105
src/routes/albums.js
Normal file
@@ -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;
|
||||||
173
src/routes/playlists.js
Normal file
173
src/routes/playlists.js
Normal file
@@ -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;
|
||||||
157
src/routes/tracks.js
Normal file
157
src/routes/tracks.js
Normal file
@@ -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;
|
||||||
92
src/routes/upload.js
Normal file
92
src/routes/upload.js
Normal file
@@ -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;
|
||||||
16
src/scripts/scanLibrary.js
Normal file
16
src/scripts/scanLibrary.js
Normal file
@@ -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);
|
||||||
42
src/server.js
Normal file
42
src/server.js
Normal file
@@ -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')}`);
|
||||||
|
});
|
||||||
174
src/services/audioScanner.js
Normal file
174
src/services/audioScanner.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tasks.md
Normal file
23
tasks.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user