streamflow/backend/routes/metadata.js
2025-12-17 00:42:43 +00:00

218 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;