nakama/backend/routes/music.js

605 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 path = require('path');
const fs = require('fs').promises;
const AdmZip = require('adm-zip');
const mm = require('music-metadata');
const { authenticate } = require('../middleware/auth');
const { uploadMusicTrack, uploadMusicAlbum, cleanupOnError } = require('../middleware/upload');
const { uploadFile } = require('../utils/minio');
const Artist = require('../models/Artist');
const Album = require('../models/Album');
const Track = require('../models/Track');
const FavoriteTrack = require('../models/FavoriteTrack');
// Извлечь метаданные из аудио файла (из buffer или пути)
async function extractAudioMetadata(audioData, isBuffer = true) {
try {
const metadata = isBuffer
? await mm.parseBuffer(audioData, audioData.mimetype || 'audio/mpeg')
: await mm.parseFile(audioData);
// Извлечь обложку если есть
let coverImageUrl = null;
if (metadata.common.picture && metadata.common.picture.length > 0) {
const picture = metadata.common.picture[0];
const coverExt = picture.format.split('/')[1] || 'jpg';
const coverFileName = `cover-${Date.now()}.${coverExt}`;
// Загрузить обложку в MinIO если включен
const { isEnabled: isMinioEnabled } = require('../utils/minio');
if (isMinioEnabled()) {
try {
coverImageUrl = await uploadFile(
Buffer.from(picture.data),
coverFileName,
`image/${coverExt}`,
'music'
);
} catch (uploadError) {
console.error('Ошибка загрузки обложки в MinIO:', uploadError);
// Fallback: сохранить локально если MinIO недоступен
const coverPath = path.join(__dirname, '../uploads/music', coverFileName);
await fs.writeFile(coverPath, picture.data);
coverImageUrl = `/uploads/music/${coverFileName}`;
}
} else {
// Локальное хранилище
const coverPath = path.join(__dirname, '../uploads/music', coverFileName);
await fs.writeFile(coverPath, picture.data);
coverImageUrl = `/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: coverImageUrl
};
} 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, uploadMusicTrack, cleanupOnError(), async (req, res) => {
try {
if (!req.uploadedMusicFile) {
return res.status(400).json({ error: 'Файл не загружен' });
}
let { title, artistName, albumTitle, trackNumber, year, genre } = req.body;
// Извлечь метаданные из файла (используем buffer)
const metadata = await extractAudioMetadata(req.uploadedMusicFile.buffer, true);
// Использовать метаданные если поля не заполнены
title = title || metadata.title || path.basename(req.uploadedMusicFile.originalname, path.extname(req.uploadedMusicFile.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
});
}
}
// Создать трек (используем URL из MinIO или локальный путь)
const fileUrl = req.uploadedMusicFile.url;
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.uploadedMusicFile.size,
mimeType: req.uploadedMusicFile.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);
res.status(500).json({ error: 'Ошибка загрузки трека' });
}
});
// Загрузка альбома из ZIP с извлечением метаданных
router.post('/upload-album', authenticate, uploadMusicAlbum, cleanupOnError(), async (req, res) => {
try {
if (!req.uploadedMusicFile) {
return res.status(400).json({ error: 'ZIP файл не загружен' });
}
const ext = path.extname(req.uploadedMusicFile.originalname).toLowerCase();
if (ext !== '.zip' && req.uploadedMusicFile.mimetype !== 'application/zip' && req.uploadedMusicFile.mimetype !== 'application/x-zip-compressed') {
return res.status(400).json({ error: 'Требуется ZIP файл' });
}
let { artistName, albumTitle, year, genre } = req.body;
// Распаковать ZIP из buffer
const zip = new AdmZip(req.uploadedMusicFile.buffer);
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) {
return res.status(400).json({ error: 'В архиве нет аудио файлов' });
}
// Извлечь первый трек для получения метаданных альбома
const firstEntry = audioFiles[0];
const firstBuffer = zip.readFile(firstEntry);
// Извлечь метаданные из первого трека (из buffer)
const firstMetadata = await extractAudioMetadata(firstBuffer, true);
// Использовать метаданные для альбома если не указаны
artistName = artistName || firstMetadata.artist || 'Unknown Artist';
albumTitle = albumTitle || firstMetadata.album || path.basename(req.uploadedMusicFile.originalname, '.zip');
year = year || firstMetadata.year;
genre = genre || firstMetadata.genre;
const albumCoverImage = firstMetadata.coverImage;
// Найти или создать исполнителя
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 ext = path.extname(fileName);
// Извлечь файл из ZIP в buffer
const trackBuffer = zip.readFile(entry);
// Извлечь метаданные из трека (из buffer)
const trackMetadata = await extractAudioMetadata(trackBuffer, true);
// Определить название трека
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;
}
// Определить MIME тип
const mimeTypeMap = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.m4a': 'audio/mp4',
'.flac': 'audio/flac'
};
const mimeType = mimeTypeMap[ext.toLowerCase()] || 'audio/mpeg';
// Загрузить трек в MinIO или сохранить локально
let trackFileUrl;
const { isEnabled: isMinioEnabled } = require('../utils/minio');
if (isMinioEnabled()) {
trackFileUrl = await uploadFile(
trackBuffer,
fileName,
mimeType,
'music'
);
} else {
// Локальное хранилище
const uploadDir = path.join(__dirname, '../uploads/music');
if (!(await fs.access(uploadDir).catch(() => false))) {
await fs.mkdir(uploadDir, { recursive: true });
}
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const newFileName = `${uniqueSuffix}${ext}`;
const filePath = path.join(uploadDir, newFileName);
await fs.writeFile(filePath, trackBuffer);
trackFileUrl = `/uploads/music/${newFileName}`;
}
// Создать трек
const track = await Track.create({
title: trackTitle,
artist: trackArtistId,
album: album._id,
fileUrl: trackFileUrl,
coverImage: trackMetadata.coverImage || albumCoverImage,
file: {
size: trackBuffer.length,
mimeType: 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);
}
// Обновить статистику
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: `Альбом "${album.title}" успешно загружен. Добавлено треков: ${tracks.length}`
});
} catch (error) {
console.error('Ошибка загрузки альбома:', error);
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;