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:
2025-06-22 02:54:41 -04:00
parent 2f55294c3d
commit b12b3882f3
4 changed files with 339 additions and 82 deletions

View File

@ -4,8 +4,8 @@ const path = require('path');
const slugify = require('slugify');
// Configuration
const PLAYLIST_URL = 'https://soundcloud.com/snxraven/sets/raven-scott-metal'; // Metal playlist URL
const CACHE_FILE = path.join(__dirname, 'cache_metal.json'); // Cache file for metal playlist
const PLAYLIST_URL = 'https://soundcloud.com/snxraven/sets/raven-scott-rap'; // Metal playlist URL
const CACHE_FILE = path.join(__dirname, 'cache_rap.json'); // Cache file for metal playlist
// Helper function to create a slug from track title
function generateSlug(title) {

View File

@ -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}`);
});

View File

@ -16,6 +16,7 @@
"rss-to-json": "^2.1.1",
"sitemap": "^8.0.0",
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
"soundcloud-scraper": "^5.0.3",
"soundcloud.ts": "^0.6.5",
"transliteration": "^2.3.5"

View File

@ -16,6 +16,12 @@
.card {
background-color: #222;
border: 1px solid #444;
border-radius: 8px;
overflow: hidden;
}
.card-body {
padding: 1.5rem;
}
.btn-primary {
@ -28,7 +34,10 @@
background-color: #ff3300;
}
/* Navbar Styling */
.pagination-btn {
margin: 0 10px;
}
.navbar {
background-color: #1e1e1e;
padding: 1rem 2rem;
@ -93,7 +102,6 @@
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23ff5500' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");
}
/* Custom slim dark mode scrollbar */
::-webkit-scrollbar {
width: 8px;
}
@ -117,10 +125,36 @@
scrollbar-color: #4a4a4a #1e1e1e;
}
/* Fix for section titles being hidden under navbar */
section {
padding-top: 90px; /* Adjust this value based on navbar height */
margin-top: -90px; /* Negative margin to offset the padding */
padding-top: 90px;
margin-top: -90px;
}
/* Page container styling */
.page-container {
display: none;
width: 100%; /* Ensure full width for grid */
}
.page-container.active {
display: block;
}
/* Iframe styling */
iframe {
width: 100%;
height: 166px;
max-width: 100%;
border: none;
border-radius: 4px;
}
/* Ensure row and columns work correctly */
.page-container .row {
display: flex;
flex-wrap: wrap;
margin-right: -15px;
margin-left: -15px;
}
</style>
</head>
@ -150,9 +184,11 @@
<h2 class="text-center mb-4">
<%= genre.charAt(0).toUpperCase() + genre.slice(1) %> Tracks
</h2>
<div id="<%= genre %>-tracks">
<div class="page-container active" data-page="1" data-total-pages="<%= genreTracks[genre].totalPages %>">
<div class="row">
<% genreTracks[genre].forEach(track => { %>
<div class="col-md-6 mb-4">
<% genreTracks[genre].tracks.forEach(track => { %>
<div class="col-md-6 mb-4" data-slug="<%= track.slug %>">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">
@ -160,15 +196,22 @@
</h5>
<p class="card-text"></p>
<iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay"
src="https://w.soundcloud.com/player/?url=<%= track.url %>&color=%23ff5500&auto_play=false&show_artwork=true"
data-src="https://w.soundcloud.com/player/?url=<%= track.url %>&color=%23ff5500&auto_play=false&show_artwork=true"
loading="lazy">
</iframe>
<a href="/<%= genre %>/track/<%= track.slug %>" class="btn btn-primary mt-3">More Details</a>
<a href="/<%= genre %>/track/<%= track.slug %>" class="btn btn-primary mt-3" target="_blank">More Details</a>
</div>
</div>
</div>
<% }) %>
</div>
</div>
</div>
<div class="text-center mb-4">
<button class="btn btn-primary pagination-btn" id="<%= genre %>-prev" data-page="<%= genreTracks[genre].page - 1 %>" <%= genreTracks[genre].page === 1 ? 'disabled' : '' %>>Previous</button>
<span>Page <%= genreTracks[genre].page %> of <%= genreTracks[genre].totalPages %></span>
<button class="btn btn-primary pagination-btn" id="<%= genre %>-next" data-page="<%= genreTracks[genre].page + 1 %>" <%= genreTracks[genre].page === genreTracks[genre].totalPages ? 'disabled' : '' %>>Next</button>
</div>
</section>
<% } %>
</div>
@ -176,6 +219,150 @@
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.4/dist/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script>
<script>
// Lazy load iframes
function lazyLoadIframes(container = document) {
const iframes = container.querySelectorAll('iframe[data-src]');
iframes.forEach(iframe => {
if (!iframe.src && iframe.getBoundingClientRect().top < window.innerHeight) {
iframe.src = iframe.getAttribute('data-src');
console.log(`Lazy loading iframe for track: ${iframe.closest('[data-slug]').dataset.slug}`);
}
});
}
window.addEventListener('load', () => lazyLoadIframes());
window.addEventListener('scroll', () => lazyLoadIframes());
// Page cache: { genre: { page: { container, totalPages } } }
const pageCache = {};
// Track current page per genre
const currentPages = {};
// WebSocket client
const socket = io();
socket.on('connect', () => {
console.log('Connected to WebSocket server');
});
socket.on('page_data', ({ genre, tracks, page, totalPages }) => {
console.log(`Received page_data for genre: ${genre}, page: ${page}, tracks: ${tracks.length}`);
const trackContainer = document.getElementById(`${genre}-tracks`);
// Initialize cache for genre
if (!pageCache[genre]) {
pageCache[genre] = {};
}
// Update current page
currentPages[genre] = page;
// Create new page if not cached
if (!pageCache[genre][page]) {
console.log(`Creating new page container for genre: ${genre}, page: ${page}`);
const pageContainer = document.createElement('div');
pageContainer.className = 'page-container';
pageContainer.dataset.page = page;
pageContainer.dataset.totalPages = totalPages;
// Create row for grid
const row = document.createElement('div');
row.className = 'row';
// Build track elements
tracks.forEach(track => {
const trackDiv = document.createElement('div');
trackDiv.className = 'col-md-6 mb-4';
trackDiv.dataset.slug = track.slug;
trackDiv.innerHTML = `
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">${track.title}</h5>
<p class="card-text"></p>
<iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay"
data-src="https://w.soundcloud.com/player/?url=${track.url}&color=%23ff5500&auto_play=false&show_artwork=true"
loading="lazy"></iframe>
<a href="/${genre}/track/${track.slug}" class="btn btn-primary mt-3">More Details</a>
</div>
</div>
`;
row.appendChild(trackDiv);
});
pageContainer.appendChild(row);
trackContainer.appendChild(pageContainer);
pageCache[genre][page] = { container: pageContainer, totalPages };
lazyLoadIframes(pageContainer);
}
// Switch to requested page
const allPages = trackContainer.querySelectorAll('.page-container');
allPages.forEach(p => p.classList.remove('active'));
pageCache[genre][page].container.classList.add('active');
console.log(`Switched to page ${page} for genre: ${genre}`);
// Update pagination controls
const prevButton = document.getElementById(`${genre}-prev`);
const nextButton = document.getElementById(`${genre}-next`);
prevButton.disabled = page === 1;
nextButton.disabled = page === totalPages;
prevButton.dataset.page = page - 1;
nextButton.dataset.page = page + 1;
const pageInfo = document.querySelector(`#${genre}-tracks + .text-center span`);
pageInfo.textContent = `Page ${page} of ${totalPages}`;
lazyLoadIframes(pageCache[genre][page].container);
});
socket.on('error', ({ message }) => {
console.error('WebSocket error:', message);
});
// Pagination button handlers
document.querySelectorAll('.pagination-btn').forEach(button => {
button.addEventListener('click', () => {
const genre = button.id.split('-')[0];
const page = parseInt(button.dataset.page, 10);
console.log(`Button clicked: ${button.id}, genre: ${genre}, page: ${page}`);
if (page > 0 && page !== currentPages[genre]) {
if (pageCache[genre] && pageCache[genre][page]) {
console.log(`Loading cached page ${page} for genre: ${genre}`);
const trackContainer = document.getElementById(`${genre}-tracks`);
const allPages = trackContainer.querySelectorAll('.page-container');
allPages.forEach(p => p.classList.remove('active'));
pageCache[genre][page].container.classList.add('active');
// Update pagination controls
const prevButton = document.getElementById(`${genre}-prev`);
const nextButton = document.getElementById(`${genre}-next`);
const totalPages = parseInt(pageCache[genre][page].totalPages);
prevButton.disabled = page === 1;
nextButton.disabled = page === totalPages;
prevButton.dataset.page = page - 1;
nextButton.dataset.page = page + 1;
const pageInfo = document.querySelector(`#${genre}-tracks + .text-center span`);
pageInfo.textContent = `Page ${page} of ${totalPages}`;
currentPages[genre] = page;
lazyLoadIframes(pageCache[genre][page].container);
} else {
socket.emit('request_page', { genre, page });
console.log(`Emitted request_page for genre: ${genre}, page: ${page}`);
}
} else if (page <= 0) {
console.error(`Invalid page number: ${page}`);
} else {
console.log(`Already on page ${page} for genre: ${genre}`);
}
});
});
</script>
</body>
</html>