feat: import 26k+ track catalog, fix suggestions frontend & backend, interactive wheel
This commit is contained in:
1 parent
867cc191cd
commit
58bc11f1d7
7 files changed
+33115
-12
No files matched your search
+32834
File diff suppressed because it is too large.
Load diff
+71
-9
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user