const express = require('express'); const router = express.Router(); const axios = require('axios'); const { authenticate } = require('../middleware/auth'); const { readLimiter } = require('../middleware/rateLimiter'); const logger = require('../utils/logger'); const { getRadioStationMetadata } = require('../utils/radioMetadata'); /** * Fetch metadata from radio stream (ICY/Shoutcast metadata) * This attempts to extract "now playing" information from radio streams */ router.get('/radio/:channelId', readLimiter, authenticate, async (req, res) => { const { channelId } = req.params; const { db } = require('../database/db'); try { // Validate channel ID const id = parseInt(channelId, 10); if (isNaN(id) || id < 1) { return res.status(400).json({ error: 'Invalid channel ID' }); } // Get channel URL from database db.get( 'SELECT url, name FROM channels WHERE id = ? AND is_radio = 1', [id], async (err, channel) => { if (err || !channel) { return res.status(404).json({ error: 'Channel not found' }); } try { // Request stream with ICY metadata headers const response = await axios.get(channel.url, { headers: { 'Icy-MetaData': '1', 'User-Agent': 'StreamFlow/1.0' }, responseType: 'stream', timeout: 5000, maxRedirects: 5 }); let metadata = { channelId: parseInt(channelId), channelName: channel.name, title: null, artist: null, song: null, streamTitle: null, bitrate: null, genre: null, url: null }; // Extract ICY headers const icyName = response.headers['icy-name']; const icyGenre = response.headers['icy-genre']; const icyBr = response.headers['icy-br']; const icyUrl = response.headers['icy-url']; const icyDescription = response.headers['icy-description']; if (icyName) { // Filter out stream quality info (e.g., "europafm_aacp_48k") const cleanName = icyName.replace(/_aacp?_\d+k?/gi, '').replace(/_mp3_\d+k?/gi, '').replace(/_\d+k/gi, ''); if (cleanName && !cleanName.match(/^\w+_\w+$/)) { metadata.streamTitle = cleanName; } else { metadata.streamTitle = icyName; } } if (icyGenre) metadata.genre = icyGenre; if (icyBr) metadata.bitrate = icyBr + ' kbps'; if (icyUrl) metadata.url = icyUrl; // Try to get current track from ICY-MetaInt const metaInt = parseInt(response.headers['icy-metaint']); if (metaInt && metaInt > 0) { // Read metadata from stream const chunks = []; let bytesRead = 0; let metadataFound = false; response.data.on('data', (chunk) => { if (metadataFound) return; chunks.push(chunk); bytesRead += chunk.length; // Once we have enough data, parse metadata if (bytesRead >= metaInt + 255) { const buffer = Buffer.concat(chunks); const metadataLength = buffer[metaInt] * 16; if (metadataLength > 0) { const metadataBuffer = buffer.slice(metaInt + 1, metaInt + 1 + metadataLength); const metadataString = metadataBuffer.toString('utf8').replace(/\0/g, ''); logger.info(`[Metadata] Raw metadata string for ${channel.name}: ${metadataString}`); // Parse StreamTitle='Artist - Song' const titleMatch = metadataString.match(/StreamTitle='([^']*)'/); if (titleMatch && titleMatch[1]) { const rawTitle = titleMatch[1]; logger.info(`[Metadata] Raw title: ${rawTitle}`); // Skip if it's just stream quality info if (!rawTitle.match(/^\w+_aacp?_\d+k?$/i) && !rawTitle.match(/^\w+_mp3_\d+k?$/i)) { metadata.song = rawTitle; // Try to split into artist and title (various separators) let parts = rawTitle.split(' - '); if (parts.length === 1) { parts = rawTitle.split(' – '); // em dash } if (parts.length === 1) { parts = rawTitle.split(' | '); } if (parts.length >= 2) { metadata.artist = parts[0].trim(); metadata.title = parts.slice(1).join(' - ').trim(); } else if (parts.length === 1 && rawTitle.length > 0) { // If no separator, use the whole thing as title metadata.title = rawTitle.trim(); } } } } metadataFound = true; response.data.destroy(); } }); // Wait a bit for metadata await new Promise(resolve => setTimeout(resolve, 2000)); } // Close the stream if (response.data && !response.data.destroyed) { response.data.destroy(); } // If no metadata found from ICY, try external APIs if (!metadata.title && !metadata.artist && !metadata.song) { logger.info(`[Metadata] No ICY metadata found, trying external sources for ${channel.name}`); try { const externalMetadata = await getRadioStationMetadata(channel.name, channel.url); if (externalMetadata) { metadata.title = externalMetadata.title; metadata.artist = externalMetadata.artist; if (externalMetadata.title && externalMetadata.artist) { metadata.song = `${externalMetadata.artist} - ${externalMetadata.title}`; } else if (externalMetadata.title) { metadata.song = externalMetadata.title; } logger.info(`[Metadata] External metadata found: ${metadata.song}`); } } catch (externalError) { logger.error(`[Metadata] External metadata fetch failed: ${externalError.message}`); } } res.json(metadata); } catch (streamError) { logger.error('Error fetching stream metadata:', streamError.message); // Try external metadata as fallback try { const externalMetadata = await getRadioStationMetadata(channel.name, channel.url); if (externalMetadata) { return res.json({ channelId: parseInt(channelId), channelName: channel.name, title: externalMetadata.title, artist: externalMetadata.artist, song: externalMetadata.artist && externalMetadata.title ? `${externalMetadata.artist} - ${externalMetadata.title}` : externalMetadata.title, streamTitle: channel.name, bitrate: null, genre: null, url: null, source: externalMetadata.source }); } } catch (externalError) { logger.error(`[Metadata] External metadata fallback failed: ${externalError.message}`); } // Return basic info if everything fails res.json({ channelId: parseInt(channelId), channelName: channel.name, title: null, artist: null, song: null, streamTitle: channel.name, bitrate: null, genre: null, url: null, error: 'Unable to fetch stream metadata' }); } } ); } catch (error) { logger.error('Metadata fetch error:', error); res.status(500).json({ error: 'Failed to fetch metadata' }); } }); module.exports = router;