219 lines
8.2 KiB
JavaScript
219 lines
8.2 KiB
JavaScript
|
|
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;
|