Initial CarPlay audio server implementation with file management, playlists, and album/single classification
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user