Add pagination and WebSocket support for track listings
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.
This commit is contained in:
185
music_site.js
185
music_site.js
@ -4,9 +4,13 @@ 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;
|
||||
|
||||
// Define genre playlists
|
||||
const server = http.createServer(app);
|
||||
const io = socketIo(server);
|
||||
|
||||
const playlists = {
|
||||
metal: {
|
||||
url: 'https://soundcloud.com/snxraven/sets/raven-scott-metal',
|
||||
@ -40,56 +44,89 @@ const playlists = {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to create a slug from track title
|
||||
function generateSlug(title) {
|
||||
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 {
|
||||
const slug = slugify(title, {
|
||||
let baseSlug = slugify(title || 'unknown', {
|
||||
lower: true,
|
||||
strict: true,
|
||||
remove: /[*+~.()'"!:@]/g
|
||||
});
|
||||
if (!slug || slug.trim() === '') {
|
||||
|
||||
if (!baseSlug || baseSlug.trim() === '') {
|
||||
console.warn(`Empty slug for title: "${title}". Using fallback.`);
|
||||
return 'track-' + Date.now();
|
||||
baseSlug = 'track-' + Date.now();
|
||||
}
|
||||
console.log(`Generated slug for "${title}": "${slug}"`);
|
||||
|
||||
// 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}"`, err);
|
||||
return 'track-' + Date.now();
|
||||
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) {
|
||||
// Log raw track data to debug URL fields
|
||||
console.log(`Raw track data for "${track.title || 'Unknown'}":`, JSON.stringify(track, null, 2));
|
||||
function sanitizeTrackData(track, genre) {
|
||||
console.log(`Sanitizing track data for "${track.title || 'Unknown'}" in genre "${genre}"`);
|
||||
|
||||
// Use permalink_url or url (from cache), with fallback
|
||||
const permalinkUrl = typeof track.permalink_url === 'string' ? track.permalink_url :
|
||||
typeof track.url === 'string' ? track.url :
|
||||
typeof track.permalink === 'string' ? track.permalink : '';
|
||||
|
||||
// Construct embed URL for SoundCloud player
|
||||
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'}"`);
|
||||
console.warn(`No embed URL generated for track "${track.title || 'Unknown'}" in genre "${genre}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
const sanitized = {
|
||||
title: typeof track.title === 'string' ? track.title : 'Unknown Title',
|
||||
description: typeof track.description === 'string' ? track.description : 'No description available',
|
||||
url: permalinkUrl, // Permalink URL for links
|
||||
embedUrl: embedUrl, // For SoundCloud player iframe or widget
|
||||
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 : generateSlug(track.title || 'Unknown')
|
||||
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
|
||||
@ -109,7 +146,6 @@ function readCache(cacheFile) {
|
||||
// Helper function to save cache
|
||||
function saveCache(cacheFile, data) {
|
||||
try {
|
||||
// Filter out invalid tracks
|
||||
const validTracks = data.tracks.filter(track => {
|
||||
try {
|
||||
JSON.stringify(track);
|
||||
@ -132,103 +168,110 @@ function saveCache(cacheFile, data) {
|
||||
}
|
||||
|
||||
// Fetch playlist tracks from SoundCloud
|
||||
async function fetchPlaylist(playlistUrl) {
|
||||
async function fetchPlaylist(playlistUrl, genre) {
|
||||
try {
|
||||
const soundcloud = new Soundcloud(); // No client ID or OAuth token
|
||||
const soundcloud = new Soundcloud();
|
||||
const playlist = await soundcloud.playlists.getAlt(playlistUrl);
|
||||
console.log(`Fetched playlist with ${playlist.tracks.length} tracks:`, playlist.tracks.map(t => t.title));
|
||||
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'}"`);
|
||||
const trackData = sanitizeTrackData(track);
|
||||
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'}":`, err);
|
||||
console.error(`Error processing track "${track.title || 'Unknown'}" in genre "${genre}":`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total fetched tracks: ${tracks.length}`);
|
||||
console.log(`Total fetched tracks for genre "${genre}": ${tracks.length}`);
|
||||
return tracks;
|
||||
} catch (err) {
|
||||
console.error(`Error fetching playlist ${playlistUrl}:`, err);
|
||||
console.error(`Error fetching playlist ${playlistUrl} for genre "${genre}":`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get tracks for a specific genre
|
||||
async function getTracks(genre, fetch = false) {
|
||||
// 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);
|
||||
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)
|
||||
}));
|
||||
slug: track.slug || generateSlug(track.title || 'Unknown', genre)
|
||||
}, genre));
|
||||
}
|
||||
|
||||
// Sort tracks
|
||||
// Sort tracks (all tracks, not just paginated ones)
|
||||
if (genre === 'cuts') {
|
||||
// Sort "cuts" playlist by EP number extracted from title
|
||||
playlist.tracks.sort((a, b) => {
|
||||
// Extract EP number from title (e.g., "Lets Cut It EP 1" -> 1)
|
||||
const getEpNumber = (title) => {
|
||||
const match = title.match(/EP\s*(\d+)/i);
|
||||
return match ? parseInt(match[1], 10) : Infinity; // Fallback for non-matching titles
|
||||
return match ? parseInt(match[1], 10) : Infinity;
|
||||
};
|
||||
const epA = getEpNumber(a.title);
|
||||
const epB = getEpNumber(b.title);
|
||||
return epA - epB; // Ascending order
|
||||
return epA - epB;
|
||||
});
|
||||
} else {
|
||||
// Existing sorting for other genres (by playCount, then publishedAt)
|
||||
playlist.tracks.sort((a, b) => {
|
||||
if (b.playCount !== a.playCount) return b.playCount - a.playCount;
|
||||
return new Date(b.publishedAt) - new Date(a.publishedAt);
|
||||
});
|
||||
}
|
||||
|
||||
return playlist.tracks;
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Set EJS as templating engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Home page route
|
||||
app.get('/', async (req, res) => {
|
||||
const genreTracks = {};
|
||||
for (const genre in playlists) {
|
||||
genreTracks[genre] = await getTracks(genre);
|
||||
genreTracks[genre] = await getTracks(genre, false, 1);
|
||||
}
|
||||
res.render('index', { genreTracks });
|
||||
});
|
||||
|
||||
// Sitemap endpoint
|
||||
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`;
|
||||
|
||||
// Home page
|
||||
sitemap += `<url>\n <loc>https://raven-scott.rocks/</loc>\n <priority>1.0</priority>\n</url>\n`;
|
||||
|
||||
// Track pages for each genre
|
||||
for (const genre in playlists) {
|
||||
const tracks = await getTracks(genre);
|
||||
tracks.forEach(track => {
|
||||
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`;
|
||||
});
|
||||
}
|
||||
@ -239,7 +282,6 @@ app.get('/sitemap.xml', async (req, res) => {
|
||||
res.send(sitemap);
|
||||
});
|
||||
|
||||
// Redirect /genre to /#genre
|
||||
app.get('/:genre', async (req, res) => {
|
||||
const { genre } = req.params;
|
||||
if (playlists[genre]) {
|
||||
@ -249,34 +291,61 @@ app.get('/:genre', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Individual track page route
|
||||
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');
|
||||
}
|
||||
|
||||
const tracks = await getTracks(genre);
|
||||
const track = tracks.find(t => t.slug === slug);
|
||||
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 });
|
||||
});
|
||||
|
||||
// JSON endpoint for specific 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 tracks = await getTracks(genre);
|
||||
res.json(tracks);
|
||||
const { allTracks } = await getTracks(genre);
|
||||
res.json(allTracks);
|
||||
});
|
||||
|
||||
// Listen on the specified port
|
||||
app.listen(PORT, () => {
|
||||
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}`);
|
||||
});
|
Reference in New Issue
Block a user