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++;
|
|
}
|
|
|
|
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);
|
|
let fallback = 'track-' + Date.now();
|
|
const genreSlugs = usedSlugs.get(genre) || new Set();
|
|
let counter = 1;
|
|
while (genreSlugs.has(fallback)) {
|
|
fallback = `track-${Date.now()}-${counter}`;
|
|
counter++;
|
|
}
|
|
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);
|
|
}
|
|
|
|
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}`);
|
|
}); |