Initial commit: StreamFlow IPTV platform
This commit is contained in:
commit
73a8ae9ffd
1240 changed files with 278451 additions and 0 deletions
218
backend/routes/metadata.js
Normal file
218
backend/routes/metadata.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue