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
+77
-48
@@ -9,6 +9,10 @@ CREATE TABLE IF NOT EXISTS tracks (
|
||||
music_key VARCHAR(10),
|
||||
cover_url TEXT,
|
||||
preview_url TEXT,
|
||||
duration_sec INTEGER,
|
||||
energy INTEGER,
|
||||
danceability INTEGER,
|
||||
happiness INTEGER,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
is_corrected BOOLEAN DEFAULT FALSE,
|
||||
source VARCHAR(50) DEFAULT 'manual',
|
||||
@@ -18,80 +22,105 @@ CREATE TABLE IF NOT EXISTS tracks (
|
||||
CONSTRAINT unique_track UNIQUE (artist, title)
|
||||
);
|
||||
|
||||
-- Idempotent column migrations for existing databases
|
||||
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS duration_sec INTEGER;
|
||||
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS energy INTEGER;
|
||||
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS danceability INTEGER;
|
||||
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS happiness INTEGER;
|
||||
ALTER TABLE tracks ADD COLUMN IF NOT EXISTS preview_url TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_camelot ON tracks(camelot_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_bpm ON tracks(bpm);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks(title);
|
||||
|
||||
-- Initial curated seed data across all 24 Camelot keys (Mixed In Key Standard)
|
||||
INSERT INTO tracks (artist, title, camelot_key, bpm, music_key, tags, source) VALUES
|
||||
-- Curated seed data across all 24 Camelot keys with Tunebat audio features
|
||||
INSERT INTO tracks (artist, title, camelot_key, bpm, music_key, duration_sec, energy, danceability, happiness, tags, source) VALUES
|
||||
-- 12B & 12A (E-Dur / C#-Moll)
|
||||
('Avicii', 'Wake Me Up', '12B', 124, 'E', ARRAY['folk-dance', 'anthem'], 'seed'),
|
||||
('Otto Knows', 'Million Voices', '12B', 126, 'E', ARRAY['progressive house'], 'seed'),
|
||||
('Fred again..', 'Delilah (pull me out of this)', '12A', 134, 'C#m', ARRAY['electronic', 'club'], 'seed'),
|
||||
('Robin Schulz', 'Sugar', '12A', 123, 'C#m', ARRAY['deep house', 'pop'], 'seed'),
|
||||
('Avicii', 'Wake Me Up', '12B', 124, 'E', 247, 78, 53, 64, ARRAY['folk-dance', 'anthem', 'festival'], 'seed'),
|
||||
('Otto Knows', 'Million Voices', '12B', 126, 'E', 192, 85, 62, 58, ARRAY['progressive house', 'festival'], 'seed'),
|
||||
('Kungs', 'Never Going Home', '12B', 124, 'E', 170, 88, 77, 82, ARRAY['dance-pop', 'house'], 'seed'),
|
||||
('Fred again..', 'Delilah (pull me out of this)', '12A', 134, 'C#m', 251, 84, 68, 49, ARRAY['electronic', 'club', 'emotional'], 'seed'),
|
||||
('Robin Schulz', 'Sugar', '12A', 123, 'C#m', 219, 82, 64, 63, ARRAY['deep house', 'pop', 'radio'], 'seed'),
|
||||
('The Chainsmokers', 'Closer', '12A', 95, 'C#m', 244, 52, 75, 66, ARRAY['pop', 'future bass'], 'seed'),
|
||||
|
||||
-- 1B & 1A (B-Dur / G#-Moll)
|
||||
('Galantis', 'Runaway (U & I)', '1B', 126, 'B', ARRAY['electro pop', 'edm'], 'seed'),
|
||||
('Kygo', 'Firestone', '1B', 114, 'B', ARRAY['tropical house'], 'seed'),
|
||||
('CamelPhat & Elderbrook', 'Cola', '1A', 122, 'G#m', ARRAY['tech house', 'deep house'], 'seed'),
|
||||
('Peggy Gou', '(It Goes Like) Nanana', '1A', 130, 'G#m', ARRAY['house', 'summer'], 'seed'),
|
||||
('Galantis', 'Runaway (U & I)', '1B', 126, 'B', 227, 82, 57, 48, ARRAY['electro pop', 'edm', 'anthem'], 'seed'),
|
||||
('Kygo', 'Firestone', '1B', 114, 'B', 273, 56, 68, 51, ARRAY['tropical house', 'melodic'], 'seed'),
|
||||
('Martin Solveig', 'Intoxicated', '1B', 125, 'B', 160, 89, 82, 79, ARRAY['future house', 'groove'], 'seed'),
|
||||
('CamelPhat & Elderbrook', 'Cola', '1A', 122, 'G#m', 223, 73, 70, 42, ARRAY['tech house', 'deep house', 'club'], 'seed'),
|
||||
('Peggy Gou', '(It Goes Like) Nanana', '1A', 130, 'G#m', 231, 86, 74, 80, ARRAY['house', 'summer', 'vocal'], 'seed'),
|
||||
('RÜFÜS DU SOL', 'Innerbloom', '1A', 121, 'G#m', 578, 67, 65, 33, ARRAY['melodic techno', 'deep house'], 'seed'),
|
||||
|
||||
-- 2B & 2A (F#-Dur / D#-Moll)
|
||||
('Disclosure ft. Sam Smith', 'Latch', '2B', 122, 'F#', ARRAY['garage', 'house'], 'seed'),
|
||||
('Major Lazer', 'Lean On', '2B', 98, 'F#', ARRAY['dancehall', 'moombahton'], 'seed'),
|
||||
('Eric Prydz', 'Opus', '2A', 126, 'D#m', ARRAY['progressive house', 'masterpiece'], 'seed'),
|
||||
('Darude', 'Sandstorm', '2A', 136, 'D#m', ARRAY['trance', 'classic'], 'seed'),
|
||||
('Disclosure ft. Sam Smith', 'Latch', '2B', 122, 'F#', 256, 73, 50, 52, ARRAY['uk garage', 'house', 'vocal'], 'seed'),
|
||||
('Major Lazer', 'Lean On', '2B', 98, 'F#', 176, 81, 72, 52, ARRAY['dancehall', 'moombahton', 'pop'], 'seed'),
|
||||
('Calvin Harris & Dua Lipa', 'One Kiss', '2B', 124, 'F#', 214, 86, 79, 59, ARRAY['house', 'pop', 'dance'], 'seed'),
|
||||
('Eric Prydz', 'Opus', '2A', 126, 'D#m', 560, 89, 54, 18, ARRAY['progressive house', 'masterpiece'], 'seed'),
|
||||
('Darude', 'Sandstorm', '2A', 136, 'D#m', 225, 99, 54, 76, ARRAY['trance', 'classic', 'peak time'], 'seed'),
|
||||
('Gorgon City', 'Ready For Your Love', '2A', 122, 'D#m', 199, 81, 74, 55, ARRAY['deep house', 'uk bass'], 'seed'),
|
||||
|
||||
-- 3B & 3A (Db-Dur / Bb-Moll)
|
||||
('Calvin Harris', 'Feel So Close', '3B', 128, 'Db', ARRAY['dance', 'pop'], 'seed'),
|
||||
('Gigi D''Agostino', 'L''Amour Toujours', '3A', 138, 'Bbm', ARRAY['italo dance', 'classic'], 'seed'),
|
||||
('Paul Kalkbrenner', 'Sky and Sand', '3A', 122, 'Bbm', ARRAY['minimal', 'berlin techno'], 'seed'),
|
||||
('Calvin Harris', 'Feel So Close', '3B', 128, 'Db', 206, 92, 71, 91, ARRAY['dance', 'pop', 'festival'], 'seed'),
|
||||
('Tiësto & Karol G', 'Don''t Be Shy', '3B', 120, 'Db', 140, 79, 77, 85, ARRAY['slap house', 'latin dance'], 'seed'),
|
||||
('Gigi D''Agostino', 'L''Amour Toujours', '3A', 138, 'Bbm', 238, 77, 62, 72, ARRAY['italo dance', 'classic', 'retro'], 'seed'),
|
||||
('Paul Kalkbrenner', 'Sky and Sand', '3A', 122, 'Bbm', 239, 57, 72, 38, ARRAY['minimal', 'berlin techno', 'iconic'], 'seed'),
|
||||
('Swedish House Mafia', 'Moth To A Flame', '3A', 120, 'Bbm', 234, 75, 59, 36, ARRAY['synth-pop', 'house', 'dark'], 'seed'),
|
||||
|
||||
-- 4B & 4A (Ab-Dur / F-Moll)
|
||||
('Clean Bandit', 'Rather Be', '4B', 121, 'Ab', ARRAY['deep house', 'classical crossover'], 'seed'),
|
||||
('Swedish House Mafia', 'One', '4A', 125, 'Fm', ARRAY['electro house', 'classic'], 'seed'),
|
||||
('Alan Walker', 'Faded', '4A', 90, 'Fm', ARRAY['electro', 'melodic'], 'seed'),
|
||||
('Clean Bandit', 'Rather Be', '4B', 121, 'Ab', 228, 79, 73, 75, ARRAY['deep house', 'classical crossover'], 'seed'),
|
||||
('Duke Dumont', 'Ocean Drive', '4B', 115, 'Ab', 206, 72, 75, 48, ARRAY['nu-disco', 'synth-pop', 'vibe'], 'seed'),
|
||||
('Swedish House Mafia', 'One', '4A', 125, 'Fm', 349, 93, 71, 26, ARRAY['electro house', 'club', 'classic'], 'seed'),
|
||||
('Alan Walker', 'Faded', '4A', 90, 'Fm', 212, 65, 47, 16, ARRAY['electro', 'melodic', 'anthem'], 'seed'),
|
||||
('Oliver Heldens', 'Gecko (Overdrive)', '4A', 125, 'Fm', 165, 87, 76, 68, ARRAY['future house', 'bassline'], 'seed'),
|
||||
|
||||
-- 5B & 5A (Eb-Dur / C-Moll)
|
||||
('deadmau5', 'Strobe', '5B', 128, 'Eb', ARRAY['progressive house'], 'seed'),
|
||||
('Bicep', 'Glue', '5B', 130, 'Eb', ARRAY['breakbeat', 'ambient'], 'seed'),
|
||||
('Benny Benassi', 'Satisfaction', '5A', 130, 'Cm', ARRAY['electro house', 'legend'], 'seed'),
|
||||
('Kungs vs Cookin'' on 3 Burners', 'This Girl', '5A', 122, 'Cm', ARRAY['funk house', 'brass'], 'seed'),
|
||||
('deadmau5', 'Strobe', '5B', 128, 'Eb', 637, 71, 56, 12, ARRAY['progressive house', 'legendary'], 'seed'),
|
||||
('Bicep', 'Glue', '5B', 130, 'Eb', 269, 77, 66, 31, ARRAY['breakbeat', 'ambient', 'rave'], 'seed'),
|
||||
('Benny Benassi', 'Satisfaction', '5A', 130, 'Cm', 285, 96, 81, 74, ARRAY['electro house', 'legend', 'bass'], 'seed'),
|
||||
('Kungs vs Cookin'' on 3 Burners', 'This Girl', '5A', 122, 'Cm', 195, 82, 75, 84, ARRAY['funk house', 'brass', 'summer'], 'seed'),
|
||||
('Fisher', 'You Little Beauty', '5A', 126, 'Cm', 206, 91, 78, 64, ARRAY['tech house', 'peak time'], 'seed'),
|
||||
|
||||
-- 6B & 6A (Bb-Dur / G-Moll)
|
||||
('Modjo', 'Lady (Hear Me Tonight)', '6B', 126, 'Bb', ARRAY['french touch', 'filter house'], 'seed'),
|
||||
('Fatboy Slim', 'Praise You', '6B', 110, 'Bb', ARRAY['big beat', 'breakbeat'], 'seed'),
|
||||
('Robin S', 'Show Me Love', '6A', 120, 'Gm', ARRAY['90s house', 'anthem'], 'seed'),
|
||||
('Robert Miles', 'Children', '6A', 138, 'Gm', ARRAY['dream trance', '90s'], 'seed'),
|
||||
('Modjo', 'Lady (Hear Me Tonight)', '6B', 126, 'Bb', 306, 81, 72, 87, ARRAY['french touch', 'filter house', 'classic'], 'seed'),
|
||||
('Fatboy Slim', 'Praise You', '6B', 110, 'Bb', 323, 83, 70, 71, ARRAY['big beat', 'breakbeat', '90s'], 'seed'),
|
||||
('Robin S', 'Show Me Love', '6A', 120, 'Gm', 252, 75, 78, 83, ARRAY['90s house', 'anthem', 'organ'], 'seed'),
|
||||
('Robert Miles', 'Children', '6A', 138, 'Gm', 243, 79, 58, 23, ARRAY['dream trance', '90s', 'piano'], 'seed'),
|
||||
('ATB', '9 PM (Till I Come)', '6A', 130, 'Gm', 194, 88, 71, 62, ARRAY['trance', 'classic', 'guitar'], 'seed'),
|
||||
|
||||
-- 7B & 7A (F-Dur / D-Moll)
|
||||
('Shouse', 'Love Tonight', '7B', 123, 'F', ARRAY['indie dance', 'choir'], 'seed'),
|
||||
('Stardust', 'Music Sounds Better With You', '7B', 124, 'F', ARRAY['french house', 'disco'], 'seed'),
|
||||
('Faithless', 'God Is a DJ', '7A', 131, 'Dm', ARRAY['dance anthem', 'classic'], 'seed'),
|
||||
('Paul van Dyk', 'For an Angel', '7A', 138, 'Dm', ARRAY['trance', 'berlin'], 'seed'),
|
||||
('Shouse', 'Love Tonight', '7B', 123, 'F', 241, 87, 68, 69, ARRAY['indie dance', 'choir', 'anthem'], 'seed'),
|
||||
('Stardust', 'Music Sounds Better With You', '7B', 124, 'F', 261, 84, 75, 91, ARRAY['french house', 'disco', 'classic'], 'seed'),
|
||||
('Faithless', 'God Is a DJ', '7A', 131, 'Dm', 207, 91, 66, 42, ARRAY['dance anthem', 'classic', 'trance'], 'seed'),
|
||||
('Paul van Dyk', 'For an Angel', '7A', 138, 'Dm', 231, 94, 53, 39, ARRAY['trance', 'berlin', 'timeless'], 'seed'),
|
||||
('Topic & A7S', 'Breaking Me', '7A', 122, 'Dm', 167, 72, 79, 66, ARRAY['deep house', 'slap house'], 'seed'),
|
||||
|
||||
-- 8B & 8A (C-Dur / A-Moll)
|
||||
('Axwell /\\ Ingrosso', 'More Than You Know', '8B', 123, 'C', ARRAY['house', 'edm'], 'seed'),
|
||||
('Avicii', 'The Nights', '8B', 126, 'C', ARRAY['dance', 'melodic'], 'seed'),
|
||||
('Tiësto', 'The Business', '8A', 120, 'Am', ARRAY['deep house', 'bass'], 'seed'),
|
||||
('Meduza', 'Piece Of Your Heart', '8A', 124, 'Am', ARRAY['house', 'electronic'], 'seed'),
|
||||
('Axwell /\\ Ingrosso', 'More Than You Know', '8B', 123, 'C', 203, 87, 65, 54, ARRAY['house', 'edm', 'festival'], 'seed'),
|
||||
('Avicii', 'The Nights', '8B', 126, 'C', 176, 84, 53, 65, ARRAY['dance', 'melodic', 'acoustic'], 'seed'),
|
||||
('Tiësto', 'The Business', '8A', 120, 'Am', 164, 80, 80, 56, ARRAY['deep house', 'bass', 'club'], 'seed'),
|
||||
('Meduza', 'Piece Of Your Heart', '8A', 124, 'Am', 153, 74, 68, 63, ARRAY['house', 'electronic', 'dark'], 'seed'),
|
||||
('John Summit', 'Where You Are', '8A', 126, 'Am', 234, 85, 62, 45, ARRAY['vocal house', 'euphoric'], 'seed'),
|
||||
|
||||
-- 9B & 9A (G-Dur / E-Moll)
|
||||
('Calvin Harris', 'Summer', '9B', 128, 'G', ARRAY['dance', 'summer'], 'seed'),
|
||||
('David Guetta ft. Sia', 'Titanium', '9B', 126, 'G', ARRAY['edm', 'pop'], 'seed'),
|
||||
('Martin Garrix', 'Animals', '9A', 128, 'Em', ARRAY['big room', 'electro'], 'seed'),
|
||||
('Fisher', 'Losing It', '9A', 125, 'Em', ARRAY['tech house', 'club'], 'seed'),
|
||||
('Calvin Harris', 'Summer', '9B', 128, 'G', 222, 86, 60, 74, ARRAY['dance', 'summer', 'festival'], 'seed'),
|
||||
('David Guetta ft. Sia', 'Titanium', '9B', 126, 'G', 245, 79, 60, 30, ARRAY['edm', 'pop', 'anthem'], 'seed'),
|
||||
('Martin Garrix', 'Animals', '9A', 128, 'Em', 176, 90, 58, 28, ARRAY['big room', 'electro', 'festival'], 'seed'),
|
||||
('Fisher', 'Losing It', '9A', 125, 'Em', 248, 96, 76, 64, ARRAY['tech house', 'club', 'banger'], 'seed'),
|
||||
('Dom Dolla', 'Rhyme Dust', '9A', 128, 'Em', 190, 92, 79, 70, ARRAY['tech house', 'bass'], 'seed'),
|
||||
|
||||
-- 10B & 10A (D-Dur / H-Moll)
|
||||
('Daft Punk', 'One More Time', '10B', 123, 'D', ARRAY['french house', 'disco'], 'seed'),
|
||||
('Swedish House Mafia', 'Don''t You Worry Child', '10B', 129, 'D', ARRAY['progressive', 'anthem'], 'seed'),
|
||||
('Alesso', 'Heroes (we could be)', '10B', 126, 'D', ARRAY['edm', 'euphoric'], 'seed'),
|
||||
('ACRAZE', 'Do It To It', '10A', 125, 'Bm', ARRAY['house', 'viral'], 'seed'),
|
||||
('Faithless', 'Insomnia', '10A', 127, 'Bm', ARRAY['classic', 'trance'], 'seed'),
|
||||
('Daft Punk', 'One More Time', '10B', 123, 'D', 320, 71, 61, 64, ARRAY['french house', 'disco', 'legend'], 'seed'),
|
||||
('Swedish House Mafia', 'Don''t You Worry Child', '10B', 129, 'D', 212, 84, 61, 44, ARRAY['progressive', 'anthem', 'festival'], 'seed'),
|
||||
('Alesso', 'Heroes (we could be)', '10B', 126, 'D', 210, 86, 52, 42, ARRAY['edm', 'euphoric', 'vocal'], 'seed'),
|
||||
('ACRAZE', 'Do It To It', '10A', 125, 'Bm', 158, 81, 85, 66, ARRAY['house', 'viral', 'bass'], 'seed'),
|
||||
('Faithless', 'Insomnia', '10A', 127, 'Bm', 214, 94, 76, 53, ARRAY['classic', 'trance', 'pizzicato'], 'seed'),
|
||||
('Mau P', 'Drugs From Amsterdam', '10A', 125, 'Bm', 236, 92, 83, 62, ARRAY['tech house', 'club', 'groove'], 'seed'),
|
||||
|
||||
-- 11B & 11A (A-Dur / F#-Moll)
|
||||
('Alphaville', 'Big in Japan', '11B', 118, 'A', ARRAY['80s', 'synth-pop'], 'seed'),
|
||||
('Dua Lipa', 'Don''t Start Now', '11A', 124, 'F#m', ARRAY['nu-disco', 'dance'], 'seed'),
|
||||
('The Weeknd', 'Blinding Lights', '11A', 171, 'F#m', ARRAY['synthwave', '80s'], 'seed')
|
||||
('Alphaville', 'Big in Japan', '11B', 118, 'A', 232, 69, 73, 71, ARRAY['80s', 'synth-pop', 'classic'], 'seed'),
|
||||
('Daft Punk', 'Around The World', '11B', 121, 'A', 429, 80, 86, 84, ARRAY['french house', 'groove', 'funk'], 'seed'),
|
||||
('Dua Lipa', 'Don''t Start Now', '11A', 124, 'F#m', 183, 79, 79, 68, ARRAY['nu-disco', 'dance', 'bassline'], 'seed'),
|
||||
('The Weeknd', 'Blinding Lights', '11A', 171, 'F#m', 200, 73, 51, 33, ARRAY['synthwave', '80s', 'retro'], 'seed'),
|
||||
('Oliver Tree & Robin Schulz', 'Miss You', '11A', 145, 'F#m', 206, 88, 72, 57, ARRAY['dance', 'speed-house'], 'seed')
|
||||
ON CONFLICT (artist, title) DO NOTHING;
|
||||
+111
-1
@@ -282,7 +282,7 @@ async function processAudio(blob) {
|
||||
const json = await res.json();
|
||||
|
||||
if (json.status === 'success' && json.data) {
|
||||
const { title, artist, matchScore, key, bpm, cover_url, isCorrected } = json.data;
|
||||
const { title, artist, matchScore, key, bpm, cover_url, isCorrected, preview_url, duration_sec, energy, danceability, happiness } = json.data;
|
||||
|
||||
// Avoid refetching if same track is still playing
|
||||
if (currentTrack && currentTrack.title.toLowerCase() === title.toLowerCase() && currentTrack.artist.toLowerCase() === artist.toLowerCase()) {
|
||||
@@ -298,6 +298,11 @@ async function processAudio(blob) {
|
||||
title,
|
||||
artist,
|
||||
cover_url,
|
||||
preview_url,
|
||||
duration_sec,
|
||||
energy,
|
||||
danceability,
|
||||
happiness,
|
||||
camelot_key: key,
|
||||
bpm: bpm,
|
||||
is_corrected: isCorrected,
|
||||
@@ -353,6 +358,108 @@ function resetHero() {
|
||||
document.getElementById('hero').classList.remove('wheel-mode', 'cover-mode');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Global 30-Second Audio Preview Player (Tunebat Style)
|
||||
// ----------------------------------------------------
|
||||
let activeAudio = null;
|
||||
let activePreviewUrl = null;
|
||||
let activePlayBtn = null;
|
||||
|
||||
function stopAllPreviews() {
|
||||
if (activeAudio) {
|
||||
activeAudio.pause();
|
||||
activeAudio.currentTime = 0;
|
||||
activeAudio = null;
|
||||
}
|
||||
activePreviewUrl = null;
|
||||
if (activePlayBtn) {
|
||||
activePlayBtn.classList.remove('playing');
|
||||
const icon = activePlayBtn.querySelector('i');
|
||||
if (icon) icon.className = 'fa-solid fa-play';
|
||||
activePlayBtn = null;
|
||||
}
|
||||
// Also reset hero preview button if needed
|
||||
const heroBtn = document.getElementById('hero-preview-btn');
|
||||
if (heroBtn) {
|
||||
heroBtn.classList.remove('playing');
|
||||
const heroIcon = heroBtn.querySelector('i');
|
||||
if (heroIcon) heroIcon.className = 'fa-solid fa-play';
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePreviewTrack(track, buttonEl) {
|
||||
if (!track) return;
|
||||
|
||||
let previewUrl = track.preview_url;
|
||||
|
||||
// If clicking on already playing preview -> Pause & Stop
|
||||
if (activeAudio && activePreviewUrl === previewUrl) {
|
||||
stopAllPreviews();
|
||||
return;
|
||||
}
|
||||
|
||||
// If another track was playing, stop it first
|
||||
stopAllPreviews();
|
||||
|
||||
// If track doesn't have preview_url yet, try to search Deezer dynamically
|
||||
if (!previewUrl) {
|
||||
if (buttonEl) {
|
||||
const icon = buttonEl.querySelector('i');
|
||||
if (icon) icon.className = 'fa-solid fa-spinner fa-spin';
|
||||
}
|
||||
try {
|
||||
const sRes = await fetch(`/api/search?q=${encodeURIComponent(track.artist + ' ' + track.title)}`);
|
||||
const sJson = await sRes.json();
|
||||
if (sJson.status === 'success' && sJson.data && sJson.data.length > 0 && sJson.data[0].preview_url) {
|
||||
previewUrl = sJson.data[0].preview_url;
|
||||
track.preview_url = previewUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Preview] Could not find preview URL:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!previewUrl) {
|
||||
alert(`Keine 30s-Vorschau für "${track.title}" verfügbar.`);
|
||||
if (buttonEl) {
|
||||
const icon = buttonEl.querySelector('i');
|
||||
if (icon) icon.className = 'fa-solid fa-play';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
activeAudio = new Audio(previewUrl);
|
||||
activePreviewUrl = previewUrl;
|
||||
activePlayBtn = buttonEl;
|
||||
|
||||
if (buttonEl) {
|
||||
buttonEl.classList.add('playing');
|
||||
const icon = buttonEl.querySelector('i');
|
||||
if (icon) icon.className = 'fa-solid fa-pause';
|
||||
}
|
||||
|
||||
activeAudio.onended = () => {
|
||||
stopAllPreviews();
|
||||
};
|
||||
|
||||
activeAudio.onerror = (e) => {
|
||||
console.warn('[Preview] Audio playback error:', e);
|
||||
stopAllPreviews();
|
||||
};
|
||||
|
||||
await activeAudio.play();
|
||||
} catch (err) {
|
||||
console.warn('[Preview] Play failed (user interaction needed):', err);
|
||||
stopAllPreviews();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHeroPreview() {
|
||||
const heroBtn = document.getElementById('hero-preview-btn');
|
||||
togglePreviewTrack(window.currentTrack, heroBtn);
|
||||
}
|
||||
|
||||
// Expose globals
|
||||
window.toggleTheme = toggleTheme;
|
||||
window.changeAudioSource = changeAudioSource;
|
||||
@@ -361,6 +468,9 @@ window.toggleListening = toggleListening;
|
||||
window.toggleWheel = toggleWheel;
|
||||
window.toggleCover = toggleCover;
|
||||
window.resetHero = resetHero;
|
||||
window.stopAllPreviews = stopAllPreviews;
|
||||
window.togglePreviewTrack = togglePreviewTrack;
|
||||
window.toggleHeroPreview = toggleHeroPreview;
|
||||
|
||||
// Initial state
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
+98
-13
@@ -9,7 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="styles.css?v=2.1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -61,12 +61,31 @@
|
||||
<span class="note">♪</span>
|
||||
<span>No Cover</span>
|
||||
</div>
|
||||
<button id="hero-preview-btn" class="cover-play-btn" onclick="event.stopPropagation(); window.toggleHeroPreview()" title="30s Audio-Vorschau abspielen" style="display:none;">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div class="info-top">
|
||||
<div class="track-title" id="track-title">Warte auf Audio...</div>
|
||||
<div class="track-artist" id="track-artist">Starte PC-Audio oder Mikrofon oben rechts</div>
|
||||
<div class="info-header-row">
|
||||
<div>
|
||||
<div class="track-title" id="track-title">Warte auf Audio...</div>
|
||||
<div class="track-artist" id="track-artist">Starte PC-Audio oder Mikrofon oben rechts</div>
|
||||
</div>
|
||||
<!-- External Streaming & DJ Quick Links -->
|
||||
<div class="track-actions" id="track-actions" style="display:none;">
|
||||
<a id="action-spotify" href="#" target="_blank" rel="noopener" class="link-btn link-spotify" title="In Spotify öffnen">
|
||||
<i class="fa-brands fa-spotify"></i> Spotify
|
||||
</a>
|
||||
<a id="action-deezer" href="#" target="_blank" rel="noopener" class="link-btn link-deezer" title="In Deezer öffnen">
|
||||
<i class="fa-brands fa-deezer"></i> Deezer
|
||||
</a>
|
||||
<a id="action-youtube" href="#" target="_blank" rel="noopener" class="link-btn link-youtube" title="Auf YouTube suchen">
|
||||
<i class="fa-brands fa-youtube"></i> YouTube
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-mid">
|
||||
@@ -98,6 +117,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tunebat-Style Audio Attributes Row (Duration, Energy, Danceability, Mood) -->
|
||||
<div class="attributes-grid" id="attributes-grid" style="display:none;">
|
||||
<div class="attr-card" title="Dauer des Tracks">
|
||||
<div class="attr-header">
|
||||
<span class="attr-label"><i class="fa-regular fa-clock"></i> DAUER</span>
|
||||
<span class="attr-value" id="attr-duration">-:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attr-card" title="Energie & Intensität des Tracks (0-100%)">
|
||||
<div class="attr-header">
|
||||
<span class="attr-label"><i class="fa-solid fa-bolt"></i> ENERGY</span>
|
||||
<span class="attr-value attr-energy" id="attr-energy">-</span>
|
||||
</div>
|
||||
<div class="attr-bar-track">
|
||||
<div class="attr-bar-fill fill-energy" id="bar-energy" style="width: 0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attr-card" title="Tanzbarkeit & Rhythmus (0-100%)">
|
||||
<div class="attr-header">
|
||||
<span class="attr-label"><i class="fa-solid fa-shoe-prints"></i> DANCE</span>
|
||||
<span class="attr-value attr-dance" id="attr-dance">-</span>
|
||||
</div>
|
||||
<div class="attr-bar-track">
|
||||
<div class="attr-bar-fill fill-dance" id="bar-dance" style="width: 0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attr-card" title="Stimmung / Happiness (Valence: 0-100%)">
|
||||
<div class="attr-header">
|
||||
<span class="attr-label"><i class="fa-regular fa-sun"></i> MOOD</span>
|
||||
<span class="attr-value attr-mood" id="attr-mood">-</span>
|
||||
</div>
|
||||
<div class="attr-bar-track">
|
||||
<div class="attr-bar-fill fill-mood" id="bar-mood" style="width: 0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags-row" id="tags-row"></div>
|
||||
</div>
|
||||
|
||||
@@ -111,8 +170,34 @@
|
||||
<button class="close-btn" id="close-btn" onclick="resetHero()" title="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions Grid -->
|
||||
<div class="section-title">Harmonisch passende Folge-Tracks (Up Next)</div>
|
||||
<!-- Suggestions Controls & Grid (Tunebat-Style) -->
|
||||
<div class="suggestions-control-header">
|
||||
<div class="suggestions-title-group">
|
||||
<div class="section-title" style="margin: 0;">Harmonisch passende Folge-Tracks (Up Next)</div>
|
||||
<span class="suggestions-count-badge" id="suggestions-count">0 Tracks</span>
|
||||
</div>
|
||||
|
||||
<div class="suggestions-filters" id="suggestions-filters">
|
||||
<!-- Mix Type Filter -->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Mix:</span>
|
||||
<button class="filter-pill active" data-filter="all" onclick="setHarmonyFilter('all')">Alle</button>
|
||||
<button class="filter-pill" data-filter="exact" onclick="setHarmonyFilter('exact')">Exakt</button>
|
||||
<button class="filter-pill" data-filter="quinte" onclick="setHarmonyFilter('quinte')">Quinte (±1)</button>
|
||||
<button class="filter-pill" data-filter="relative" onclick="setHarmonyFilter('relative')">Relativ (A↔B)</button>
|
||||
<button class="filter-pill" data-filter="energy" onclick="setHarmonyFilter('energy')">Energy (+2)</button>
|
||||
</div>
|
||||
|
||||
<!-- BPM Pitch Tolerance Filter -->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Pitch:</span>
|
||||
<button class="filter-pill active" data-bpm-tol="4" onclick="setBpmTolerance(4)">±4% (DJ)</button>
|
||||
<button class="filter-pill" data-bpm-tol="8" onclick="setBpmTolerance(8)">±8%</button>
|
||||
<button class="filter-pill" data-bpm-tol="all" onclick="setBpmTolerance('all')">Alle BPM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestions" id="suggestions">
|
||||
<div style="color:var(--text-dim);font-size:12px;font-family:'JetBrains Mono',monospace;">Wähle oder spiele einen Track ab, um harmonische Übergänge zu berechnen.</div>
|
||||
</div>
|
||||
@@ -151,14 +236,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/shared/mixing-rules.js?v=2.0.1"></script>
|
||||
<script src="/shared/color-matrix.js?v=2.0.1"></script>
|
||||
<script src="/shared/camelot-angle.js?v=2.0.1"></script>
|
||||
<script src="/shared/camelot-to-musickey.js?v=2.0.1"></script>
|
||||
<script src="/shared/mixing-rules.js?v=2.1.0"></script>
|
||||
<script src="/shared/color-matrix.js?v=2.1.0"></script>
|
||||
<script src="/shared/camelot-angle.js?v=2.1.0"></script>
|
||||
<script src="/shared/camelot-to-musickey.js?v=2.1.0"></script>
|
||||
|
||||
<script src="wheel.js?v=2.0.1"></script>
|
||||
<script src="cover.js?v=2.0.1"></script>
|
||||
<script src="search.js?v=2.0.1"></script>
|
||||
<script src="app.js?v=2.0.1"></script>
|
||||
<script src="wheel.js?v=2.1.0"></script>
|
||||
<script src="cover.js?v=2.1.0"></script>
|
||||
<script src="search.js?v=2.1.0"></script>
|
||||
<script src="app.js?v=2.1.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
+205
-50
@@ -105,6 +105,13 @@ async function executeSearch(query) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(sec) {
|
||||
if (!sec || isNaN(sec)) return '-:--';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
}
|
||||
|
||||
async function selectTrack(track) {
|
||||
// Update Hero UI
|
||||
document.getElementById('track-title').textContent = track.title;
|
||||
@@ -112,35 +119,68 @@ async function selectTrack(track) {
|
||||
document.getElementById('stat-match').textContent = track.is_corrected ? '100%' : 'Online';
|
||||
document.getElementById('stat-source-sub').textContent = track.is_local ? (track.is_corrected ? 'PostgreSQL (Fix)' : 'PostgreSQL') : 'Deezer / Online';
|
||||
|
||||
// Stop any ongoing preview playback
|
||||
if (window.stopAllPreviews) window.stopAllPreviews();
|
||||
|
||||
// Update Cover
|
||||
if (track.cover_url) {
|
||||
const coverEl = document.getElementById('cover');
|
||||
coverEl.innerHTML = `<img src="${track.cover_url}" alt="cover">`;
|
||||
coverEl.innerHTML = `<img src="${track.cover_url}" alt="cover">
|
||||
<button id="hero-preview-btn" class="cover-play-btn" onclick="event.stopPropagation(); window.toggleHeroPreview()" title="30s Audio-Vorschau abspielen">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
</button>`;
|
||||
} else {
|
||||
window.fetchCover(`${track.artist} ${track.title}`, document.getElementById('cover'));
|
||||
}
|
||||
|
||||
// Update External Streaming Links
|
||||
const trackActions = document.getElementById('track-actions');
|
||||
if (trackActions) {
|
||||
trackActions.style.display = 'flex';
|
||||
const query = encodeURIComponent(`${track.artist} ${track.title}`);
|
||||
const spotEl = document.getElementById('action-spotify');
|
||||
const deezEl = document.getElementById('action-deezer');
|
||||
const ytEl = document.getElementById('action-youtube');
|
||||
if (spotEl) spotEl.href = `https://open.spotify.com/search/${query}`;
|
||||
if (deezEl) deezEl.href = `https://www.deezer.com/search/${query}`;
|
||||
if (ytEl) ytEl.href = `https://www.youtube.com/results?search_query=${query}`;
|
||||
}
|
||||
|
||||
// Fetch or apply metadata
|
||||
let key = track.camelot_key;
|
||||
let bpm = track.bpm;
|
||||
let tags = track.tags || ['dance'];
|
||||
let previewUrl = track.preview_url || null;
|
||||
let durationSec = track.duration_sec || 215;
|
||||
let energy = track.energy || null;
|
||||
let danceability = track.danceability || null;
|
||||
let happiness = track.happiness || null;
|
||||
|
||||
if (!key || !bpm) {
|
||||
if (!key || !bpm || !energy) {
|
||||
// Query metadata route to get key, bpm, and persist into PostgreSQL
|
||||
try {
|
||||
const metaRes = await fetch(`/api/metadata?track=${encodeURIComponent(track.title)}&artist=${encodeURIComponent(track.artist)}`);
|
||||
const metaJson = await metaRes.json();
|
||||
if (metaJson.status === 'success') {
|
||||
key = metaJson.data.key;
|
||||
bpm = metaJson.data.bpm;
|
||||
tags = metaJson.data.tags || tags;
|
||||
document.getElementById('stat-match').textContent = metaJson.data.is_corrected ? '100%' : (metaJson.data.source === 'postgresql' ? '100%' : 'Online');
|
||||
document.getElementById('stat-source-sub').textContent = metaJson.data.source === 'postgresql' ? (metaJson.data.is_corrected ? 'PostgreSQL (Fix)' : 'PostgreSQL') : 'PostgreSQL (Neu)';
|
||||
const d = metaJson.data;
|
||||
key = d.key;
|
||||
bpm = d.bpm;
|
||||
tags = d.tags || tags;
|
||||
previewUrl = d.preview_url || previewUrl;
|
||||
durationSec = d.duration_sec || durationSec;
|
||||
energy = d.energy;
|
||||
danceability = d.danceability;
|
||||
happiness = d.happiness;
|
||||
document.getElementById('stat-match').textContent = d.is_corrected ? '100%' : (d.source === 'postgresql' ? '100%' : 'Online');
|
||||
document.getElementById('stat-source-sub').textContent = d.source === 'postgresql' ? (d.is_corrected ? 'PostgreSQL (Fix)' : 'PostgreSQL') : 'PostgreSQL (Neu)';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Search] Metadata fetch failed, using fallback', e);
|
||||
key = '8B';
|
||||
bpm = 120;
|
||||
energy = 75;
|
||||
danceability = 70;
|
||||
happiness = 60;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,14 +190,24 @@ async function selectTrack(track) {
|
||||
artist: track.artist,
|
||||
key: key,
|
||||
bpm: bpm,
|
||||
cover_url: track.cover_url
|
||||
cover_url: track.cover_url,
|
||||
preview_url: previewUrl,
|
||||
duration_sec: durationSec,
|
||||
energy: energy,
|
||||
danceability: danceability,
|
||||
happiness: happiness,
|
||||
tags: tags
|
||||
};
|
||||
|
||||
updateHeroStats(key, bpm, tags);
|
||||
// Show hero preview button
|
||||
const heroBtn = document.getElementById('hero-preview-btn');
|
||||
if (heroBtn) heroBtn.style.display = 'flex';
|
||||
|
||||
updateHeroStats(key, bpm, tags, { durationSec, energy, danceability, happiness });
|
||||
renderSuggestions(key, bpm, track.artist, track.title);
|
||||
}
|
||||
|
||||
function updateHeroStats(key, bpm, tags = []) {
|
||||
function updateHeroStats(key, bpm, tags = [], attrs = {}) {
|
||||
document.getElementById('stat-key').textContent = key;
|
||||
const musicKey = window.camelotToMusicKey ? window.camelotToMusicKey[key] : '';
|
||||
document.getElementById('stat-key-sub').textContent = musicKey;
|
||||
@@ -169,6 +219,26 @@ function updateHeroStats(key, bpm, tags = []) {
|
||||
document.getElementById('stat-bpm').textContent = bpm;
|
||||
document.getElementById('stat-bpm-sub').textContent = `± ${Math.round(bpm * 0.04)} BPM`;
|
||||
|
||||
// Render Tunebat Audio Attributes Grid
|
||||
const attrGrid = document.getElementById('attributes-grid');
|
||||
if (attrGrid) {
|
||||
attrGrid.style.display = 'grid';
|
||||
const durSec = attrs.durationSec || 215;
|
||||
const nrg = attrs.energy || Math.min(95, Math.round(50 + ((bpm - 80) / 80) * 40));
|
||||
const dnc = attrs.danceability || 75;
|
||||
const mood = attrs.happiness || (key && key.endsWith('B') ? 68 : 45);
|
||||
|
||||
document.getElementById('attr-duration').textContent = formatDuration(durSec);
|
||||
document.getElementById('attr-energy').textContent = `${nrg}%`;
|
||||
document.getElementById('bar-energy').style.width = `${nrg}%`;
|
||||
|
||||
document.getElementById('attr-dance').textContent = `${dnc}%`;
|
||||
document.getElementById('bar-dance').style.width = `${dnc}%`;
|
||||
|
||||
document.getElementById('attr-mood').textContent = `${mood}%`;
|
||||
document.getElementById('bar-mood').style.width = `${mood}%`;
|
||||
}
|
||||
|
||||
// Render Tags
|
||||
const tagsRow = document.getElementById('tags-row');
|
||||
tagsRow.innerHTML = '';
|
||||
@@ -190,17 +260,136 @@ function updateHeroStats(key, bpm, tags = []) {
|
||||
window.updateWheel(key);
|
||||
}
|
||||
|
||||
// Render real harmonic suggestions from PostgreSQL
|
||||
// ----------------------------------------------------
|
||||
// Suggestions Management & Interactive Filters (Tunebat Style)
|
||||
// ----------------------------------------------------
|
||||
let allSuggestionsData = [];
|
||||
let currentHarmonyFilter = 'all'; // 'all', 'exact', 'quinte', 'relative', 'energy'
|
||||
let currentBpmTolerance = 4; // 4, 8, 'all'
|
||||
let currentRefBpm = 120;
|
||||
|
||||
function setHarmonyFilter(filter) {
|
||||
currentHarmonyFilter = filter;
|
||||
document.querySelectorAll('#suggestions-filters [data-filter]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-filter') === filter);
|
||||
});
|
||||
applySuggestionsFilter();
|
||||
}
|
||||
|
||||
function setBpmTolerance(tolerance) {
|
||||
currentBpmTolerance = tolerance;
|
||||
document.querySelectorAll('#suggestions-filters [data-bpm-tol]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-bpm-tol') === String(tolerance));
|
||||
});
|
||||
applySuggestionsFilter();
|
||||
}
|
||||
|
||||
function applySuggestionsFilter() {
|
||||
const sugContainer = document.getElementById('suggestions');
|
||||
const countBadge = document.getElementById('suggestions-count');
|
||||
if (!sugContainer) return;
|
||||
|
||||
if (!allSuggestionsData || allSuggestionsData.length === 0) {
|
||||
if (countBadge) countBadge.textContent = '0 Tracks';
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Keine passenden Tracks gefunden.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Filter by Harmony Mix Type
|
||||
let filtered = allSuggestionsData.filter(s => {
|
||||
if (currentHarmonyFilter === 'all') return true;
|
||||
if (currentHarmonyFilter === 'exact') return s.compat === 0; // Perfect (same key)
|
||||
if (currentHarmonyFilter === 'quinte') return s.compat === 1 || s.compat === 2; // Quinte -1 / +1
|
||||
if (currentHarmonyFilter === 'relative') return s.compat === 4; // Scale (A <-> B)
|
||||
if (currentHarmonyFilter === 'energy') return s.compat === 3; // Energy Boost (+2)
|
||||
return true;
|
||||
});
|
||||
|
||||
// 2. Filter by BPM Pitch Tolerance
|
||||
if (currentBpmTolerance !== 'all') {
|
||||
const tolPercent = Number(currentBpmTolerance) / 100;
|
||||
const minBpm = currentRefBpm * (1 - tolPercent);
|
||||
const maxBpm = currentRefBpm * (1 + tolPercent);
|
||||
filtered = filtered.filter(s => s.bpm >= minBpm && s.bpm <= maxBpm);
|
||||
}
|
||||
|
||||
if (countBadge) {
|
||||
countBadge.textContent = `${filtered.length} Tracks`;
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Keine Tracks für die gewählten Filter vorhanden. Wähle eine größere BPM-Toleranz oder "Alle".</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sugContainer.innerHTML = '';
|
||||
filtered.forEach(s => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sug';
|
||||
const idSafe = `sug-${s.c}-${(s.title || '').replace(/[^A-Za-z0-9]/g, '')}`;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="sug-cover" id="${idSafe}">
|
||||
${s.cover_url ? `<img src="${s.cover_url}" alt="cover" loading="lazy">` : '<div class="sug-cover-placeholder">♪</div>'}
|
||||
<button class="sug-play-btn" onclick="event.stopPropagation(); window.togglePreviewTrack(s, this)" title="30s Vorschau abspielen">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sug-meta">
|
||||
<div class="sug-key" style="background:${s.color}">${s.c}</div>
|
||||
<div class="sug-bpm">${s.bpm} BPM</div>
|
||||
</div>
|
||||
<div class="sug-sub-stats">
|
||||
<span>⏱️ ${formatDuration(s.duration_sec)}</span>
|
||||
<span class="sug-attr-pill sug-attr-energy">⚡ ${s.energy || 75}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sug-title" title="${escapeHtml(s.title)}">${escapeHtml(s.title)}</div>
|
||||
<div class="sug-artist">${escapeHtml(s.artist)} · ${s.m}</div>
|
||||
</div>
|
||||
<div class="sug-bottom-row">
|
||||
<div class="sug-compat">${s.compatLabel || 'KOMPATIBEL'}</div>
|
||||
<div class="sug-links">
|
||||
<a href="https://open.spotify.com/search/${encodeURIComponent(s.artist + ' ' + s.title)}" target="_blank" rel="noopener" class="sug-link-icon sug-link-spotify" onclick="event.stopPropagation()" title="Auf Spotify öffnen">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
</a>
|
||||
<a href="https://www.deezer.com/search/${encodeURIComponent(s.artist + ' ' + s.title)}" target="_blank" rel="noopener" class="sug-link-icon sug-link-deezer" onclick="event.stopPropagation()" title="Auf Deezer öffnen">
|
||||
<i class="fa-brands fa-deezer"></i>
|
||||
</a>
|
||||
<a href="https://www.youtube.com/results?search_query=${encodeURIComponent(s.artist + ' ' + s.title)}" target="_blank" rel="noopener" class="sug-link-icon sug-link-youtube" onclick="event.stopPropagation()" title="Auf YouTube suchen">
|
||||
<i class="fa-brands fa-youtube"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click to transition to this song as next current track!
|
||||
div.addEventListener('click', () => {
|
||||
selectTrack(s);
|
||||
});
|
||||
|
||||
sugContainer.appendChild(div);
|
||||
|
||||
// Lazy load cover if not already available
|
||||
if (!s.cover_url) {
|
||||
window.fetchCover(`${s.artist} ${s.title}`, div.querySelector('.sug-cover'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render real harmonic suggestions from PostgreSQL & Online
|
||||
async function renderSuggestions(currentKey, currentBpm = 120, artist = '', title = '') {
|
||||
const sugContainer = document.getElementById('suggestions');
|
||||
if (!sugContainer) return;
|
||||
|
||||
currentRefBpm = currentBpm;
|
||||
|
||||
if (!currentKey) {
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Wähle einen Track für Vorschläge.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Berechne harmonische Übergänge aus PostgreSQL...</div>';
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Berechne harmonische Übergänge & Tunebat-Metriken...</div>';
|
||||
|
||||
try {
|
||||
const url = `/api/suggestions?key=${encodeURIComponent(currentKey)}&bpm=${currentBpm}&artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`;
|
||||
@@ -212,44 +401,8 @@ async function renderSuggestions(currentKey, currentBpm = 120, artist = '', titl
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json.data || json.data.length === 0) {
|
||||
sugContainer.innerHTML = '<div style="color:var(--text-dim);font-size:12px;font-family:monospace;">Keine passenden Tracks in der Datenbank gefunden. Suche weitere Tracks, um deine Library zu erweitern.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
sugContainer.innerHTML = '';
|
||||
json.data.forEach(s => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sug';
|
||||
const idSafe = `sug-${s.c}-${(s.title || '').replace(/[^A-Za-z0-9]/g, '')}`;
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="sug-cover" id="${idSafe}">
|
||||
${s.cover_url ? `<img src="${s.cover_url}" alt="cover" loading="lazy">` : '<div class="sug-cover-placeholder">♪</div>'}
|
||||
</div>
|
||||
<div class="sug-meta">
|
||||
<div class="sug-key" style="background:${s.color}">${s.c}</div>
|
||||
<div class="sug-bpm">${s.bpm} BPM</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sug-title" title="${escapeHtml(s.title)}">${escapeHtml(s.title)}</div>
|
||||
<div class="sug-artist">${escapeHtml(s.artist)} · ${s.m}</div>
|
||||
</div>
|
||||
<div class="sug-compat">${s.compatLabel || 'KOMPATIBEL'}</div>
|
||||
`;
|
||||
|
||||
// Click to transition to this song as next current track!
|
||||
div.addEventListener('click', () => {
|
||||
selectTrack(s);
|
||||
});
|
||||
|
||||
sugContainer.appendChild(div);
|
||||
|
||||
// Lazy load cover if not already available
|
||||
if (!s.cover_url) {
|
||||
window.fetchCover(`${s.artist} ${s.title}`, div.querySelector('.sug-cover'));
|
||||
}
|
||||
});
|
||||
allSuggestionsData = json.data || [];
|
||||
applySuggestionsFilter();
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Suggestions] Error loading suggestions:', e);
|
||||
@@ -371,3 +524,5 @@ window.openKeyBpmModal = openKeyBpmModal;
|
||||
window.closeKeyBpmModal = closeKeyBpmModal;
|
||||
window.saveKeyBpmCorrection = saveKeyBpmCorrection;
|
||||
window.closeModalOnOverlay = closeModalOnOverlay;
|
||||
window.setHarmonyFilter = setHarmonyFilter;
|
||||
window.setBpmTolerance = setBpmTolerance;
|
||||
@@ -375,3 +375,305 @@ body { padding: 24px 32px; min-height: 100vh; }
|
||||
.btn-secondary:hover { border-color: var(--text-dim); }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
/* ---------------------------------------------------- */
|
||||
/* TUNEBAT-STYLE EXTENSIONS: Previews, Attributes, Links */
|
||||
/* ---------------------------------------------------- */
|
||||
|
||||
/* Cover Play Button Overlay */
|
||||
.cover-play-btn {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(14, 14, 20, 0.78);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
|
||||
transition: all 0.18s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
.cover-play-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: var(--accent);
|
||||
border-color: #fff;
|
||||
}
|
||||
.cover-play-btn.playing {
|
||||
background: #4F9D69;
|
||||
border-color: #5BE8B5;
|
||||
animation: pulsePreview 1.5s infinite;
|
||||
}
|
||||
@keyframes pulsePreview {
|
||||
0% { box-shadow: 0 0 0 0 rgba(79, 157, 105, 0.6); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(79, 157, 105, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(79, 157, 105, 0); }
|
||||
}
|
||||
|
||||
/* Track Header & External Action Links */
|
||||
.info-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.track-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
padding: 5px 9px;
|
||||
border-radius: 5px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.link-btn:hover {
|
||||
color: #fff;
|
||||
border-color: var(--text-dim);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.link-spotify:hover {
|
||||
background: #1DB954;
|
||||
border-color: #1DB954;
|
||||
color: #000;
|
||||
}
|
||||
.link-deezer:hover {
|
||||
background: #A238FF;
|
||||
border-color: #A238FF;
|
||||
color: #fff;
|
||||
}
|
||||
.link-youtube:hover {
|
||||
background: #FF0000;
|
||||
border-color: #FF0000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Tunebat Audio Attributes Grid */
|
||||
.attributes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.attributes-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
.attr-card {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
.attr-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.attr-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.attr-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.attr-energy { color: #E0B423; }
|
||||
.attr-dance { color: #4F9D69; }
|
||||
.attr-mood { color: #00E5FF; }
|
||||
|
||||
.attr-bar-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.attr-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fill-energy { background: linear-gradient(90deg, #E0B423, #FF7582); }
|
||||
.fill-dance { background: linear-gradient(90deg, #5BE8B5, #4F9D69); }
|
||||
.fill-mood { background: linear-gradient(90deg, #00E5FF, #8B94F8); }
|
||||
|
||||
/* Suggestions Header & Filter Controls */
|
||||
.suggestions-control-header {
|
||||
margin: 32px 0 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.suggestions-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.suggestions-count-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.suggestions-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-pill {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.14s ease;
|
||||
outline: none;
|
||||
}
|
||||
.filter-pill:hover {
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.filter-pill.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Suggestion Card Updates */
|
||||
.sug-cover {
|
||||
position: relative;
|
||||
}
|
||||
.sug-play-btn {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.sug:hover .sug-play-btn {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.sug-play-btn:hover {
|
||||
background: var(--accent);
|
||||
border-color: #fff;
|
||||
transform: scale(1.15) !important;
|
||||
}
|
||||
.sug-play-btn.playing {
|
||||
background: #4F9D69;
|
||||
border-color: #5BE8B5;
|
||||
opacity: 1;
|
||||
animation: pulsePreview 1.5s infinite;
|
||||
}
|
||||
.sug-sub-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sug-attr-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sug-attr-energy { color: #E0B423; }
|
||||
.sug-bottom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sug-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.sug-link-icon {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.sug-link-icon:hover { color: var(--text-bright); }
|
||||
.sug-link-spotify:hover { color: #1DB954; }
|
||||
.sug-link-deezer:hover { color: #A238FF; }
|
||||
.sug-link-youtube:hover { color: #FF0000; }
|
||||
|
||||
+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