diff --git a/src/controllers/adminController.js b/src/controllers/adminController.js index 62e6ad1..06aecb1 100644 --- a/src/controllers/adminController.js +++ b/src/controllers/adminController.js @@ -28,7 +28,10 @@ export const adminController = { removeFile(audioFile); return res .status(400) - .json({ success: false, message: 'Cover and audio files are required' }); + .json({ + success: false, + message: 'Cover and audio files are required', + }); } if (coverFile.size > 5 * 1024 * 1024) { @@ -36,7 +39,10 @@ export const adminController = { removeFile(audioFile); return res .status(400) - .json({ success: false, message: 'Cover image must be 5MB or smaller' }); + .json({ + success: false, + message: 'Cover image must be 5MB or smaller', + }); } const coverUrl = `/uploads/covers/${coverFile.filename}`; diff --git a/src/controllers/artistController.js b/src/controllers/artistController.js new file mode 100644 index 0000000..4de0971 --- /dev/null +++ b/src/controllers/artistController.js @@ -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); + } + }, +}; diff --git a/src/controllers/playlistController.js b/src/controllers/playlistController.js index 968b335..c56e431 100644 --- a/src/controllers/playlistController.js +++ b/src/controllers/playlistController.js @@ -15,15 +15,22 @@ export const playlistController = { async create(req, res, next) { try { - const { name } = req.body; + 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, }); @@ -46,7 +53,9 @@ export const playlistController = { const exists = await Track.exists({ _id: trackId }); if (!exists) { - return res.status(404).json({ success: false, message: 'Track not found' }); + return res + .status(404) + .json({ success: false, message: 'Track not found' }); } const playlist = await Playlist.findOneAndUpdate( @@ -56,7 +65,9 @@ export const playlistController = { ).lean(); if (!playlist) { - return res.status(404).json({ success: false, message: 'Playlist not found' }); + return res + .status(404) + .json({ success: false, message: 'Playlist not found' }); } return res.json({ success: true, data: playlist }); diff --git a/src/controllers/searchController.js b/src/controllers/searchController.js new file mode 100644 index 0000000..182dc15 --- /dev/null +++ b/src/controllers/searchController.js @@ -0,0 +1,42 @@ +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 artistNames = [...new Set(tracks.map(t => t.artist))]; + const artists = artistNames.map(name => ({ name })); + + const albumNames = [...new Set(tracks.map(t => t.album))]; + const albums = albumNames.map(name => ({ name })); + + const data = { tracks, artists, albums }; + + return res.json({ success: true, data }); + } catch (error) { + return next(error); + } + }, +}; diff --git a/src/controllers/trackController.js b/src/controllers/trackController.js index ee4988c..f0e3c8d 100644 --- a/src/controllers/trackController.js +++ b/src/controllers/trackController.js @@ -17,7 +17,9 @@ export const trackController = { const exists = await Track.exists({ _id: trackId }); if (!exists) { - return res.status(404).json({ success: false, message: 'Track not found' }); + return res + .status(404) + .json({ success: false, message: 'Track not found' }); } const user = await User.findByIdAndUpdate( diff --git a/src/controllers/uploadController.js b/src/controllers/uploadController.js new file mode 100644 index 0000000..534306e --- /dev/null +++ b/src/controllers/uploadController.js @@ -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); + } + }, +}; diff --git a/src/middlewares/roles.js b/src/middlewares/roles.js index 6e3f104..3eed50c 100644 --- a/src/middlewares/roles.js +++ b/src/middlewares/roles.js @@ -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); + } +}; diff --git a/src/models/Album.js b/src/models/Album.js new file mode 100644 index 0000000..8fe56e6 --- /dev/null +++ b/src/models/Album.js @@ -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); diff --git a/src/models/Playlist.js b/src/models/Playlist.js index bb6e094..41527b5 100644 --- a/src/models/Playlist.js +++ b/src/models/Playlist.js @@ -8,6 +8,15 @@ const playlistSchema = new mongoose.Schema( trim: true, maxlength: 120, }, + description: { + type: String, + trim: true, + maxlength: 240, + }, + coverUrl: { + type: String, + trim: true, + }, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', diff --git a/src/routes/artists.js b/src/routes/artists.js new file mode 100644 index 0000000..2a81885 --- /dev/null +++ b/src/routes/artists.js @@ -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; diff --git a/src/routes/index.js b/src/routes/index.js index 13d30ae..f88685a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -3,6 +3,8 @@ import authRoutes from './auths.js'; import adminRoutes from './admins.js'; import trackRoutes from './tracks.js'; import playlistRoutes from './playlists.js'; +import artistRoutes from './artists.js'; +import searchRoutes from './search.js'; const router = Router(); @@ -20,5 +22,7 @@ router.use('/auth', authRoutes); router.use('/admin', adminRoutes); router.use('/tracks', trackRoutes); router.use('/playlists', playlistRoutes); +router.use('/artists', artistRoutes); +router.use('/search', searchRoutes); export default router; diff --git a/src/routes/playlists.js b/src/routes/playlists.js index 84784cd..ca44484 100644 --- a/src/routes/playlists.js +++ b/src/routes/playlists.js @@ -1,11 +1,17 @@ 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.post('/', auth, playlistController.create); +router.post( + '/', + auth, + uploadPlaylistCover.single('playlistCover'), + playlistController.create +); router.post('/:playlistId/tracks', auth, playlistController.addTrack); export default router; diff --git a/src/routes/search.js b/src/routes/search.js new file mode 100644 index 0000000..a5889be --- /dev/null +++ b/src/routes/search.js @@ -0,0 +1,6 @@ +import { Router } from 'express'; +import { searchController } from '../controllers/searchController.js'; + +const router = Router(); +router.get('/', searchController.search); +export default router; diff --git a/src/utils/multer.js b/src/utils/multer.js index c368958..d938846 100644 --- a/src/utils/multer.js +++ b/src/utils/multer.js @@ -4,6 +4,7 @@ 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'); if (!fs.existsSync(coversDir)) { fs.mkdirSync(coversDir, { recursive: true }); @@ -13,6 +14,10 @@ if (!fs.existsSync(audioDir)) { fs.mkdirSync(audioDir, { recursive: true }); } +if (!fs.existsSync(playlistCoversDir)) { + fs.mkdirSync(playlistCoversDir, { recursive: true }); +} + const storage = multer.diskStorage({ destination: (req, file, cb) => { if (file.fieldname === 'cover') { @@ -23,6 +28,10 @@ const storage = multer.diskStorage({ return cb(null, audioDir); } + if (file.fieldname === 'playlistCover') { + return cb(null, playlistCoversDir); + } + return cb(new Error('Invalid upload field')); }, filename: (req, file, cb) => { @@ -57,6 +66,15 @@ const imageFilter = (req, file, cb) => { return cb(null, true); } + if (file.fieldname === 'playlistCover') { + 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')); }; @@ -65,3 +83,9 @@ export const uploadTrackAssets = multer({ fileFilter: imageFilter, limits: { fileSize: 50 * 1024 * 1024 }, }); + +export const uploadPlaylistCover = multer({ + storage, + fileFilter: imageFilter, + limits: { fileSize: 5 * 1024 * 1024 }, +});