nakama/backend/routes/music.js

523 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;