Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5775161ac5 | |||
| 478faac0f2 | |||
| 401a0d51a6 | |||
| 2719cd4a6f | |||
| ba293fd140 | |||
| b0c29765a9 | |||
| 59e4d86a3b | |||
| 8a03d51ccf | |||
| a702f1f83c | |||
| e344f704a0 |
+5
-1
@@ -13,7 +13,11 @@ app.use(
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
app.use(helmet());
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
})
|
||||||
|
);
|
||||||
app.use(express.json({ limit: '2mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
import fs from 'fs';
|
||||||
import Track from '../models/Track.js';
|
import Track from '../models/Track.js';
|
||||||
|
|
||||||
|
const removeFile = (file) => {
|
||||||
|
if (!file?.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.unlink(file.path, () => {});
|
||||||
|
};
|
||||||
|
|
||||||
export const adminController = {
|
export const adminController = {
|
||||||
async addTrack(req, res, next) {
|
async addTrack(req, res, next) {
|
||||||
try {
|
try {
|
||||||
@@ -11,7 +20,33 @@ export const adminController = {
|
|||||||
.json({ success: false, message: 'All fields are required' });
|
.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({
|
const track = await Track.create({
|
||||||
title,
|
title,
|
||||||
@@ -19,6 +54,7 @@ export const adminController = {
|
|||||||
album,
|
album,
|
||||||
genre,
|
genre,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
|
audioUrl,
|
||||||
createdBy: req.userId,
|
createdBy: req.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
loginUser,
|
loginUser,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
registerUser,
|
registerUser,
|
||||||
|
updateProfile,
|
||||||
} from '../services/authService.js';
|
} from '../services/authService.js';
|
||||||
|
|
||||||
const accessCookieName = 'noctune_access';
|
const accessCookieName = 'noctune_access';
|
||||||
@@ -92,4 +93,25 @@ export const authController = {
|
|||||||
clearAuthCookies(res);
|
clearAuthCookies(res);
|
||||||
res.json({ success: true, data: { message: 'Logged out' } });
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
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);
|
||||||
@@ -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);
|
||||||
@@ -29,6 +29,12 @@ const trackSchema = new mongoose.Schema(
|
|||||||
coverUrl: {
|
coverUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
audioUrl: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
createdBy: {
|
createdBy: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ const userSchema = new mongoose.Schema(
|
|||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
},
|
},
|
||||||
|
likedTracks: [
|
||||||
|
{
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Track',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|||||||
import { adminController } from '../controllers/adminController.js';
|
import { adminController } from '../controllers/adminController.js';
|
||||||
import { auth } from '../middlewares/auth.js';
|
import { auth } from '../middlewares/auth.js';
|
||||||
import { requireAdmin } from '../middlewares/roles.js';
|
import { requireAdmin } from '../middlewares/roles.js';
|
||||||
import { uploadCover } from '../utils/multer.js';
|
import { uploadTrackAssets } from '../utils/multer.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -10,7 +10,10 @@ router.post(
|
|||||||
'/tracks',
|
'/tracks',
|
||||||
auth,
|
auth,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
uploadCover.single('cover'),
|
uploadTrackAssets.fields([
|
||||||
|
{ name: 'cover', maxCount: 1 },
|
||||||
|
{ name: 'audio', maxCount: 1 },
|
||||||
|
]),
|
||||||
adminController.addTrack
|
adminController.addTrack
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authController } from '../controllers/authController.js';
|
import { authController } from '../controllers/authController.js';
|
||||||
import { auth } from '../middlewares/auth.js';
|
import { auth } from '../middlewares/auth.js';
|
||||||
|
import { uploadAvatar } from '../utils/multer.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -9,5 +10,6 @@ router.post('/login', authController.login);
|
|||||||
router.post('/refresh', authController.refresh);
|
router.post('/refresh', authController.refresh);
|
||||||
router.post('/logout', authController.logout);
|
router.post('/logout', authController.logout);
|
||||||
router.get('/me', auth, authController.me);
|
router.get('/me', auth, authController.me);
|
||||||
|
router.put('/profile', auth, uploadAvatar.single('avatar'), authController.updateProfile);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import authRoutes from './auths.js';
|
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';
|
import adminRoutes from './admins.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -15,6 +20,11 @@ router.get('/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.use('/auth', authRoutes);
|
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);
|
router.use('/admin', adminRoutes);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { searchController } from '../controllers/searchController.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.get('/', searchController.search);
|
||||||
|
export default router;
|
||||||
@@ -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;
|
||||||
@@ -168,3 +168,30 @@ export const getSessionUser = async (userId) => {
|
|||||||
|
|
||||||
return user;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
+74
-2
@@ -3,14 +3,45 @@ import path from 'path';
|
|||||||
import multer from 'multer';
|
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 playlistCoversDir = path.join(process.cwd(), 'uploads', 'playlists');
|
||||||
|
const avatarsDir = path.join(process.cwd(), 'uploads', 'avatars');
|
||||||
|
|
||||||
if (!fs.existsSync(coversDir)) {
|
if (!fs.existsSync(coversDir)) {
|
||||||
fs.mkdirSync(coversDir, { recursive: true });
|
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({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
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) => {
|
filename: (req, file, cb) => {
|
||||||
const safeName = file.originalname.replace(/\s+/g, '-');
|
const safeName = file.originalname.replace(/\s+/g, '-');
|
||||||
@@ -19,15 +50,56 @@ const storage = multer.diskStorage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageFilter = (req, file, cb) => {
|
const imageFilter = (req, file, cb) => {
|
||||||
|
if (file.fieldname === 'cover') {
|
||||||
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
if (!allowed.includes(file.mimetype)) {
|
if (!allowed.includes(file.mimetype)) {
|
||||||
return cb(new Error('Only JPG, PNG, or WEBP images are allowed'));
|
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,
|
storage,
|
||||||
fileFilter: imageFilter,
|
fileFilter: imageFilter,
|
||||||
limits: { fileSize: 5 * 1024 * 1024 },
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
|||||||
Reference in New Issue
Block a user