This commit introduces pagination and real-time updates to the music site, improving performance and user experience for browsing tracks across genres. Key changes include: music_site.js: Added Socket.IO for real-time communication between client and server. Implemented pagination with TRACKS_PER_PAGE (4 tracks per page) in getTracks function, returning paginated tracks, current page, total pages, and total tracks. Enhanced slug generation with usedSlugs Map to ensure uniqueness within genres. Updated routes and WebSocket handlers to support pagination: Modified getTracks to handle page parameters and return paginated data. Added io.on('connection') to handle request_page events and emit page_data or error. Updated / and /sitemap.xml to work with paginated track data. Adjusted track retrieval in /:genre/track/:slug and /json/:genre to use allTracks for consistency. index.ejs: Added pagination controls (Previous/Next buttons) for each genre section, displaying current page and total pages. Introduced page-container divs to manage paginated track displays, with active class for the current page. Implemented client-side WebSocket logic with Socket.IO to request and render new pages dynamically. Added page caching (pageCache) to store rendered pages and reduce server requests. Enhanced lazy loading of iframes to apply to dynamically loaded pages. Updated styling for page-container and iframes to ensure proper layout and responsiveness. These changes enable users to navigate through tracks efficiently without loading all tracks at once, reduce server load, and provide a smoother browsing experience with real-time updates.
351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
const express = require('express');
|
|
const app = express();
|
|
const path = require('path');
|
|
const Soundcloud = require('soundcloud.ts').default;
|
|
const fs = require('fs');
|
|
const slugify = require('slugify');
|
|
const http = require('http');
|
|
const socketIo = require('socket.io');
|
|
const PORT = process.env.PORT || 6767;
|
|
|
|
const server = http.createServer(app);
|
|
const io = socketIo(server);
|
|
|
|
const playlists = {
|
|
metal: {
|
|
url: 'https://soundcloud.com/snxraven/sets/raven-scott-metal',
|
|
cacheFile: path.join(__dirname, 'cache_metal.json'),
|
|
tracks: []
|
|
},
|
|
altrock: {
|
|
url: 'https://soundcloud.com/snxraven/sets/raven-scott-alt-rock',
|
|
cacheFile: path.join(__dirname, 'cache_altrock.json'),
|
|
tracks: []
|
|
},
|
|
rap: {
|
|
url: 'https://soundcloud.com/snxraven/sets/raven-scott-rap',
|
|
cacheFile: path.join(__dirname, 'cache_rap.json'),
|
|
tracks: []
|
|
},
|
|
lofi: {
|
|
url: 'https://soundcloud.com/snxraven/sets/raven-scott-lofi',
|
|
cacheFile: path.join(__dirname, 'cache_lofi.json'),
|
|
tracks: []
|
|
},
|
|
edm: {
|
|
url: 'https://soundcloud.com/snxraven/sets/raven-scott-edm',
|
|
cacheFile: path.join(__dirname, 'cache_edm.json'),
|
|
tracks: []
|
|
},
|
|
cuts: {
|
|
url: 'https://soundcloud.com/snxraven/sets/lets-cut-it',
|
|
cacheFile: path.join(__dirname, 'cache_cuts.json'),
|
|
tracks: []
|
|
}
|
|
};
|
|
|
|
const TRACKS_PER_PAGE = 4;
|
|
|
|
// Track used slugs to ensure uniqueness
|
|
const usedSlugs = new Map(); // genre -> Set of slugs
|
|
|
|
// Helper function to create a unique slug from track title
|
|
function generateSlug(title, genre) {
|
|
try {
|
|
let baseSlug = slugify(title || 'unknown', {
|
|
lower: true,
|
|
strict: true,
|
|
remove: /[*+~.()'"!:@]/g
|
|
});
|
|
|
|
if (!baseSlug || baseSlug.trim() === '') {
|
|
console.warn(`Empty slug for title: "${title}". Using fallback.`);
|
|
baseSlug = 'track-' + Date.now();
|
|
}
|
|
|
|
// Ensure uniqueness within the genre
|
|
let slug = baseSlug;
|
|
let counter = 1;
|
|
const genreSlugs = usedSlugs.get(genre) || new Set();
|
|
|
|
while (genreSlugs.has(slug)) {
|
|
slug = `${baseSlug}-${counter}`;
|
|
counter++;
|
|
}
|
|
|
|
genreSlugs.add(slug);
|
|
usedSlugs.set(genre, genreSlugs);
|
|
|
|
console.log(`Generated slug for "${title}" in genre "${genre}": "${slug}"`);
|
|
return slug;
|
|
} catch (err) {
|
|
console.error(`Error generating slug for title: "${title}" in genre "${genre}"`, err);
|
|
const fallback = 'track-' + Date.now();
|
|
const genreSlugs = usedSlugs.get(genre) || new Set();
|
|
genreSlugs.add(fallback);
|
|
usedSlugs.set(genre, genreSlugs);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
// Helper function to sanitize track data
|
|
function sanitizeTrackData(track, genre) {
|
|
console.log(`Sanitizing track data for "${track.title || 'Unknown'}" in genre "${genre}"`);
|
|
|
|
const permalinkUrl = typeof track.permalink_url === 'string' ? track.permalink_url :
|
|
typeof track.url === 'string' ? track.url :
|
|
typeof track.permalink === 'string' ? track.permalink : '';
|
|
|
|
const embedUrl = permalinkUrl ?
|
|
`https://w.soundcloud.com/player/?url=${encodeURIComponent(permalinkUrl)}&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true` :
|
|
'';
|
|
|
|
if (!embedUrl) {
|
|
console.warn(`No embed URL generated for track "${track.title || 'Unknown'}" in genre "${genre}"`);
|
|
}
|
|
|
|
const sanitized = {
|
|
title: typeof track.title === 'string' ? track.title : 'Unknown Title',
|
|
description: typeof track.description === 'string' ? track.description : 'No description available',
|
|
url: permalinkUrl,
|
|
embedUrl: embedUrl,
|
|
playCount: Number.isFinite(track.playback_count) ? track.playback_count :
|
|
Number.isFinite(track.playCount) ? track.playCount : 0,
|
|
publishedAt: typeof track.created_at === 'string' ? track.created_at :
|
|
typeof track.publishedAt === 'string' ? track.publishedAt : new Date().toISOString(),
|
|
slug: typeof track.slug === 'string' && track.slug ? track.slug : generateSlug(track.title, genre)
|
|
};
|
|
|
|
// Validate slug
|
|
const genreSlugs = usedSlugs.get(genre) || new Set();
|
|
if (!sanitized.slug || genreSlugs.has(sanitized.slug)) {
|
|
console.warn(`Invalid or duplicate slug "${sanitized.slug}" for "${sanitized.title}". Regenerating.`);
|
|
sanitized.slug = generateSlug(sanitized.title, genre);
|
|
} else {
|
|
genreSlugs.add(sanitized.slug);
|
|
usedSlugs.set(genre, genreSlugs);
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
// Helper function to read the cache
|
|
function readCache(cacheFile) {
|
|
if (fs.existsSync(cacheFile)) {
|
|
try {
|
|
const data = fs.readFileSync(cacheFile, { encoding: 'utf8' });
|
|
return JSON.parse(data);
|
|
} catch (err) {
|
|
console.error(`Error reading cache from ${cacheFile}:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper function to save cache
|
|
function saveCache(cacheFile, data) {
|
|
try {
|
|
const validTracks = data.tracks.filter(track => {
|
|
try {
|
|
JSON.stringify(track);
|
|
return true;
|
|
} catch (err) {
|
|
console.error(`Invalid track data for "${track.title}":`, err);
|
|
return false;
|
|
}
|
|
});
|
|
const cacheData = { tracks: validTracks, timestamp: data.timestamp };
|
|
console.log(`Saving cache with ${validTracks.length} tracks to ${cacheFile}`);
|
|
console.log('Cached track titles:', validTracks.map(t => t.title));
|
|
const jsonString = JSON.stringify(cacheData, null, 2);
|
|
fs.writeFileSync(cacheFile, jsonString, { encoding: 'utf8' });
|
|
console.log(`Cache saved to ${cacheFile}`);
|
|
} catch (err) {
|
|
console.error(`Error saving cache to ${cacheFile}:`, err);
|
|
console.error('Problematic cache data:', data);
|
|
}
|
|
}
|
|
|
|
// Fetch playlist tracks from SoundCloud
|
|
async function fetchPlaylist(playlistUrl, genre) {
|
|
try {
|
|
const soundcloud = new Soundcloud();
|
|
const playlist = await soundcloud.playlists.getAlt(playlistUrl);
|
|
console.log(`Fetched playlist with ${playlist.tracks.length} tracks for genre "${genre}":`, playlist.tracks.map(t => t.title));
|
|
|
|
const tracks = [];
|
|
for (const track of playlist.tracks) {
|
|
try {
|
|
console.log(`Processing track: "${track.title || 'Unknown'}" in genre "${genre}"`);
|
|
const trackData = sanitizeTrackData(track, genre);
|
|
console.log(`Generated embed URL: "${trackData.embedUrl}"`);
|
|
tracks.push(trackData);
|
|
console.log(`Successfully processed track: "${track.title || 'Unknown'}"`);
|
|
} catch (err) {
|
|
console.error(`Error processing track "${track.title || 'Unknown'}" in genre "${genre}":`, err);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
console.log(`Total fetched tracks for genre "${genre}": ${tracks.length}`);
|
|
return tracks;
|
|
} catch (err) {
|
|
console.error(`Error fetching playlist ${playlistUrl} for genre "${genre}":`, err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Get tracks for a specific genre with pagination
|
|
async function getTracks(genre, fetch = false, page = 1) {
|
|
const playlist = playlists[genre];
|
|
const cache = readCache(playlist.cacheFile);
|
|
const oneWeekInMs = 7 * 24 * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
|
|
// Clear used slugs for this genre before processing
|
|
usedSlugs.set(genre, new Set());
|
|
|
|
if (fetch || !cache || (now - cache.timestamp) > oneWeekInMs) {
|
|
playlist.tracks = await fetchPlaylist(playlist.url, genre);
|
|
saveCache(playlist.cacheFile, { tracks: playlist.tracks, timestamp: now });
|
|
} else {
|
|
// Sanitize all tracks from cache to ensure slugs are assigned
|
|
playlist.tracks = cache.tracks.map(track => sanitizeTrackData({
|
|
...track,
|
|
slug: track.slug || generateSlug(track.title || 'Unknown', genre)
|
|
}, genre));
|
|
}
|
|
|
|
// Sort tracks (all tracks, not just paginated ones)
|
|
if (genre === 'cuts') {
|
|
playlist.tracks.sort((a, b) => {
|
|
const getEpNumber = (title) => {
|
|
const match = title.match(/EP\s*(\d+)/i);
|
|
return match ? parseInt(match[1], 10) : Infinity;
|
|
};
|
|
const epA = getEpNumber(a.title);
|
|
const epB = getEpNumber(b.title);
|
|
return epA - epB;
|
|
});
|
|
} else {
|
|
playlist.tracks.sort((a, b) => {
|
|
if (b.playCount !== a.playCount) return b.playCount - a.playCount;
|
|
return new Date(b.publishedAt) - new Date(a.publishedAt);
|
|
});
|
|
}
|
|
|
|
// Paginate tracks for display
|
|
const start = (page - 1) * TRACKS_PER_PAGE;
|
|
const end = start + TRACKS_PER_PAGE;
|
|
const paginatedTracks = playlist.tracks.slice(start, end);
|
|
const totalPages = Math.ceil(playlist.tracks.length / TRACKS_PER_PAGE);
|
|
|
|
return {
|
|
tracks: paginatedTracks,
|
|
page,
|
|
totalPages,
|
|
totalTracks: playlist.tracks.length,
|
|
allTracks: playlist.tracks // Include all tracks for slug access
|
|
};
|
|
}
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
app.set('view engine', 'ejs');
|
|
app.set('views', path.join(__dirname, 'views'));
|
|
|
|
app.get('/', async (req, res) => {
|
|
const genreTracks = {};
|
|
for (const genre in playlists) {
|
|
genreTracks[genre] = await getTracks(genre, false, 1);
|
|
}
|
|
res.render('index', { genreTracks });
|
|
});
|
|
|
|
app.get('/sitemap.xml', async (req, res) => {
|
|
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n`;
|
|
sitemap += `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`;
|
|
|
|
sitemap += `<url>\n <loc>https://raven-scott.rocks/</loc>\n <priority>1.0</priority>\n</url>\n`;
|
|
|
|
for (const genre in playlists) {
|
|
const { allTracks } = await getTracks(genre);
|
|
allTracks.forEach(track => {
|
|
sitemap += `<url>\n <loc>https://raven-scott.rocks/${genre}/track/${track.slug}</loc>\n <priority>0.8</priority>\n</url>\n`;
|
|
});
|
|
}
|
|
|
|
sitemap += `</urlset>`;
|
|
|
|
res.header('Content-Type', 'application/xml');
|
|
res.send(sitemap);
|
|
});
|
|
|
|
app.get('/:genre', async (req, res) => {
|
|
const { genre } = req.params;
|
|
if (playlists[genre]) {
|
|
res.redirect(`/#${genre}`);
|
|
} else {
|
|
res.status(404).send('Genre not found');
|
|
}
|
|
});
|
|
|
|
app.get('/:genre/track/:slug', async (req, res) => {
|
|
const { genre, slug } = req.params;
|
|
if (!playlists[genre]) {
|
|
console.error(`Genre not found: ${genre}`);
|
|
return res.status(404).send('Genre not found');
|
|
}
|
|
|
|
console.log(`Fetching track for genre: ${genre}, slug: ${slug}`);
|
|
const { allTracks } = await getTracks(genre);
|
|
const track = allTracks.find(t => t.slug === slug);
|
|
|
|
if (!track) {
|
|
console.error(`Track not found for slug "${slug}" in genre "${genre}". Available slugs:`, allTracks.map(t => t.slug));
|
|
return res.status(404).send('Track not found');
|
|
}
|
|
|
|
console.log(`Found track: "${track.title}" for slug "${slug}" in genre "${genre}"`);
|
|
res.render('track', { track, genre });
|
|
});
|
|
|
|
app.get('/json/:genre', async (req, res) => {
|
|
const { genre } = req.params;
|
|
if (!playlists[genre]) {
|
|
return res.status(404).json({ error: 'Genre not found' });
|
|
}
|
|
const { allTracks } = await getTracks(genre);
|
|
res.json(allTracks);
|
|
});
|
|
|
|
io.on('connection', (socket) => {
|
|
console.log('Client connected:', socket.id);
|
|
|
|
socket.on('request_page', async ({ genre, page }) => {
|
|
console.log(`Received request_page for genre: ${genre}, page: ${page}`);
|
|
if (!playlists[genre]) {
|
|
console.error(`Invalid genre: ${genre}`);
|
|
socket.emit('error', { message: 'Genre not found' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await getTracks(genre, false, page);
|
|
console.log(`Sending page_data for genre: ${genre}, page: ${page}, tracks: ${data.tracks.length}`);
|
|
socket.emit('page_data', { genre, ...data });
|
|
} catch (err) {
|
|
console.error(`Error fetching page ${page} for genre ${genre}:`, err);
|
|
socket.emit('error', { message: 'Error fetching tracks' });
|
|
}
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log('Client disconnected:', socket.id);
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`Server is running on port ${PORT}`);
|
|
}); |