Compare commits

...

10 Commits

24 changed files with 855 additions and 10 deletions
+5 -1
View File
@@ -13,7 +13,11 @@ app.use(
credentials: true,
})
);
app.use(helmet());
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
})
);
app.use(express.json({ limit: '2mb' }));
app.use(cookieParser());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
+37 -1
View File
@@ -1,5 +1,14 @@
import fs from 'fs';
import Track from '../models/Track.js';
const removeFile = (file) => {
if (!file?.path) {
return;
}
fs.unlink(file.path, () => {});
};
export const adminController = {
async addTrack(req, res, next) {
try {
@@ -11,7 +20,33 @@ export const adminController = {
.json({ success: false, message: 'All fields are required' });
}
const coverUrl = req.file ? `/uploads/covers/${req.file.filename}` : '';
const coverFile = req.files?.cover?.[0];
const audioFile = req.files?.audio?.[0];
if (!coverFile || !audioFile) {
removeFile(coverFile);
removeFile(audioFile);
return res
.status(400)
.json({
success: false,
message: 'Cover and audio files are required',
});
}
if (coverFile.size > 5 * 1024 * 1024) {
removeFile(coverFile);
removeFile(audioFile);
return res
.status(400)
.json({
success: false,
message: 'Cover image must be 5MB or smaller',
});
}
const coverUrl = `/uploads/covers/${coverFile.filename}`;
const audioUrl = `/uploads/audio/${audioFile.filename}`;
const track = await Track.create({
title,
@@ -19,6 +54,7 @@ export const adminController = {
album,
genre,
coverUrl,
audioUrl,
createdBy: req.userId,
});
+81
View File
@@ -0,0 +1,81 @@
import mongoose from 'mongoose';
import Track from '../models/Track.js';
import Album from '../models/Album.js';
export const albumController = {
async list(req, res, next) {
try {
const albums = await Album.find()
.sort({ createdAt: -1 })
.populate('tracks')
.lean();
res.json({ success: true, data: albums });
} catch (error) {
next(error);
}
},
async getById(req, res, next) {
try {
const { albumId } = req.params;
const decodedId = decodeURIComponent(albumId);
if (mongoose.Types.ObjectId.isValid(decodedId)) {
const album = await Album.findById(decodedId)
.populate('tracks')
.lean();
if (album) {
return res.json({ success: true, data: album });
}
}
const tracks = await Track.find({ album: decodedId })
.sort({ createdAt: -1 })
.lean();
if (!tracks.length) {
return res.status(404).json({ success: false, message: 'Album not found' });
}
const data = {
_id: decodedId,
title: decodedId,
artist: tracks[0].artist,
coverUrl: tracks.find(t => t.coverUrl)?.coverUrl || null,
releaseYear: null,
tracks,
};
return res.json({ success: true, data });
} catch (error) {
next(error);
}
},
async getByName(req, res, next) {
try {
const { albumTitle } = req.params;
const decoded = decodeURIComponent(albumTitle);
const tracks = await Track.find({ album: decoded })
.sort({ createdAt: -1 })
.lean();
if (!tracks.length) {
return res.status(404).json({ success: false, message: 'Album not found' });
}
const data = {
title: decoded,
artist: tracks[0].artist,
coverUrl: tracks.find(t => t.coverUrl)?.coverUrl || null,
tracks,
trackCount: tracks.length,
};
return res.json({ success: true, data });
} catch (error) {
next(error);
}
},
};
+62
View File
@@ -0,0 +1,62 @@
import Track from '../models/Track.js';
export const artistController = {
async getProfile(req, res, next) {
try {
const artistName = req.params.artistName;
if (!artistName) {
return res.status(400).json({ success: false, message: 'Artist name is required' });
}
const decodedName = decodeURIComponent(artistName);
const tracks = await Track.find({ artist: decodedName })
.select('-__v')
.sort({ createdAt: -1 })
.lean();
if (!tracks.length) {
return res.status(404).json({ success: false, message: 'Artist not found' });
}
const totalTracks = tracks.length;
const genres = [...new Set(tracks.map(t => t.genre).filter(Boolean))];
const albums = [...new Set(tracks.map(t => t.album).filter(Boolean))];
const data = {
name: decodedName,
trackCount: totalTracks,
genres,
albums,
imageUrl: tracks.find(t => t.coverUrl)?.coverUrl || null,
topTracks: tracks.slice(0, 10),
};
return res.json({ success: true, data });
} catch (error) {
return next(error);
}
},
async getTracks(req, res, next) {
try {
const artistName = req.params.artistName;
if (!artistName) {
return res.status(400).json({ success: false, message: 'Artist name is required' });
}
const decodedName = decodeURIComponent(artistName);
const tracks = await Track.find({ artist: decodedName })
.select('-__v')
.sort({ createdAt: -1 })
.lean();
return res.json({ success: true, data: tracks });
} catch (error) {
return next(error);
}
},
};
+22
View File
@@ -3,6 +3,7 @@ import {
loginUser,
refreshSession,
registerUser,
updateProfile,
} from '../services/authService.js';
const accessCookieName = 'noctune_access';
@@ -92,4 +93,25 @@ export const authController = {
clearAuthCookies(res);
res.json({ success: true, data: { message: 'Logged out' } });
},
async updateProfile(req, res, next) {
try {
const { name, username, email } = req.body;
const avatarFile = req.file;
const avatarUrl = avatarFile
? `/uploads/avatars/${avatarFile.filename}`
: undefined;
const user = await updateProfile(req.userId, {
name,
username,
email,
avatarUrl,
});
return res.json({ success: true, data: { user } });
} catch (error) {
next(error);
}
},
};
+150
View File
@@ -0,0 +1,150 @@
import Playlist from '../models/Playlist.js';
import Track from '../models/Track.js';
export const playlistController = {
async list(req, res, next) {
try {
const playlists = await Playlist.find({ user: req.userId })
.sort({ createdAt: -1 })
.lean();
res.json({ success: true, data: playlists });
} catch (error) {
next(error);
}
},
async getById(req, res, next) {
try {
const { playlistId } = req.params;
const playlist = await Playlist.findOne({
_id: playlistId,
user: req.userId,
})
.populate('tracks')
.lean();
if (!playlist) {
return res
.status(404)
.json({ success: false, message: 'Playlist not found' });
}
return res.json({ success: true, data: playlist });
} catch (error) {
next(error);
}
},
async create(req, res, next) {
try {
const { name, description } = req.body;
if (!name) {
return res
.status(400)
.json({ success: false, message: 'Playlist name is required' });
}
const coverFile = req.file;
const coverUrl = coverFile
? `/uploads/playlists/${coverFile.filename}`
: '';
const playlist = await Playlist.create({
name,
description,
coverUrl,
user: req.userId,
});
return res.json({ success: true, data: playlist });
} catch (error) {
next(error);
}
},
async addTrack(req, res, next) {
try {
const { playlistId } = req.params;
const { trackId } = req.body;
if (!trackId) {
return res
.status(400)
.json({ success: false, message: 'Track id is required' });
}
const exists = await Track.exists({ _id: trackId });
if (!exists) {
return res
.status(404)
.json({ success: false, message: 'Track not found' });
}
const playlist = await Playlist.findOneAndUpdate(
{ _id: playlistId, user: req.userId },
{ $addToSet: { tracks: trackId } },
{ new: true }
).lean();
if (!playlist) {
return res
.status(404)
.json({ success: false, message: 'Playlist not found' });
}
return res.json({ success: true, data: playlist });
} catch (error) {
next(error);
}
},
async removeTrack(req, res, next) {
try {
const { playlistId } = req.params;
const { trackId } = req.body;
if (!trackId) {
return res
.status(400)
.json({ success: false, message: 'Track id is required' });
}
const playlist = await Playlist.findOneAndUpdate(
{ _id: playlistId, user: req.userId },
{ $pull: { tracks: trackId } },
{ new: true }
).populate('tracks').lean();
if (!playlist) {
return res
.status(404)
.json({ success: false, message: 'Playlist not found' });
}
return res.json({ success: true, data: playlist });
} catch (error) {
next(error);
}
},
async deletePlaylist(req, res, next) {
try {
const { playlistId } = req.params;
const playlist = await Playlist.findOneAndDelete({
_id: playlistId,
user: req.userId,
});
if (!playlist) {
return res
.status(404)
.json({ success: false, message: 'Playlist not found' });
}
return res.json({ success: true, message: 'Playlist deleted' });
} catch (error) {
next(error);
}
},
};
+74
View File
@@ -0,0 +1,74 @@
import mongoose from 'mongoose';
import Track from '../models/Track.js';
export const searchController = {
async search(req, res, next) {
try {
const query = (req.query.q || '').trim();
if (!query) {
return res.json({
success: true,
data: { tracks: [], artists: [], albums: [] },
});
}
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
const tracks = await Track.find({
$or: [
{ title: regex },
{ artist: regex },
{ album: regex },
],
})
.select('-__v')
.sort({ createdAt: -1 })
.limit(50)
.lean();
const artistMap = new Map();
tracks.forEach(t => {
if (!artistMap.has(t.artist)) {
artistMap.set(t.artist, {
_id: new mongoose.Types.ObjectId(),
name: t.artist,
imageUrl: t.coverUrl || '',
trackCount: 0,
});
}
const entry = artistMap.get(t.artist);
entry.trackCount++;
if (t.coverUrl && !entry.imageUrl) {
entry.imageUrl = t.coverUrl;
}
});
const artists = [...artistMap.values()];
const albumMap = new Map();
tracks.forEach(t => {
if (!albumMap.has(t.album)) {
albumMap.set(t.album, {
_id: new mongoose.Types.ObjectId(),
title: t.album,
artist: t.artist,
coverUrl: t.coverUrl || '',
trackCount: 0,
});
}
const entry = albumMap.get(t.album);
entry.trackCount++;
if (t.coverUrl && !entry.coverUrl) {
entry.coverUrl = t.coverUrl;
}
});
const albums = [...albumMap.values()];
const data = { tracks, artists, albums };
return res.json({ success: true, data });
} catch (error) {
return next(error);
}
},
};
+94
View File
@@ -0,0 +1,94 @@
import Track from '../models/Track.js';
import User from '../models/User.js';
export const trackController = {
async byIds(req, res, next) {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) {
return res.json({ success: true, data: [] });
}
const tracks = await Track.find({ _id: { $in: ids } }).lean();
return res.json({ success: true, data: tracks });
} catch (error) {
next(error);
}
},
async list(req, res, next) {
try {
const tracks = await Track.find().sort({ createdAt: -1 }).lean();
res.json({ success: true, data: tracks });
} catch (error) {
next(error);
}
},
async listLiked(req, res, next) {
try {
const user = await User.findById(req.userId)
.select('likedTracks')
.populate('likedTracks')
.lean();
if (!user) {
return res
.status(404)
.json({ success: false, message: 'User not found' });
}
return res.json({ success: true, data: user.likedTracks || [] });
} catch (error) {
next(error);
}
},
async like(req, res, next) {
try {
const { trackId } = req.params;
const exists = await Track.exists({ _id: trackId });
if (!exists) {
return res
.status(404)
.json({ success: false, message: 'Track not found' });
}
const user = await User.findByIdAndUpdate(
req.userId,
{ $addToSet: { likedTracks: trackId } },
{ new: true }
)
.select('likedTracks')
.lean();
return res.json({
success: true,
data: { liked: true, likedTracks: user?.likedTracks ?? [] },
});
} catch (error) {
next(error);
}
},
async unlike(req, res, next) {
try {
const { trackId } = req.params;
const user = await User.findByIdAndUpdate(
req.userId,
{ $pull: { likedTracks: trackId } },
{ new: true }
)
.select('likedTracks')
.lean();
return res.json({
success: true,
data: { liked: false, likedTracks: user?.likedTracks ?? [] },
});
} catch (error) {
next(error);
}
},
};
+62
View File
@@ -0,0 +1,62 @@
import fs from 'fs';
import Track from '../models/Track.js';
const removeFile = (file) => {
if (!file?.path) {
return;
}
fs.unlink(file.path, () => {});
};
export const uploadController = {
async uploadTrack(req, res, next) {
try {
if (!req.userId) {
removeFile(req.files?.cover?.[0]);
removeFile(req.files?.audio?.[0]);
return res.status(401).json({ success: false, message: 'Authentication required to upload' });
}
const { title, artist, album, genre } = req.body;
if (!title || !artist || !album || !genre) {
removeFile(req.files?.cover?.[0]);
removeFile(req.files?.audio?.[0]);
return res.status(400).json({ success: false, message: 'All fields are required' });
}
const coverFile = req.files?.cover?.[0];
const audioFile = req.files?.audio?.[0];
if (!coverFile || !audioFile) {
removeFile(coverFile);
removeFile(audioFile);
return res.status(400).json({ success: false, message: 'Cover and audio files are required' });
}
if (coverFile.size > 5 * 1024 * 1024) {
removeFile(coverFile);
removeFile(audioFile);
return res.status(400).json({ success: false, message: 'Cover image must be 5MB or smaller' });
}
const coverUrl = `/uploads/covers/${coverFile.filename}`;
const audioUrl = `/uploads/audio/${audioFile.filename}`;
const track = await Track.create({
title,
artist,
album,
genre,
coverUrl,
audioUrl,
createdBy: req.userId,
});
return res.json({ success: true, data: track });
} catch (error) {
return next(error);
}
},
};
+17
View File
@@ -16,3 +16,20 @@ export const requireAdmin = async (req, res, next) => {
return next(error);
}
};
export const requireArtist = async (req, res, next) => {
try {
if (!req.userId) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
const user = await User.findById(req.userId).select('role').lean();
if (!user || (user.role !== 'artist' && user.role !== 'admin')) {
return res.status(403).json({ success: false, message: 'Forbidden' });
}
return next();
} catch (error) {
return next(error);
}
};
+13
View File
@@ -0,0 +1,13 @@
import mongoose from 'mongoose';
const albumSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 120 },
artist: { type: String, required: true, trim: true, maxlength: 120 },
coverUrl: { type: String, trim: true },
releaseYear: { type: Number },
genre: { type: String, trim: true, maxlength: 60 },
tracks: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Track' }],
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
}, { timestamps: true });
export default mongoose.model('Album', albumSchema);
+37
View File
@@ -0,0 +1,37 @@
import mongoose from 'mongoose';
const playlistSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
trim: true,
maxlength: 120,
},
description: {
type: String,
trim: true,
maxlength: 240,
},
coverUrl: {
type: String,
trim: true,
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
tracks: [
{
type: mongoose.Schema.Types.ObjectId,
ref: 'Track',
},
],
},
{
timestamps: true,
}
);
export default mongoose.model('Playlist', playlistSchema);
+6
View File
@@ -29,6 +29,12 @@ const trackSchema = new mongoose.Schema(
coverUrl: {
type: String,
trim: true,
required: true,
},
audioUrl: {
type: String,
trim: true,
required: true,
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
+6
View File
@@ -37,6 +37,12 @@ const userSchema = new mongoose.Schema(
type: String,
trim: true,
},
likedTracks: [
{
type: mongoose.Schema.Types.ObjectId,
ref: 'Track',
},
],
},
{
timestamps: true,
+5 -2
View File
@@ -2,7 +2,7 @@ import { Router } from 'express';
import { adminController } from '../controllers/adminController.js';
import { auth } from '../middlewares/auth.js';
import { requireAdmin } from '../middlewares/roles.js';
import { uploadCover } from '../utils/multer.js';
import { uploadTrackAssets } from '../utils/multer.js';
const router = Router();
@@ -10,7 +10,10 @@ router.post(
'/tracks',
auth,
requireAdmin,
uploadCover.single('cover'),
uploadTrackAssets.fields([
{ name: 'cover', maxCount: 1 },
{ name: 'audio', maxCount: 1 },
]),
adminController.addTrack
);
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { albumController } from '../controllers/albumController.js';
const router = Router();
router.get('/', albumController.list);
router.get('/:albumId', albumController.getById);
router.get('/title/:albumTitle', albumController.getByName);
export default router;
+7
View File
@@ -0,0 +1,7 @@
import { Router } from 'express';
import { artistController } from '../controllers/artistController.js';
const router = Router();
router.get('/:artistName', artistController.getProfile);
router.get('/:artistName/tracks', artistController.getTracks);
export default router;
+2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { authController } from '../controllers/authController.js';
import { auth } from '../middlewares/auth.js';
import { uploadAvatar } from '../utils/multer.js';
const router = Router();
@@ -9,5 +10,6 @@ router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
router.get('/me', auth, authController.me);
router.put('/profile', auth, uploadAvatar.single('avatar'), authController.updateProfile);
export default router;
+10
View File
@@ -1,5 +1,10 @@
import { Router } from 'express';
import authRoutes from './auths.js';
import trackRoutes from './tracks.js';
import playlistRoutes from './playlists.js';
import artistRoutes from './artists.js';
import albumRoutes from './albums.js';
import searchRoutes from './search.js';
import adminRoutes from './admins.js';
const router = Router();
@@ -15,6 +20,11 @@ router.get('/health', (req, res) => {
});
router.use('/auth', authRoutes);
router.use('/tracks', trackRoutes);
router.use('/playlists', playlistRoutes);
router.use('/artists', artistRoutes);
router.use('/albums', albumRoutes);
router.use('/search', searchRoutes);
router.use('/admin', adminRoutes);
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { playlistController } from '../controllers/playlistController.js';
import { auth } from '../middlewares/auth.js';
import { uploadPlaylistCover } from '../utils/multer.js';
const router = Router();
router.get('/', auth, playlistController.list);
router.get('/:playlistId', auth, playlistController.getById);
router.post(
'/',
auth,
uploadPlaylistCover.single('playlistCover'),
playlistController.create
);
router.post('/:playlistId/tracks', auth, playlistController.addTrack);
router.delete('/:playlistId/tracks', auth, playlistController.removeTrack);
router.delete('/:playlistId', auth, playlistController.deletePlaylist);
export default router;
+6
View File
@@ -0,0 +1,6 @@
import { Router } from 'express';
import { searchController } from '../controllers/searchController.js';
const router = Router();
router.get('/', searchController.search);
export default router;
+24
View File
@@ -0,0 +1,24 @@
import { Router } from 'express';
import { trackController } from '../controllers/trackController.js';
import { uploadController } from '../controllers/uploadController.js';
import { auth } from '../middlewares/auth.js';
import { uploadTrackAssets } from '../utils/multer.js';
const router = Router();
router.get('/', trackController.list);
router.post('/by-ids', trackController.byIds);
router.get('/liked', auth, trackController.listLiked);
router.post(
'/',
auth,
uploadTrackAssets.fields([
{ name: 'cover', maxCount: 1 },
{ name: 'audio', maxCount: 1 },
]),
uploadController.uploadTrack
);
router.post('/:trackId/like', auth, trackController.like);
router.delete('/:trackId/like', auth, trackController.unlike);
export default router;
+27
View File
@@ -168,3 +168,30 @@ export const getSessionUser = async (userId) => {
return user;
};
export const updateProfile = async (userId, { name, username, email, avatarUrl }) => {
const updateFields = {};
if (name !== undefined) updateFields.name = name.trim();
if (username !== undefined) updateFields.username = username.trim().toLowerCase();
if (email !== undefined) updateFields.email = email.trim().toLowerCase();
if (avatarUrl !== undefined) updateFields.avatarUrl = avatarUrl;
if (updateFields.username || updateFields.email) {
const existing = await User.findOne({
_id: { $ne: userId },
$or: [
...(updateFields.username ? [{ username: updateFields.username }] : []),
...(updateFields.email ? [{ email: updateFields.email }] : []),
],
}).lean();
if (existing) {
throw createError(409, 'Username or email already in use');
}
}
await User.updateOne({ _id: userId }, { $set: updateFields });
const user = await getPublicUser(userId);
return user;
};
+78 -6
View File
@@ -3,14 +3,45 @@ import path from 'path';
import multer from 'multer';
const coversDir = path.join(process.cwd(), 'uploads', 'covers');
const audioDir = path.join(process.cwd(), 'uploads', 'audio');
const playlistCoversDir = path.join(process.cwd(), 'uploads', 'playlists');
const avatarsDir = path.join(process.cwd(), 'uploads', 'avatars');
if (!fs.existsSync(coversDir)) {
fs.mkdirSync(coversDir, { recursive: true });
}
if (!fs.existsSync(audioDir)) {
fs.mkdirSync(audioDir, { recursive: true });
}
if (!fs.existsSync(playlistCoversDir)) {
fs.mkdirSync(playlistCoversDir, { recursive: true });
}
if (!fs.existsSync(avatarsDir)) {
fs.mkdirSync(avatarsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, coversDir);
if (file.fieldname === 'cover') {
return cb(null, coversDir);
}
if (file.fieldname === 'audio') {
return cb(null, audioDir);
}
if (file.fieldname === 'playlistCover') {
return cb(null, playlistCoversDir);
}
if (file.fieldname === 'avatar') {
return cb(null, avatarsDir);
}
return cb(new Error('Invalid upload field'));
},
filename: (req, file, cb) => {
const safeName = file.originalname.replace(/\s+/g, '-');
@@ -19,15 +50,56 @@ const storage = multer.diskStorage({
});
const imageFilter = (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only JPG, PNG, or WEBP images are allowed'));
if (file.fieldname === 'cover') {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only JPG, PNG, or WEBP images are allowed'));
}
return cb(null, true);
}
return cb(null, true);
if (file.fieldname === 'audio') {
const allowed = [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-wav',
'audio/flac',
'audio/x-flac',
];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only MP3, WAV, or FLAC audio is allowed'));
}
return cb(null, true);
}
if (file.fieldname === 'playlistCover' || file.fieldname === 'avatar') {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only JPG, PNG, or WEBP images are allowed'));
}
return cb(null, true);
}
return cb(new Error('Invalid upload field'));
};
export const uploadCover = multer({
export const uploadAvatar = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 },
});
export const uploadTrackAssets = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 50 * 1024 * 1024 },
});
export const uploadPlaylistCover = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 },