From fc1adda97d4f709c3cab6bea7d563d8b9f376ddc Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Sun, 13 Sep 2026 01:13:33 +0200 Subject: [PATCH] feat: Add Tunebat feature parity (30s previews, audio features, filters, Spotify/Deezer/YouTube quick links) --- db/schema.sql | 125 +++++++++------ public/app.js | 112 ++++++++++++- public/index.html | 111 +++++++++++-- public/search.js | 255 +++++++++++++++++++++++------ public/styles.css | 302 +++++++++++++++++++++++++++++++++++ server/db.js | 18 ++- server/routes/metadata.js | 52 +++++- server/routes/recognize.js | 7 +- server/routes/search.js | 10 ++ server/routes/suggestions.js | 27 +++- 10 files changed, 894 insertions(+), 125 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index 1654570..3143fdc 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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; diff --git a/public/app.js b/public/app.js index cc88e89..c252eac 100644 --- a/public/app.js +++ b/public/app.js @@ -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', () => { diff --git a/public/index.html b/public/index.html index dac24a8..f19202d 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ - +
@@ -61,12 +61,31 @@ ♪ No Cover
+
-
Warte auf Audio...
-
Starte PC-Audio oder Mikrofon oben rechts
+
+
+
Warte auf Audio...
+
Starte PC-Audio oder Mikrofon oben rechts
+
+ + +
@@ -98,6 +117,46 @@
+ + +
@@ -111,8 +170,34 @@ - -
Harmonisch passende Folge-Tracks (Up Next)
+ +
+
+
Harmonisch passende Folge-Tracks (Up Next)
+ 0 Tracks +
+ +
+ +
+ Mix: + + + + + +
+ + +
+ Pitch: + + + +
+
+
+
Wähle oder spiele einen Track ab, um harmonische Übergänge zu berechnen.
@@ -151,14 +236,14 @@ - - - - + + + + - - - - + + + + diff --git a/public/search.js b/public/search.js index 814bb91..7a6590e 100644 --- a/public/search.js +++ b/public/search.js @@ -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 = `cover`; + coverEl.innerHTML = `cover + `; } 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 = '
Keine passenden Tracks gefunden.
'; + 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 = '
Keine Tracks für die gewählten Filter vorhanden. Wähle eine größere BPM-Toleranz oder "Alle".
'; + 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 = ` +
+ ${s.cover_url ? `cover` : '
♪
'} + +
+
+
${s.c}
+
${s.bpm} BPM
+
+
+ ⏱️ ${formatDuration(s.duration_sec)} + ⚡ ${s.energy || 75}% +
+
+
${escapeHtml(s.title)}
+
${escapeHtml(s.artist)} · ${s.m}
+
+
+
${s.compatLabel || 'KOMPATIBEL'}
+ +
+ `; + + // 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 = '
Wähle einen Track für Vorschläge.
'; return; } - sugContainer.innerHTML = '
Berechne harmonische Übergänge aus PostgreSQL...
'; + sugContainer.innerHTML = '
Berechne harmonische Übergänge & Tunebat-Metriken...
'; 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 = '
Keine passenden Tracks in der Datenbank gefunden. Suche weitere Tracks, um deine Library zu erweitern.
'; - 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 = ` -
- ${s.cover_url ? `cover` : '
♪
'} -
-
-
${s.c}
-
${s.bpm} BPM
-
-
-
${escapeHtml(s.title)}
-
${escapeHtml(s.artist)} · ${s.m}
-
-
${s.compatLabel || 'KOMPATIBEL'}
- `; - - // 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; diff --git a/public/styles.css b/public/styles.css index de3fb3a..59a9112 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } + diff --git a/server/db.js b/server/db.js index 0fafddb..fbb11dd 100644 --- a/server/db.js +++ b/server/db.js @@ -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 diff --git a/server/routes/metadata.js b/server/routes/metadata.js index 6e966ff..0bd9a50 100644 --- a/server/routes/metadata.js +++ b/server/routes/metadata.js @@ -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' } }); diff --git a/server/routes/recognize.js b/server/routes/recognize.js index b4c78f0..1bcec01 100644 --- a/server/routes/recognize.js +++ b/server/routes/recognize.js @@ -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 } }); diff --git a/server/routes/search.js b/server/routes/search.js index c39649f..a5c3fef 100644 --- a/server/routes/search.js +++ b/server/routes/search.js @@ -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 }); diff --git a/server/routes/suggestions.js b/server/routes/suggestions.js index 59d624b..8c6b49a 100644 --- a/server/routes/suggestions.js +++ b/server/routes/suggestions.js @@ -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 || [] }; });