280 lines
9.5 KiB
JavaScript
280 lines
9.5 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const axios = require('axios');
|
|
const db = require('../db');
|
|
const camelotToMusicKey = require('../../shared/camelot-to-musickey');
|
|
|
|
const reverseMap = {
|
|
// 12B / 12A (E-Dur / C#m / Dbm)
|
|
'e': '12B', 'e major': '12B', 'e-dur': '12B', 'e maj': '12B',
|
|
'c# minor': '12A', 'c#m': '12A', 'c#-moll': '12A', 'c# min': '12A',
|
|
'db minor': '12A', 'dbm': '12A', 'db-moll': '12A',
|
|
|
|
// 1B / 1A (B-Dur / H-Dur / G#m / Abm)
|
|
'b': '1B', 'b major': '1B', 'b-dur': '1B', 'b maj': '1B',
|
|
'h': '1B', 'h major': '1B', 'h-dur': '1B',
|
|
'g# minor': '1A', 'g#m': '1A', 'g#-moll': '1A', 'g# min': '1A',
|
|
'ab minor': '1A', 'abm': '1A', 'ab-moll': '1A',
|
|
|
|
// 2B / 2A (F#-Dur / Gb-Dur / D#m / Ebm)
|
|
'f#': '2B', 'f# major': '2B', 'f#-dur': '2B', 'f# maj': '2B',
|
|
'gb': '2B', 'gb major': '2B', 'gb-dur': '2B',
|
|
'd# minor': '2A', 'd#m': '2A', 'd#-moll': '2A', 'd# min': '2A',
|
|
'eb minor': '2A', 'ebm': '2A', 'eb-moll': '2A',
|
|
|
|
// 3B / 3A (Db-Dur / C#-Dur / Bbm / A#m)
|
|
'db': '3B', 'db major': '3B', 'db-dur': '3B', 'db maj': '3B',
|
|
'c#': '3B', 'c# major': '3B', 'c#-dur': '3B',
|
|
'bb minor': '3A', 'bbm': '3A', 'bb-moll': '3A', 'bb min': '3A',
|
|
'a# minor': '3A', 'a#m': '3A', 'a#-moll': '3A',
|
|
|
|
// 4B / 4A (Ab-Dur / G#-Dur / Fm)
|
|
'ab': '4B', 'ab major': '4B', 'ab-dur': '4B', 'ab maj': '4B',
|
|
'g#': '4B', 'g# major': '4B', 'g#-dur': '4B',
|
|
'f minor': '4A', 'fm': '4A', 'f-moll': '4A', 'f min': '4A',
|
|
|
|
// 5B / 5A (Eb-Dur / D#-Dur / Cm)
|
|
'eb': '5B', 'eb major': '5B', 'eb-dur': '5B', 'eb maj': '5B',
|
|
'd#': '5B', 'd# major': '5B', 'd#-dur': '5B',
|
|
'c minor': '5A', 'cm': '5A', 'c-moll': '5A', 'c min': '5A',
|
|
|
|
// 6B / 6A (Bb-Dur / A#-Dur / Gm)
|
|
'bb': '6B', 'bb major': '6B', 'bb-dur': '6B', 'bb maj': '6B',
|
|
'a#': '6B', 'a# major': '6B', 'a#-dur': '6B',
|
|
'g minor': '6A', 'gm': '6A', 'g-moll': '6A', 'g min': '6A',
|
|
|
|
// 7B / 7A (F-Dur / Dm)
|
|
'f': '7B', 'f major': '7B', 'f-dur': '7B', 'f maj': '7B',
|
|
'd minor': '7A', 'dm': '7A', 'd-moll': '7A', 'd min': '7A',
|
|
|
|
// 8B / 8A (C-Dur / Am)
|
|
'c': '8B', 'c major': '8B', 'c-dur': '8B', 'c maj': '8B',
|
|
'a minor': '8A', 'am': '8A', 'a-moll': '8A', 'a min': '8A',
|
|
|
|
// 9B / 9A (G-Dur / Em)
|
|
'g': '9B', 'g major': '9B', 'g-dur': '9B', 'g maj': '9B',
|
|
'e minor': '9A', 'em': '9A', 'e-moll': '9A', 'e min': '9A',
|
|
|
|
// 10B / 10A (D-Dur / Bm / Hm)
|
|
'd': '10B', 'd major': '10B', 'd-dur': '10B', 'd maj': '10B',
|
|
'b minor': '10A', 'bm': '10A', 'b-moll': '10A', 'b min': '10A',
|
|
'h minor': '10A', 'hm': '10A', 'h-moll': '10A', 'h min': '10A',
|
|
|
|
// 11B / 11A (A-Dur / F#m / Gbm)
|
|
'a': '11B', 'a major': '11B', 'a-dur': '11B', 'a maj': '11B',
|
|
'f# minor': '11A', 'f#m': '11A', 'f#-moll': '11A', 'f# min': '11A',
|
|
'gb minor': '11A', 'gbm': '11A', 'gb-moll': '11A',
|
|
};
|
|
|
|
function standardizeKey(keyString) {
|
|
if (!keyString) return null;
|
|
const str = keyString.toLowerCase().trim();
|
|
const upper = str.toUpperCase();
|
|
if (camelotToMusicKey[upper]) return upper;
|
|
return reverseMap[str] || null;
|
|
}
|
|
|
|
function estimateAudioFeatures(bpm, camelotKey, tags = []) {
|
|
let baseEnergy = Math.min(98, Math.max(35, Math.round(30 + ((bpm - 70) / 100) * 60)));
|
|
let baseDanceability = 70;
|
|
let baseHappiness = camelotKey && camelotKey.endsWith('B') ? 68 : 45;
|
|
|
|
const tagStr = (tags || []).join(' ').toLowerCase();
|
|
if (tagStr.includes('ambient') || tagStr.includes('acoustic') || tagStr.includes('chill')) {
|
|
baseEnergy = Math.max(25, baseEnergy - 30);
|
|
baseDanceability = Math.max(30, baseDanceability - 25);
|
|
}
|
|
if (tagStr.includes('tech') || tagStr.includes('club') || tagStr.includes('banger') || tagStr.includes('rave')) {
|
|
baseEnergy = Math.min(98, baseEnergy + 15);
|
|
baseDanceability = Math.min(95, baseDanceability + 12);
|
|
}
|
|
if (tagStr.includes('house') || tagStr.includes('disco') || tagStr.includes('dance')) {
|
|
baseDanceability = Math.min(92, baseDanceability + 10);
|
|
}
|
|
|
|
return {
|
|
energy: Math.min(99, Math.max(15, baseEnergy)),
|
|
danceability: Math.min(99, Math.max(20, baseDanceability)),
|
|
happiness: Math.min(99, Math.max(10, baseHappiness))
|
|
};
|
|
}
|
|
|
|
router.get('/', async (req, res) => {
|
|
const track = req.query.track;
|
|
const artist = req.query.artist;
|
|
|
|
if (!track || !artist) {
|
|
return res.status(400).json({ error: 'Missing track or artist' });
|
|
}
|
|
|
|
try {
|
|
// 1. Check PostgreSQL first!
|
|
const local = await db.findTrack(artist, track);
|
|
if (local && local.camelot_key && local.bpm) {
|
|
const est = estimateAudioFeatures(local.bpm, local.camelot_key, local.tags);
|
|
return res.json({
|
|
status: 'success',
|
|
data: {
|
|
key: local.camelot_key,
|
|
bpm: local.bpm,
|
|
music_key: local.music_key || camelotToMusicKey[local.camelot_key] || '',
|
|
tags: local.tags || ['dance'],
|
|
is_corrected: !!local.is_corrected,
|
|
cover_url: local.cover_url || null,
|
|
preview_url: local.preview_url || null,
|
|
deezer_id: local.deezer_id || null,
|
|
deezer_link: local.deezer_link || (local.deezer_id ? `https://www.deezer.com/track/${local.deezer_id}` : null),
|
|
duration_sec: local.duration_sec || 210,
|
|
energy: local.energy || est.energy,
|
|
danceability: local.danceability || est.danceability,
|
|
happiness: local.happiness || est.happiness,
|
|
source: 'postgresql'
|
|
}
|
|
});
|
|
}
|
|
|
|
let bpm = null;
|
|
let camelotKey = null;
|
|
let tags = [];
|
|
let coverUrl = null;
|
|
|
|
// 2. Query GetSongBPM API if key available
|
|
if (process.env.GETSONGBPM_API_KEY) {
|
|
try {
|
|
const gsbRes = await axios.get('https://api.getsong.co/search/', {
|
|
params: {
|
|
api_key: process.env.GETSONGBPM_API_KEY,
|
|
type: 'both',
|
|
lookup: `song:${track} artist:${artist}`
|
|
},
|
|
timeout: 4000
|
|
});
|
|
|
|
const searchResults = gsbRes.data && gsbRes.data.search;
|
|
if (Array.isArray(searchResults) && searchResults.length > 0) {
|
|
const song = searchResults[0];
|
|
if (song.tempo) {
|
|
bpm = parseFloat(song.tempo);
|
|
}
|
|
if (song.key_of) {
|
|
camelotKey = standardizeKey(song.key_of);
|
|
}
|
|
if (song.artist && song.artist.genres && tags.length === 0) {
|
|
tags = song.artist.genres.slice(0, 4);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Metadata] GetSongBPM Error:', err.message);
|
|
}
|
|
}
|
|
|
|
// 3. Last.fm API for genre tags
|
|
if (process.env.LASTFM_API_KEY) {
|
|
try {
|
|
const lfmRes = await axios.get('https://ws.audioscrobbler.com/2.0/', {
|
|
params: {
|
|
method: 'track.getInfo',
|
|
api_key: process.env.LASTFM_API_KEY,
|
|
artist: artist,
|
|
track: track,
|
|
format: 'json'
|
|
},
|
|
timeout: 4000
|
|
});
|
|
|
|
if (lfmRes.data && lfmRes.data.track && lfmRes.data.track.toptags && lfmRes.data.track.toptags.tag) {
|
|
tags = lfmRes.data.track.toptags.tag
|
|
.slice(0, 4)
|
|
.map(t => t.name.toLowerCase());
|
|
}
|
|
} catch (err) {
|
|
console.warn('[Metadata] Last.fm Error:', err.message);
|
|
}
|
|
}
|
|
|
|
// 4. Query Deezer for cover, duration, preview, and genre fallback
|
|
let durationSec = 210;
|
|
let previewUrl = null;
|
|
let deezerLink = null;
|
|
let deezerId = null;
|
|
try {
|
|
const deezerRes = await axios.get(`https://api.deezer.com/search?q=${encodeURIComponent(artist + ' ' + track)}&limit=1`, {
|
|
timeout: 3000
|
|
});
|
|
if (deezerRes.data && deezerRes.data.data && deezerRes.data.data[0]) {
|
|
const dTrack = deezerRes.data.data[0];
|
|
if (dTrack.album && dTrack.album.cover_medium) {
|
|
coverUrl = dTrack.album.cover_medium;
|
|
}
|
|
if (dTrack.duration) {
|
|
durationSec = dTrack.duration;
|
|
}
|
|
if (dTrack.preview) {
|
|
previewUrl = dTrack.preview;
|
|
}
|
|
if (dTrack.link) {
|
|
deezerLink = dTrack.link;
|
|
}
|
|
if (dTrack.id) {
|
|
deezerId = dTrack.id;
|
|
if (!deezerLink) deezerLink = `https://www.deezer.com/track/${dTrack.id}`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
// Defaults if not found online
|
|
if (!bpm) bpm = 120;
|
|
if (!camelotKey) camelotKey = '8B';
|
|
if (tags.length === 0) tags = ['electronic', 'dance'];
|
|
|
|
const musicKey = camelotToMusicKey[camelotKey] || '';
|
|
const audioAttrs = estimateAudioFeatures(bpm, camelotKey, tags);
|
|
|
|
// 5. Persist newly fetched track into PostgreSQL
|
|
await db.saveTrack({
|
|
title: track,
|
|
artist: artist,
|
|
camelot_key: camelotKey,
|
|
bpm: Math.round(bpm),
|
|
music_key: musicKey,
|
|
cover_url: coverUrl,
|
|
preview_url: previewUrl,
|
|
duration_sec: durationSec,
|
|
energy: audioAttrs.energy,
|
|
danceability: audioAttrs.danceability,
|
|
happiness: audioAttrs.happiness,
|
|
tags: tags,
|
|
is_corrected: false,
|
|
source: 'online'
|
|
});
|
|
|
|
res.json({
|
|
status: 'success',
|
|
data: {
|
|
key: camelotKey,
|
|
bpm: Math.round(bpm),
|
|
music_key: musicKey,
|
|
tags: tags,
|
|
is_corrected: false,
|
|
cover_url: coverUrl,
|
|
preview_url: previewUrl,
|
|
duration_sec: durationSec,
|
|
deezer_id: deezerId,
|
|
deezer_link: deezerLink,
|
|
energy: audioAttrs.energy,
|
|
danceability: audioAttrs.danceability,
|
|
happiness: audioAttrs.happiness,
|
|
source: 'online'
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('[Metadata] error:', err.message);
|
|
res.status(500).json({ error: 'Failed to fetch metadata' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|