nakama/backend/routes/music.js

523 lines
16 KiB
JavaScript
Raw Normal View History

2025-12-15 07:28:47 +00:00
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const AdmZip = require('adm-zip');
const { authenticate } = require('../middleware/auth');
const Artist = require('../models/Artist');
const Album = require('../models/Album');
const Track = require('../models/Track');
const FavoriteTrack = require('../models/FavoriteTrack');
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../uploads/music');
try {
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: 50 * 1024 * 1024 // 50MB для треков
},
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'application/zip'];
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат файла'));
}
}
});
// Вспомогательная функция для поиска или создания исполнителя
async function findOrCreateArtist(artistName, userId) {
const normalizedName = artistName.toLowerCase().replace(/\s+/g, '');
let artist = await Artist.findOne({ normalizedName });
if (!artist) {
artist = await Artist.create({
name: artistName,
normalizedName,
addedBy: userId
});
}
return artist;
}
// Загрузка одного трека
router.post('/upload-track', authenticate, upload.single('track'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Файл не загружен' });
}
const { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
if (!title || !artistName) {
// Удалить загруженный файл
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Название трека и исполнитель обязательны' });
}
// Найти или создать исполнителя
const artist = await findOrCreateArtist(artistName, req.user._id);
// Найти или создать альбом (если указан)
let album = null;
if (albumTitle) {
album = await Album.findOne({ title: albumTitle, artist: artist._id });
if (!album) {
album = await Album.create({
title: albumTitle,
artist: artist._id,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
}
}
// Создать трек
const fileUrl = `/uploads/music/${req.file.filename}`;
const track = await Track.create({
title,
artist: artist._id,
album: album ? album._id : null,
fileUrl,
file: {
size: req.file.size,
mimeType: req.file.mimetype,
duration: 0 // TODO: извлечь из метаданных
},
trackNumber: trackNumber ? parseInt(trackNumber) : 0,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
// Обновить статистику
artist.stats.tracks += 1;
await artist.save();
if (album) {
album.stats.tracks += 1;
await album.save();
}
// Populate для ответа
await track.populate('artist album');
res.json({
success: true,
track
});
} catch (error) {
console.error('Ошибка загрузки трека:', error);
// Удалить файл в случае ошибки
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Ошибка загрузки трека' });
}
});
// Загрузка альбома из ZIP
router.post('/upload-album', authenticate, upload.single('album'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'ZIP файл не загружен' });
}
if (req.file.mimetype !== 'application/zip') {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Требуется ZIP файл' });
}
const { artistName, albumTitle, year, genre } = req.body;
if (!artistName || !albumTitle) {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Исполнитель и название альбома обязательны' });
}
// Распаковать ZIP
const zip = new AdmZip(req.file.path);
const zipEntries = zip.getEntries();
// Фильтровать аудио файлы
const audioExtensions = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
const audioFiles = zipEntries.filter(entry => {
const ext = path.extname(entry.entryName).toLowerCase();
return audioExtensions.includes(ext) && !entry.isDirectory;
});
if (audioFiles.length === 0) {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'В архиве нет аудио файлов' });
}
// Найти или создать исполнителя
const artist = await findOrCreateArtist(artistName, req.user._id);
// Создать альбом
const album = await Album.create({
title: albumTitle,
artist: artist._id,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
// Извлечь и сохранить треки
const tracks = [];
const uploadDir = path.join(__dirname, '../uploads/music');
for (let i = 0; i < audioFiles.length; i++) {
const entry = audioFiles[i];
const fileName = path.basename(entry.entryName);
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(fileName);
const newFileName = `${uniqueSuffix}${ext}`;
const filePath = path.join(uploadDir, newFileName);
// Извлечь файл
zip.extractEntryTo(entry, uploadDir, false, true, false, newFileName);
// Получить размер файла
const stats = await fs.stat(filePath);
// Создать трек
const track = await Track.create({
title: fileName.replace(ext, ''),
artist: artist._id,
album: album._id,
fileUrl: `/uploads/music/${newFileName}`,
file: {
size: stats.size,
mimeType: 'audio/mpeg', // TODO: определить правильный MIME type
duration: 0
},
trackNumber: i + 1,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
tracks.push(track);
}
// Удалить ZIP после обработки
await fs.unlink(req.file.path).catch(() => {});
// Обновить статистику
artist.stats.tracks += tracks.length;
artist.stats.albums += 1;
await artist.save();
album.stats.tracks = tracks.length;
await album.save();
// Populate для ответа
await album.populate('artist');
res.json({
success: true,
album,
tracks,
message: `Загружено ${tracks.length} треков`
});
} catch (error) {
console.error('Ошибка загрузки альбома:', error);
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Ошибка загрузки альбома' });
}
});
// Поиск треков, исполнителей, альбомов
router.get('/search', authenticate, async (req, res) => {
try {
const { q, type = 'all', limit = 20, page = 1 } = req.query;
if (!q || q.trim().length < 1) {
return res.json({
tracks: [],
artists: [],
albums: []
});
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const limitNum = parseInt(limit);
const searchRegex = new RegExp(q.trim(), 'i');
let tracks = [];
let artists = [];
let albums = [];
if (type === 'all' || type === 'tracks') {
tracks = await Track.find({
title: searchRegex
})
.populate('artist album')
.sort({ 'stats.plays': -1, createdAt: -1 })
.limit(limitNum)
.skip(skip);
}
if (type === 'all' || type === 'artists') {
artists = await Artist.find({
name: searchRegex
})
.sort({ 'stats.tracks': -1, createdAt: -1 })
.limit(limitNum)
.skip(skip);
}
if (type === 'all' || type === 'albums') {
albums = await Album.find({
title: searchRegex
})
.populate('artist')
.sort({ 'stats.tracks': -1, createdAt: -1 })
.limit(limitNum)
.skip(skip);
}
res.json({
tracks,
artists,
albums
});
} catch (error) {
console.error('Ошибка поиска:', error);
res.status(500).json({ error: 'Ошибка поиска' });
}
});
// Получить список треков
router.get('/tracks', authenticate, async (req, res) => {
try {
const { limit = 50, page = 1, artistId, albumId } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const limitNum = parseInt(limit);
const query = {};
if (artistId) query.artist = artistId;
if (albumId) query.album = albumId;
const tracks = await Track.find(query)
.populate('artist album')
.sort({ createdAt: -1 })
.limit(limitNum)
.skip(skip);
const total = await Track.countDocuments(query);
res.json({
tracks,
total,
page: parseInt(page),
pages: Math.ceil(total / limitNum)
});
} catch (error) {
console.error('Ошибка получения треков:', error);
res.status(500).json({ error: 'Ошибка получения треков' });
}
});
// Получить трек по ID
router.get('/tracks/:trackId', authenticate, async (req, res) => {
try {
const track = await Track.findById(req.params.trackId)
.populate('artist album');
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
res.json({ track });
} catch (error) {
console.error('Ошибка получения трека:', error);
res.status(500).json({ error: 'Ошибка получения трека' });
}
});
// Получить альбом по ID с треками
router.get('/albums/:albumId', authenticate, async (req, res) => {
try {
const album = await Album.findById(req.params.albumId)
.populate('artist');
if (!album) {
return res.status(404).json({ error: 'Альбом не найден' });
}
const tracks = await Track.find({ album: album._id })
.populate('artist')
.sort({ trackNumber: 1 });
res.json({ album, tracks });
} catch (error) {
console.error('Ошибка получения альбома:', error);
res.status(500).json({ error: 'Ошибка получения альбома' });
}
});
// Добавить трек в избранное
router.post('/favorites/:trackId', authenticate, async (req, res) => {
try {
const track = await Track.findById(req.params.trackId);
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
// Проверить, не добавлен ли уже
const existing = await FavoriteTrack.findOne({
user: req.user._id,
track: track._id
});
if (existing) {
return res.status(400).json({ error: 'Трек уже в избранном' });
}
await FavoriteTrack.create({
user: req.user._id,
track: track._id
});
// Обновить счетчик
track.stats.favorites += 1;
await track.save();
res.json({ success: true, message: 'Трек добавлен в избранное' });
} catch (error) {
console.error('Ошибка добавления в избранное:', error);
res.status(500).json({ error: 'Ошибка добавления в избранное' });
}
});
// Удалить трек из избранного
router.delete('/favorites/:trackId', authenticate, async (req, res) => {
try {
const favorite = await FavoriteTrack.findOneAndDelete({
user: req.user._id,
track: req.params.trackId
});
if (!favorite) {
return res.status(404).json({ error: 'Трек не найден в избранном' });
}
// Обновить счетчик
const track = await Track.findById(req.params.trackId);
if (track) {
track.stats.favorites = Math.max(0, track.stats.favorites - 1);
await track.save();
}
res.json({ success: true, message: 'Трек удален из избранного' });
} catch (error) {
console.error('Ошибка удаления из избранного:', error);
res.status(500).json({ error: 'Ошибка удаления из избранного' });
}
});
// Получить избранные треки пользователя
router.get('/favorites', authenticate, async (req, res) => {
try {
const { limit = 50, page = 1 } = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const limitNum = parseInt(limit);
const favorites = await FavoriteTrack.find({ user: req.user._id })
.populate({
path: 'track',
populate: { path: 'artist album' }
})
.sort({ createdAt: -1 })
.limit(limitNum)
.skip(skip);
const total = await FavoriteTrack.countDocuments({ user: req.user._id });
const tracks = favorites.map(f => f.track).filter(t => t); // Фильтр на случай удаленных треков
res.json({
tracks,
total,
page: parseInt(page),
pages: Math.ceil(total / limitNum)
});
} catch (error) {
console.error('Ошибка получения избранного:', error);
res.status(500).json({ error: 'Ошибка получения избранного' });
}
});
// Увеличить счетчик прослушиваний
router.post('/tracks/:trackId/play', authenticate, async (req, res) => {
try {
const track = await Track.findById(req.params.trackId);
if (!track) {
return res.status(404).json({ error: 'Трек не найден' });
}
track.stats.plays += 1;
await track.save();
// Также обновить статистику исполнителя
const artist = await Artist.findById(track.artist);
if (artist) {
artist.stats.plays += 1;
await artist.save();
}
// И альбома
if (track.album) {
const album = await Album.findById(track.album);
if (album) {
album.stats.plays += 1;
await album.save();
}
}
res.json({ success: true });
} catch (error) {
console.error('Ошибка обновления счетчика:', error);
res.status(500).json({ error: 'Ошибка обновления счетчика' });
}
});
module.exports = router;