feat: import 26k+ track catalog, fix suggestions frontend & backend, interactive wheel

This commit is contained in:
Jannik committed 2026-09-13 12:39:34 +02:00
1 parent 867cc191cd
commit 58bc11f1d7
7 files changed
+33115 -12

No files matched your search

+32834
View File
File diff suppressed because it is too large. Load diff
+71 -9
View File
@@ -168,14 +168,14 @@ async function selectTrack(track) {
}
// Fetch or apply metadata
let key = track.camelot_key;
let key = track.camelot_key || track.key || track.c;
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;
let energy = track.energy !== undefined ? track.energy : null;
let danceability = track.danceability !== undefined ? track.danceability : null;
let happiness = track.happiness !== undefined ? track.happiness : null;
let deezerLink = track.deezer_link || (track.deezer_id ? `https://www.deezer.com/track/${track.deezer_id}` : null);
let deezerId = track.deezer_id || null;
@@ -336,16 +336,23 @@ function applySuggestionsFilter() {
return true;
});
// 2. Filter by BPM Pitch Tolerance
let isFallback = false;
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);
const bpmFiltered = filtered.filter(s => s.bpm >= minBpm && s.bpm <= maxBpm);
if (bpmFiltered.length > 0) {
filtered = bpmFiltered;
} else if (filtered.length > 0) {
isFallback = true;
// Keep best matches sorted by compatibility
filtered = filtered.slice(0, 10);
}
}
if (countBadge) {
countBadge.textContent = `${filtered.length} Tracks`;
countBadge.textContent = isFallback ? `${filtered.length} Tracks (Erweitert)` : `${filtered.length} Tracks`;
}
if (filtered.length === 0) {
@@ -354,21 +361,38 @@ function applySuggestionsFilter() {
}
sugContainer.innerHTML = '';
if (isFallback) {
const notice = document.createElement('div');
notice.className = 'sug-notice-banner';
notice.innerHTML = `<i class="fa-solid fa-circle-info"></i> Keine Tracks bei ±${currentBpmTolerance}% — zeige die harmonisch passendsten verfügbaren Tracks mit Pitch-Abweichung:`;
sugContainer.appendChild(notice);
}
filtered.forEach(s => {
const div = document.createElement('div');
div.className = 'sug';
const idSafe = `sug-${s.c}-${(s.title || '').replace(/[^A-Za-z0-9]/g, '')}`;
const pitchDiff = s.pitch_diff_pct !== undefined
? s.pitch_diff_pct
: (currentRefBpm ? Math.round(((s.bpm - currentRefBpm) / currentRefBpm) * 100) : 0);
const pitchSign = pitchDiff > 0 ? `+${pitchDiff}%` : `${pitchDiff}%`;
const pitchBadgeClass = Math.abs(pitchDiff) <= 4 ? 'pitch-dj' : (Math.abs(pitchDiff) <= 8 ? 'pitch-mid' : 'pitch-far');
const pitchBadgeHtml = (currentRefBpm && Math.abs(pitchDiff) > 0)
? `<span class="sug-pitch-badge ${pitchBadgeClass}" title="Pitch-Anpassung: ${pitchSign}">${pitchSign}</span>`
: '';
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">
<button class="sug-play-btn" 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 class="sug-bpm">${s.bpm} BPM ${pitchBadgeHtml}</div>
</div>
<div class="sug-sub-stats">
<span>⏱️ ${formatDuration(s.duration_sec)}</span>
@@ -394,6 +418,15 @@ function applySuggestionsFilter() {
</div>
`;
// Direct event listener for play preview button
const playBtn = div.querySelector('.sug-play-btn');
if (playBtn) {
playBtn.addEventListener('click', (e) => {
e.stopPropagation();
window.togglePreviewTrack(s, playBtn);
});
}
// Click to transition to this song as next current track!
div.addEventListener('click', () => {
selectTrack(s);
@@ -549,8 +582,37 @@ function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function selectKeyFromWheel(key, musicKey) {
if (!key) return;
const bpm = currentRefBpm || 120;
// Set hero title/artist if no active track
if (!window.currentTrack || !window.currentTrack.title) {
const titleEl = document.getElementById('track-title');
const artistEl = document.getElementById('track-artist');
if (titleEl) titleEl.textContent = `Harmonie für ${key}`;
if (artistEl) artistEl.textContent = musicKey ? `${musicKey} (Auswahl über Wheel)` : 'Auswahl über Wheel';
}
const keyColor = window.colorMatrix ? window.colorMatrix[key] : '#4F9D69';
const keyEl = document.getElementById('stat-key');
const keySubEl = document.getElementById('stat-key-sub');
if (keyEl) {
keyEl.textContent = key;
keyEl.style.color = keyColor;
}
if (keySubEl) {
keySubEl.textContent = musicKey || (window.camelotToMusicKey ? window.camelotToMusicKey[key] : '');
}
document.documentElement.style.setProperty('--key-current', keyColor);
window.updateWheel(key);
renderSuggestions(key, bpm, window.currentTrack ? window.currentTrack.artist : '', window.currentTrack ? window.currentTrack.title : '');
}
window.renderSuggestions = renderSuggestions;
window.selectTrack = selectTrack;
window.selectKeyFromWheel = selectKeyFromWheel;
window.openKeyBpmModal = openKeyBpmModal;
window.closeKeyBpmModal = closeKeyBpmModal;
window.saveKeyBpmCorrection = saveKeyBpmCorrection;
+57
View File
@@ -677,3 +677,60 @@ body { padding: 24px 32px; min-height: 100vh; }
.sug-link-deezer:hover { color: #A238FF; }
.sug-link-youtube:hover { color: #FF0000; }
/* Wheel Segment Interactive Hover */
.wheel-segment {
cursor: pointer;
}
.wheel-segment polygon {
transition: filter 0.15s ease, opacity 0.15s ease;
}
.wheel-segment:hover polygon {
filter: brightness(1.25);
}
.wheel-segment:hover text {
font-weight: 800;
}
/* Suggestions Notice Banner */
.sug-notice-banner {
grid-column: 1 / -1;
padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
font-size: 11px;
color: var(--text-bright);
font-family: 'JetBrains Mono', monospace;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
/* Pitch Badges */
.sug-pitch-badge {
display: inline-block;
padding: 1px 5px;
border-radius: 4px;
font-size: 9px;
font-weight: 700;
margin-left: 4px;
}
.sug-pitch-badge.pitch-dj {
background: rgba(0, 229, 255, 0.15);
color: #00E5FF;
border: 1px solid rgba(0, 229, 255, 0.4);
}
.sug-pitch-badge.pitch-mid {
background: rgba(255, 193, 7, 0.15);
color: #FFC107;
border: 1px solid rgba(255, 193, 7, 0.4);
}
.sug-pitch-badge.pitch-far {
background: rgba(255, 87, 34, 0.15);
color: #FF7582;
border: 1px solid rgba(255, 87, 34, 0.4);
}
+15 -3
View File
@@ -96,7 +96,6 @@ function buildWheel(svgId, viewBoxSize, currentKey = null, compatIdxs = { primar
poly.setAttribute('stroke-width', '0.5');
}
poly.setAttribute('stroke-linejoin', 'miter');
svg.appendChild(poly);
const [lx, ly] = p((rOut + rIn) / 2, aMid);
@@ -110,7 +109,6 @@ function buildWheel(svgId, viewBoxSize, currentKey = null, compatIdxs = { primar
t1.setAttribute('font-weight', '700');
t1.setAttribute('fill', (isCurrent || isPrimary) ? '#FFFFFF' : theme.textBright);
t1.textContent = c;
svg.appendChild(t1);
const t2 = document.createElementNS(NS, 'text');
t2.setAttribute('x', lx);
@@ -121,7 +119,21 @@ function buildWheel(svgId, viewBoxSize, currentKey = null, compatIdxs = { primar
t2.setAttribute('font-size', isMajor ? viewBoxSize/26 : viewBoxSize/32);
t2.setAttribute('fill', (isCurrent || isPrimary) ? '#EEEEEE' : theme.textDim);
t2.textContent = m;
svg.appendChild(t2);
const segmentG = document.createElementNS(NS, 'g');
segmentG.setAttribute('class', 'wheel-segment');
segmentG.style.cursor = 'pointer';
segmentG.addEventListener('click', (e) => {
e.stopPropagation();
if (window.selectKeyFromWheel) {
window.selectKeyFromWheel(c, m);
}
});
segmentG.appendChild(poly);
segmentG.appendChild(t1);
segmentG.appendChild(t2);
svg.appendChild(segmentG);
});
// Outer circle border
+5
View File
@@ -1,6 +1,7 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const { importCatalogIfEmpty } = require('./importer');
let pool = null;
let isConnected = false;
@@ -40,6 +41,10 @@ async function initDb() {
await client.query(sql);
console.log('[PostgreSQL] Schema and seed data verified/applied.');
}
// Automatically seed full 26,000+ track catalog if DB is empty or below threshold
await importCatalogIfEmpty(client);
client.release();
} catch (err) {
isConnected = false;
+129
View File
@@ -0,0 +1,129 @@
const fs = require('fs');
const readline = require('readline');
const path = require('path');
const PITCH_MAJOR = ['8B', '3B', '10B', '5B', '12B', '7B', '2B', '9B', '4B', '11B', '6B', '1B'];
const PITCH_MINOR = ['5A', '12A', '7A', '2A', '9A', '4A', '11A', '6A', '1A', '8A', '3A', '10A'];
const MUSIC_MAJOR = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'];
const MUSIC_MINOR = ['Cm', 'C#m', 'Dm', 'D#m', 'Em', 'Fm', 'F#m', 'Gm', 'G#m', 'Am', 'Bbm', 'Bm'];
function parseCsvLine(line) {
const result = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (ch === ',' && !inQuotes) {
result.push(cur);
cur = '';
} else {
cur += ch;
}
}
result.push(cur);
return result;
}
async function importCatalogIfEmpty(pool) {
if (!pool) return;
try {
const countRes = await pool.query('SELECT COUNT(*) FROM tracks');
const count = parseInt(countRes.rows[0].count, 10);
if (count >= 5000) {
console.log(`[Catalog Importer] Database already contains ${count} tracks. Skipping seed import.`);
return;
}
const csvPath = path.join(__dirname, '../db/spotify_songs.csv');
if (!fs.existsSync(csvPath)) {
console.warn(`[Catalog Importer] Dataset not found at ${csvPath}. Skipping.`);
return;
}
console.log(`[Catalog Importer] Database has only ${count} tracks. Importing full 26,000+ catalog...`);
const fileStream = fs.createReadStream(csvPath);
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
let isHeader = true;
const rows = [];
const seen = new Set();
for await (const line of rl) {
if (isHeader) {
isHeader = false;
continue;
}
const cols = parseCsvLine(line);
const title = cols[1]?.trim();
const artist = cols[2]?.trim();
const genre = cols[9]?.trim();
const subgenre = cols[10]?.trim();
const dance = Math.round(parseFloat(cols[11] || '0.7') * 100);
const energy = Math.round(parseFloat(cols[12] || '0.7') * 100);
const keyNum = parseInt(cols[13], 10);
const mode = parseInt(cols[15], 10);
const happy = Math.round(parseFloat(cols[20] || '0.5') * 100);
const bpm = Math.round(parseFloat(cols[21] || '120'));
const durationSec = Math.round(parseInt(cols[22] || '210000', 10) / 1000);
if (!title || !artist || isNaN(keyNum) || isNaN(mode) || !bpm) continue;
const cKey = mode === 1 ? PITCH_MAJOR[keyNum] : PITCH_MINOR[keyNum];
const mKey = mode === 1 ? MUSIC_MAJOR[keyNum] : MUSIC_MINOR[keyNum];
if (!cKey) continue;
const dedup = `${artist.toLowerCase()} - ${title.toLowerCase()}`;
if (seen.has(dedup)) continue;
seen.add(dedup);
const tags = [];
if (genre) tags.push(genre.toLowerCase());
if (subgenre && subgenre !== genre) tags.push(subgenre.toLowerCase());
rows.push([artist, title, cKey, bpm, mKey, durationSec, energy, dance, happy, tags, 'spotify']);
}
console.log(`[Catalog Importer] Loaded ${rows.length} unique tracks from dataset. Inserting into PostgreSQL...`);
const BATCH_SIZE = 500;
let inserted = 0;
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const chunk = rows.slice(i, i + BATCH_SIZE);
const valuePlaceholders = [];
const params = [];
let pIdx = 1;
for (const r of chunk) {
valuePlaceholders.push(`($${pIdx}, $${pIdx + 1}, $${pIdx + 2}, $${pIdx + 3}, $${pIdx + 4}, $${pIdx + 5}, $${pIdx + 6}, $${pIdx + 7}, $${pIdx + 8}, $${pIdx + 9}, $${pIdx + 10})`);
params.push(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[10]);
pIdx += 11;
}
const sql = `
INSERT INTO tracks (artist, title, camelot_key, bpm, music_key, duration_sec, energy, danceability, happiness, tags, source)
VALUES ${valuePlaceholders.join(',\n')}
ON CONFLICT (artist, title) DO NOTHING
`;
await pool.query(sql, params);
inserted += chunk.length;
}
const finalRes = await pool.query('SELECT COUNT(*) FROM tracks');
console.log(`[Catalog Importer] Successfully initialized catalog! Total tracks in DB: ${finalRes.rows[0].count}`);
} catch (err) {
console.error('[Catalog Importer] Error importing dataset:', err.message);
}
}
module.exports = { importCatalogIfEmpty };
+4
View File
@@ -66,13 +66,17 @@ router.get('/', async (req, res) => {
const defaultDance = Math.min(94, Math.max(50, Math.round(65 + ((bpmVal - 100) / 60) * 25)));
const defaultHappy = isMajor ? 70 : 46;
const pitchDiff = currentBpm ? Math.round(((bpmVal - currentBpm) / currentBpm) * 100) : 0;
return {
id: t.id,
title: t.title,
artist: t.artist,
camelot_key: t.camelot_key,
key: t.camelot_key,
c: t.camelot_key,
bpm: t.bpm,
pitch_diff_pct: pitchDiff,
musicKey: musicKey,
m: musicKey,
color: color,