feat: Add Tunebat feature parity (30s previews, audio features, filters, Spotify/Deezer/YouTube quick links)

This commit is contained in:
Jannik committed 2026-09-13 01:13:33 +02:00
1 parent 3d4372b154
commit fc1adda97d
10 files changed
+894 -125

No files matched your search

+11 -7
View File
@@ -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
+51 -1
View File
@@ -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'
}
});
+6 -1
View File
@@ -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
}
});
+10
View File
@@ -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
});
+23 -4
View File
@@ -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 || []
};
});