feat: Add Tunebat feature parity (30s previews, audio features, filters, Spotify/Deezer/YouTube quick links)
This commit is contained in:
1 parent
3d4372b154
commit
fc1adda97d
10 files changed
+894
-125
No files matched your search
+11
-7
@@ -81,24 +81,28 @@ async function findTrack(artist, title) {
|
||||
}
|
||||
|
||||
// Save or update track
|
||||
async function saveTrack({ title, artist, camelot_key, bpm, music_key, cover_url, preview_url, tags = [], is_corrected = false, source = 'manual' }) {
|
||||
async function saveTrack({ title, artist, camelot_key, bpm, music_key, cover_url, preview_url, duration_sec, energy, danceability, happiness, tags = [], is_corrected = false, source = 'manual' }) {
|
||||
if (!title || !artist || !camelot_key || !bpm) return null;
|
||||
|
||||
if (isConnected) {
|
||||
try {
|
||||
const res = await getPool().query(
|
||||
`INSERT INTO tracks (title, artist, camelot_key, bpm, music_key, cover_url, preview_url, tags, is_corrected, source, play_count, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 1, CURRENT_TIMESTAMP)
|
||||
`INSERT INTO tracks (title, artist, camelot_key, bpm, music_key, cover_url, preview_url, duration_sec, energy, danceability, happiness, tags, is_corrected, source, play_count, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (artist, title) DO UPDATE
|
||||
SET play_count = tracks.play_count + 1,
|
||||
cover_url = COALESCE(EXCLUDED.cover_url, tracks.cover_url),
|
||||
preview_url = COALESCE(EXCLUDED.preview_url, tracks.preview_url),
|
||||
duration_sec = COALESCE(EXCLUDED.duration_sec, tracks.duration_sec),
|
||||
energy = COALESCE(EXCLUDED.energy, tracks.energy),
|
||||
danceability = COALESCE(EXCLUDED.danceability, tracks.danceability),
|
||||
happiness = COALESCE(EXCLUDED.happiness, tracks.happiness),
|
||||
camelot_key = CASE WHEN tracks.is_corrected THEN tracks.camelot_key ELSE EXCLUDED.camelot_key END,
|
||||
bpm = CASE WHEN tracks.is_corrected THEN tracks.bpm ELSE EXCLUDED.bpm END,
|
||||
music_key = CASE WHEN tracks.is_corrected THEN tracks.music_key ELSE EXCLUDED.music_key END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *`,
|
||||
[title.trim(), artist.trim(), camelot_key, Math.round(bpm), music_key, cover_url, preview_url, tags, is_corrected, source]
|
||||
[title.trim(), artist.trim(), camelot_key, Math.round(bpm), music_key, cover_url, preview_url, duration_sec || null, energy || null, danceability || null, happiness || null, tags, is_corrected, source]
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (e) {
|
||||
@@ -106,7 +110,7 @@ async function saveTrack({ title, artist, camelot_key, bpm, music_key, cover_url
|
||||
}
|
||||
}
|
||||
|
||||
const track = { title, artist, camelot_key, bpm: Math.round(bpm), music_key, cover_url, preview_url, tags, is_corrected, source };
|
||||
const track = { title, artist, camelot_key, bpm: Math.round(bpm), music_key, cover_url, preview_url, duration_sec, energy, danceability, happiness, tags, is_corrected, source };
|
||||
const key = `${artist.trim().toLowerCase()} - ${title.trim().toLowerCase()}`;
|
||||
memoryTracks.set(key, track);
|
||||
return track;
|
||||
@@ -151,7 +155,7 @@ async function getSuggestions(compatibleKeys = [], minBpm = 80, maxBpm = 180, li
|
||||
if (isConnected) {
|
||||
try {
|
||||
const res = await getPool().query(
|
||||
`SELECT id, title, artist, camelot_key, bpm, music_key, cover_url, preview_url, tags, play_count
|
||||
`SELECT id, title, artist, camelot_key, bpm, music_key, cover_url, preview_url, duration_sec, energy, danceability, happiness, tags, play_count
|
||||
FROM tracks
|
||||
WHERE camelot_key = ANY($1)
|
||||
AND bpm BETWEEN $2 AND $3
|
||||
@@ -186,7 +190,7 @@ async function searchTracks(queryStr, limit = 10) {
|
||||
if (isConnected) {
|
||||
try {
|
||||
const res = await getPool().query(
|
||||
`SELECT id, title, artist, camelot_key, bpm, music_key, cover_url, is_corrected
|
||||
`SELECT id, title, artist, camelot_key, bpm, music_key, cover_url, preview_url, duration_sec, energy, danceability, happiness, is_corrected
|
||||
FROM tracks
|
||||
WHERE LOWER(title) LIKE $1
|
||||
OR LOWER(artist) LIKE $1
|
||||
|
||||
@@ -74,6 +74,31 @@ function standardizeKey(keyString) {
|
||||
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;
|
||||
@@ -86,6 +111,7 @@ router.get('/', async (req, res) => {
|
||||
// 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: {
|
||||
@@ -95,6 +121,11 @@ router.get('/', async (req, res) => {
|
||||
tags: local.tags || ['dance'],
|
||||
is_corrected: !!local.is_corrected,
|
||||
cover_url: local.cover_url || null,
|
||||
preview_url: local.preview_url || null,
|
||||
duration_sec: local.duration_sec || 210,
|
||||
energy: local.energy || est.energy,
|
||||
danceability: local.danceability || est.danceability,
|
||||
happiness: local.happiness || est.happiness,
|
||||
source: 'postgresql'
|
||||
}
|
||||
});
|
||||
@@ -159,7 +190,9 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Query Deezer for cover and genre fallback
|
||||
// 4. Query Deezer for cover, duration, preview, and genre fallback
|
||||
let durationSec = 210;
|
||||
let previewUrl = null;
|
||||
try {
|
||||
const deezerRes = await axios.get(`https://api.deezer.com/search?q=${encodeURIComponent(artist + ' ' + track)}&limit=1`, {
|
||||
timeout: 3000
|
||||
@@ -169,6 +202,12 @@ router.get('/', async (req, res) => {
|
||||
if (dTrack.album && dTrack.album.cover_medium) {
|
||||
coverUrl = dTrack.album.cover_medium;
|
||||
}
|
||||
if (dTrack.duration) {
|
||||
durationSec = dTrack.duration;
|
||||
}
|
||||
if (dTrack.preview) {
|
||||
previewUrl = dTrack.preview;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
@@ -180,6 +219,7 @@ router.get('/', async (req, res) => {
|
||||
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({
|
||||
@@ -189,6 +229,11 @@ router.get('/', async (req, res) => {
|
||||
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'
|
||||
@@ -203,6 +248,11 @@ router.get('/', async (req, res) => {
|
||||
tags: tags,
|
||||
is_corrected: false,
|
||||
cover_url: coverUrl,
|
||||
preview_url: previewUrl,
|
||||
duration_sec: durationSec,
|
||||
energy: audioAttrs.energy,
|
||||
danceability: audioAttrs.danceability,
|
||||
happiness: audioAttrs.happiness,
|
||||
source: 'online'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,7 +146,12 @@ router.post('/', upload.single('audio'), async (req, res) => {
|
||||
bpm: bpm,
|
||||
musicKey: musicKey,
|
||||
isCorrected: isCorrected,
|
||||
cover_url: coverUrl
|
||||
cover_url: coverUrl,
|
||||
preview_url: existing ? existing.preview_url : null,
|
||||
duration_sec: existing ? existing.duration_sec : null,
|
||||
energy: existing ? existing.energy : null,
|
||||
danceability: existing ? existing.danceability : null,
|
||||
happiness: existing ? existing.happiness : null
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ router.get('/', async (req, res) => {
|
||||
artist: item.artist ? item.artist.name : 'Unknown Artist',
|
||||
cover_url: item.album && item.album.cover_medium ? item.album.cover_medium : (item.album ? item.album.cover : null),
|
||||
preview_url: item.preview || null,
|
||||
duration_sec: item.duration || 210,
|
||||
deezer_id: item.id
|
||||
}));
|
||||
}
|
||||
@@ -57,6 +58,11 @@ router.get('/', async (req, res) => {
|
||||
bpm: lt.bpm,
|
||||
music_key: lt.music_key,
|
||||
cover_url: lt.cover_url,
|
||||
preview_url: lt.preview_url,
|
||||
duration_sec: lt.duration_sec,
|
||||
energy: lt.energy,
|
||||
danceability: lt.danceability,
|
||||
happiness: lt.happiness,
|
||||
is_local: true,
|
||||
is_corrected: lt.is_corrected
|
||||
});
|
||||
@@ -74,9 +80,13 @@ router.get('/', async (req, res) => {
|
||||
artist: ot.artist,
|
||||
cover_url: ot.cover_url,
|
||||
preview_url: ot.preview_url,
|
||||
duration_sec: ot.duration_sec,
|
||||
camelot_key: existingLocal ? existingLocal.camelot_key : null,
|
||||
bpm: existingLocal ? existingLocal.bpm : null,
|
||||
music_key: existingLocal ? existingLocal.music_key : null,
|
||||
energy: existingLocal ? existingLocal.energy : null,
|
||||
danceability: existingLocal ? existingLocal.danceability : null,
|
||||
happiness: existingLocal ? existingLocal.happiness : null,
|
||||
is_local: !!existingLocal,
|
||||
is_corrected: existingLocal ? existingLocal.is_corrected : false
|
||||
});
|
||||
|
||||
@@ -40,17 +40,31 @@ router.get('/', async (req, res) => {
|
||||
tracks = await db.getSuggestions(compatibleKeys, minBpm, maxBpm, 10, artist, title);
|
||||
}
|
||||
|
||||
// 4. If still empty, fetch any compatible key track (50-220 BPM)
|
||||
if (tracks.length === 0) {
|
||||
tracks = await db.getSuggestions(compatibleKeys, 50, 220, 10, artist, title);
|
||||
// 4. If still fewer than 8 tracks, widen to any compatible key track (50-220 BPM)
|
||||
if (tracks.length < 8) {
|
||||
const moreTracks = await db.getSuggestions(compatibleKeys, 50, 220, 15, artist, title);
|
||||
const trackIds = new Set(tracks.map(t => t.id || `${t.artist}-${t.title}`));
|
||||
for (const mt of moreTracks) {
|
||||
const idKey = mt.id || `${mt.artist}-${mt.title}`;
|
||||
if (!trackIds.has(idKey)) {
|
||||
tracks.push(mt);
|
||||
trackIds.add(idKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Map tracks with compatibility info and colors
|
||||
// 5. Map tracks with compatibility info, colors and Tunebat audio features
|
||||
const enriched = tracks.map(t => {
|
||||
const ruleIdx = compatibleKeys.indexOf(t.camelot_key);
|
||||
const compatLabel = ruleIdx !== -1 ? compatLabels[ruleIdx] : 'Compatible';
|
||||
const color = colorMatrix[t.camelot_key] || '#4F9D69';
|
||||
const musicKey = t.music_key || camelotToMusicKey[t.camelot_key] || '';
|
||||
|
||||
const bpmVal = t.bpm || 120;
|
||||
const isMajor = t.camelot_key && t.camelot_key.endsWith('B');
|
||||
const defaultEnergy = Math.min(96, Math.max(40, Math.round(45 + ((bpmVal - 80) / 90) * 50)));
|
||||
const defaultDance = Math.min(94, Math.max(50, Math.round(65 + ((bpmVal - 100) / 60) * 25)));
|
||||
const defaultHappy = isMajor ? 70 : 46;
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
@@ -65,6 +79,11 @@ router.get('/', async (req, res) => {
|
||||
compat: ruleIdx !== -1 ? ruleIdx : 0,
|
||||
compatLabel: compatLabel,
|
||||
cover_url: t.cover_url || null,
|
||||
preview_url: t.preview_url || null,
|
||||
duration_sec: t.duration_sec || 215,
|
||||
energy: t.energy || defaultEnergy,
|
||||
danceability: t.danceability || defaultDance,
|
||||
happiness: t.happiness || defaultHappy,
|
||||
tags: t.tags || []
|
||||
};
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user