feat: add Album model, artist/search/upload controllers, requireArtist middleware

This commit is contained in:
2026-05-26 07:01:11 +07:00
parent 59e4d86a3b
commit b0c29765a9
14 changed files with 278 additions and 7 deletions
+8 -2
View File
@@ -28,7 +28,10 @@ export const adminController = {
removeFile(audioFile); removeFile(audioFile);
return res return res
.status(400) .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) { if (coverFile.size > 5 * 1024 * 1024) {
@@ -36,7 +39,10 @@ export const adminController = {
removeFile(audioFile); removeFile(audioFile);
return res return res
.status(400) .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}`; const coverUrl = `/uploads/covers/${coverFile.filename}`;
+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);
}
},
};
+14 -3
View File
@@ -15,15 +15,22 @@ export const playlistController = {
async create(req, res, next) { async create(req, res, next) {
try { try {
const { name } = req.body; const { name, description } = req.body;
if (!name) { if (!name) {
return res return res
.status(400) .status(400)
.json({ success: false, message: 'Playlist name is required' }); .json({ success: false, message: 'Playlist name is required' });
} }
const coverFile = req.file;
const coverUrl = coverFile
? `/uploads/playlists/${coverFile.filename}`
: '';
const playlist = await Playlist.create({ const playlist = await Playlist.create({
name, name,
description,
coverUrl,
user: req.userId, user: req.userId,
}); });
@@ -46,7 +53,9 @@ export const playlistController = {
const exists = await Track.exists({ _id: trackId }); const exists = await Track.exists({ _id: trackId });
if (!exists) { 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( const playlist = await Playlist.findOneAndUpdate(
@@ -56,7 +65,9 @@ export const playlistController = {
).lean(); ).lean();
if (!playlist) { 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 }); return res.json({ success: true, data: playlist });
+42
View File
@@ -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);
}
},
};
+3 -1
View File
@@ -17,7 +17,9 @@ export const trackController = {
const exists = await Track.exists({ _id: trackId }); const exists = await Track.exists({ _id: trackId });
if (!exists) { 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( const user = await User.findByIdAndUpdate(
+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); 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);
+9
View File
@@ -8,6 +8,15 @@ const playlistSchema = new mongoose.Schema(
trim: true, trim: true,
maxlength: 120, maxlength: 120,
}, },
description: {
type: String,
trim: true,
maxlength: 240,
},
coverUrl: {
type: String,
trim: true,
},
user: { user: {
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'User', ref: 'User',
+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;
+4
View File
@@ -3,6 +3,8 @@ import authRoutes from './auths.js';
import adminRoutes from './admins.js'; import adminRoutes from './admins.js';
import trackRoutes from './tracks.js'; import trackRoutes from './tracks.js';
import playlistRoutes from './playlists.js'; import playlistRoutes from './playlists.js';
import artistRoutes from './artists.js';
import searchRoutes from './search.js';
const router = Router(); const router = Router();
@@ -20,5 +22,7 @@ router.use('/auth', authRoutes);
router.use('/admin', adminRoutes); router.use('/admin', adminRoutes);
router.use('/tracks', trackRoutes); router.use('/tracks', trackRoutes);
router.use('/playlists', playlistRoutes); router.use('/playlists', playlistRoutes);
router.use('/artists', artistRoutes);
router.use('/search', searchRoutes);
export default router; export default router;
+7 -1
View File
@@ -1,11 +1,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { playlistController } from '../controllers/playlistController.js'; import { playlistController } from '../controllers/playlistController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
import { uploadPlaylistCover } from '../utils/multer.js';
const router = Router(); const router = Router();
router.get('/', auth, playlistController.list); 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); router.post('/:playlistId/tracks', auth, playlistController.addTrack);
export default router; 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
@@ -4,6 +4,7 @@ import multer from 'multer';
const coversDir = path.join(process.cwd(), 'uploads', 'covers'); const coversDir = path.join(process.cwd(), 'uploads', 'covers');
const audioDir = path.join(process.cwd(), 'uploads', 'audio'); const audioDir = path.join(process.cwd(), 'uploads', 'audio');
const playlistCoversDir = path.join(process.cwd(), 'uploads', 'playlists');
if (!fs.existsSync(coversDir)) { if (!fs.existsSync(coversDir)) {
fs.mkdirSync(coversDir, { recursive: true }); fs.mkdirSync(coversDir, { recursive: true });
@@ -13,6 +14,10 @@ if (!fs.existsSync(audioDir)) {
fs.mkdirSync(audioDir, { recursive: true }); fs.mkdirSync(audioDir, { recursive: true });
} }
if (!fs.existsSync(playlistCoversDir)) {
fs.mkdirSync(playlistCoversDir, { recursive: true });
}
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
if (file.fieldname === 'cover') { if (file.fieldname === 'cover') {
@@ -23,6 +28,10 @@ const storage = multer.diskStorage({
return cb(null, audioDir); return cb(null, audioDir);
} }
if (file.fieldname === 'playlistCover') {
return cb(null, playlistCoversDir);
}
return cb(new Error('Invalid upload field')); return cb(new Error('Invalid upload field'));
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
@@ -57,6 +66,15 @@ const imageFilter = (req, file, cb) => {
return cb(null, true); 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')); return cb(new Error('Invalid upload field'));
}; };
@@ -65,3 +83,9 @@ export const uploadTrackAssets = multer({
fileFilter: imageFilter, fileFilter: imageFilter,
limits: { fileSize: 50 * 1024 * 1024 }, limits: { fileSize: 50 * 1024 * 1024 },
}); });
export const uploadPlaylistCover = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 },
});