Compare commits
10 Commits
88dcaea7af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5775161ac5 | |||
| 478faac0f2 | |||
| 401a0d51a6 | |||
| 2719cd4a6f | |||
| ba293fd140 | |||
| b0c29765a9 | |||
| 59e4d86a3b | |||
| 8a03d51ccf | |||
| a702f1f83c | |||
| e344f704a0 |
+5
-1
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
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: {
|
||||
type: String,
|
||||
trim: true,
|
||||
required: true,
|
||||
},
|
||||
audioUrl: {
|
||||
type: String,
|
||||
trim: true,
|
||||
required: true,
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
|
||||
@@ -37,6 +37,12 @@ const userSchema = new mongoose.Schema(
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
likedTracks: [
|
||||
{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Track',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 { 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user