nakama/backend/routes/music.js

613 lines
20 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 mm = require('music-metadata');
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: 105 * 1024 * 1024 // 105MB для ZIP альбомов (соответствует express.json limit)
},
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mp4', 'audio/m4a', 'application/zip', 'application/x-zip-compressed'];
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.zip'];
if (allowedMimeTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Неподдерживаемый формат файла'));
}
}
});
// Извлечь метаданные из аудио файла
async function extractAudioMetadata(filePath) {
try {
const metadata = await mm.parseFile(filePath);
// Извлечь обложку если есть
let coverImagePath = null;
if (metadata.common.picture && metadata.common.picture.length > 0) {
const picture = metadata.common.picture[0];
const coverFileName = `cover-${Date.now()}.${picture.format.split('/')[1] || 'jpg'}`;
coverImagePath = path.join(__dirname, '../uploads/music', coverFileName);
await fs.writeFile(coverImagePath, picture.data);
coverImagePath = `/uploads/music/${coverFileName}`;
}
return {
title: metadata.common.title || null,
artist: metadata.common.artist || metadata.common.artists?.[0] || null,
album: metadata.common.album || null,
year: metadata.common.year || null,
genre: metadata.common.genre?.[0] || null,
trackNumber: metadata.common.track?.no || null,
duration: metadata.format.duration || 0,
coverImage: coverImagePath
};
} catch (error) {
console.error('Ошибка извлечения метаданных:', error);
return {
title: null,
artist: null,
album: null,
year: null,
genre: null,
trackNumber: null,
duration: 0,
coverImage: null
};
}
}
// Вспомогательная функция для поиска или создания исполнителя
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: 'Файл не загружен' });
}
let { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
// Извлечь метаданные из файла
const metadata = await extractAudioMetadata(req.file.path);
// Использовать метаданные если поля не заполнены
title = title || metadata.title || path.basename(req.file.originalname, path.extname(req.file.originalname));
artistName = artistName || metadata.artist || 'Unknown Artist';
albumTitle = albumTitle || metadata.album;
trackNumber = trackNumber || metadata.trackNumber;
year = year || metadata.year;
genre = genre || metadata.genre;
// Найти или создать исполнителя
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,
coverImage: metadata.coverImage,
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,
coverImage: metadata.coverImage || (album ? album.coverImage : null),
file: {
size: req.file.size,
mimeType: req.file.mimetype,
duration: Math.round(metadata.duration)
},
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;
album.stats.duration += Math.round(metadata.duration);
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 файл не загружен' });
}
const ext = path.extname(req.file.originalname).toLowerCase();
if (ext !== '.zip' && req.file.mimetype !== 'application/zip' && req.file.mimetype !== 'application/x-zip-compressed') {
await fs.unlink(req.file.path).catch(() => {});
return res.status(400).json({ error: 'Требуется ZIP файл' });
}
let { artistName, albumTitle, year, genre } = req.body;
// Распаковать 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 uploadDir = path.join(__dirname, '../uploads/music');
// Извлечь первый трек для получения метаданных альбома
const firstEntry = audioFiles[0];
const firstFileName = path.basename(firstEntry.entryName);
const firstTempName = `temp-${Date.now()}${path.extname(firstFileName)}`;
const firstTempPath = path.join(uploadDir, firstTempName);
zip.extractEntryTo(firstEntry, uploadDir, false, true, false, firstTempName);
// Извлечь метаданные из первого трека
const firstMetadata = await extractAudioMetadata(firstTempPath);
// Использовать метаданные для альбома если не указаны
artistName = artistName || firstMetadata.artist || 'Unknown Artist';
albumTitle = albumTitle || firstMetadata.album || path.basename(req.file.originalname, '.zip');
year = year || firstMetadata.year;
genre = genre || firstMetadata.genre;
const albumCoverImage = firstMetadata.coverImage;
// Удалить временный файл
await fs.unlink(firstTempPath).catch(() => {});
// Найти или создать исполнителя
const artist = await findOrCreateArtist(artistName, req.user._id);
// Создать альбом
const album = await Album.create({
title: albumTitle,
artist: artist._id,
coverImage: albumCoverImage,
year: year ? parseInt(year) : null,
genre: genre || '',
addedBy: req.user._id
});
// Извлечь и сохранить треки
const tracks = [];
let totalDuration = 0;
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 trackMetadata = await extractAudioMetadata(filePath);
// Получить размер файла
const stats = await fs.stat(filePath);
// Определить название трека
const trackTitle = trackMetadata.title || fileName.replace(ext, '');
const trackArtist = trackMetadata.artist || artistName;
// Если исполнитель трека отличается от исполнителя альбома, найти или создать
let trackArtistId = artist._id;
if (trackArtist !== artistName) {
const trackArtistObj = await findOrCreateArtist(trackArtist, req.user._id);
trackArtistId = trackArtistObj._id;
}
// Создать трек
const track = await Track.create({
title: trackTitle,
artist: trackArtistId,
album: album._id,
fileUrl: `/uploads/music/${newFileName}`,
coverImage: trackMetadata.coverImage || albumCoverImage,
file: {
size: stats.size,
mimeType: req.file.mimetype,
duration: Math.round(trackMetadata.duration)
},
trackNumber: trackMetadata.trackNumber || (i + 1),
year: trackMetadata.year || year ? parseInt(year) : null,
genre: trackMetadata.genre || genre || '',
addedBy: req.user._id
});
tracks.push(track);
totalDuration += Math.round(trackMetadata.duration);
}
// Удалить 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;
album.stats.duration = totalDuration;
await album.save();
// Populate для ответа
await album.populate('artist');
res.json({
success: true,
album,
tracks,
message: `Загружено ${tracks.length} треков из альбома "${albumTitle}"`
});
} catch (error) {
console.error('Ошибка загрузки альбома:', error);
if (req.file) {
await fs.unlink(req.file.path).catch(() => {});
}
res.status(500).json({ error: 'Ошибка загрузки альбома: ' + error.message });
}
});
// Поиск треков, исполнителей, альбомов
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;