feat: add Album model, artist/search/upload controllers, requireArtist middleware
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { searchController } from '../controllers/searchController.js';
|
||||
|
||||
const router = Router();
|
||||
router.get('/', searchController.search);
|
||||
export default router;
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user