folx-live/src/server.js

173 lines
6.7 KiB
JavaScript

const path = require('path');
const crypto = require('crypto');
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// Our independent Bunny CDN pull zone — reads from same storage as Rok's,
// but with our own SecurityKey and our own tokenization.
const BUNNY_HOST = 'https://folxlive.b-cdn.net';
const BUNNY_KEY = process.env.BUNNY_SECURITY_KEY || '492fb7b3-8a1d-4a78-8e9e-fae8c07af195';
const TOKEN_TTL = parseInt(process.env.TOKEN_TTL || '14400', 10); // 4 hours default
function signBunnyUrl(urlPath, expiresInSeconds = TOKEN_TTL) {
const expires = Math.floor(Date.now() / 1000) + expiresInSeconds;
const hash = crypto.createHash('sha256').update(BUNNY_KEY + urlPath + expires).digest();
const token = hash.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return { token, expires, url: `${BUNNY_HOST}${urlPath}?token=${token}&expires=${expires}` };
}
// Map stream number → CDN path. CDN serves rewritten/tokenized version of master.
// /live/stream1_master.m3u8 has variant URLs already pointing to folxplay.b-cdn.net (with tokens).
// We fetch this, then rewrite folxplay → folxlive with our own tokens.
function getMasterCdnPath(n) {
return `/live/stream${n}_master.m3u8`;
}
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '..', 'views'));
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
etag: true,
}));
app.get('/', (_req, res) => {
// Use our proxy route for the player — it always returns fresh tokens
res.render('index', { hlsUrl: '/stream/1/master.m3u8' });
});
app.get('/test-embed', (_req, res) => {
res.render('test-embed');
});
/**
* Master proxy: fetches raw master from our Bunny zone, rewrites variant URLs to point
* to our zone with our tokens (4-hour TTL), and serves the result.
*
* GET /stream/:n/master.m3u8
* Returns rewritten master.m3u8 with all variant URLs pointing to folxlive.b-cdn.net
* with fresh tokens (4 hours).
*/
app.get('/stream/:n/master.m3u8', async (req, res) => {
const n = req.params.n;
if (!/^[1-6]$/.test(n)) return res.status(400).send('Invalid stream number');
const masterCdnPath = getMasterCdnPath(n);
const { url: signedMasterUrl } = signBunnyUrl(masterCdnPath, 600); // short TTL on internal fetch
try {
const response = await fetch(signedMasterUrl, {
headers: { 'User-Agent': 'folx-live-proxy/1.0' },
});
if (!response.ok) {
console.error(`[stream/${n}] origin ${response.status}: ${signedMasterUrl}`);
return res.status(502).send(`Origin returned ${response.status}`);
}
let text = await response.text();
// Origin master.m3u8 may contain variant URLs in any of these messed-up forms:
// https://folxplay.b-cdn.net/live/streamN_1080p.m3u8?token=ABC&expires=N
// https://folxplay.b-cdn.net/bcdn_token=XYZ&expires=N&token_path=%2Flive%2F/live/streamN_1080p.m3u8
// https://folxlive.b-cdn.net/live/streamN_1080p.m3u8 (already plain)
// Strategy: ignore everything in the upstream URL except the trailing filename
// (streamN_*.m3u8) and rebuild a clean signed folxlive URL.
text = text.replace(
/https:\/\/[^\s]*?\/(stream\d+_\d+p\.m3u8)(?:\?[^\s]*)?/g,
(_match, filename) => {
const variantPath = `/live/${filename}`;
const { url } = signBunnyUrl(variantPath, TOKEN_TTL);
return url;
}
);
res.set({
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Access-Control-Allow-Origin': '*',
});
res.send(text);
} catch (err) {
console.error(`[stream/${n}] error:`, err.message);
res.status(502).send('Upstream error');
}
});
/**
* Variant proxy: fetches a variant manifest (e.g. stream_1080p.m3u8) and rewrites .ts URLs
* to absolute folxlive paths (no token needed for .ts thanks to edge rule).
*
* Actually — variant manifests use relative paths so we don't need to rewrite anything.
* Player will resolve them against folxlive.b-cdn.net which is correct.
*
* So we just redirect to the signed origin URL with our token.
*/
app.get('/stream/:n/variant/:filename', (req, res) => {
const n = req.params.n;
const filename = req.params.filename;
if (!/^[1-6]$/.test(n)) return res.status(400).send('Invalid stream number');
if (!/^stream(\d+)?_\d+p\.m3u8$/.test(filename)) return res.status(400).send('Invalid variant');
const variantPath = `/live/${filename}`;
const { url } = signBunnyUrl(variantPath, TOKEN_TTL);
res.redirect(302, url);
});
// Now-Playing proxy: reads master.biba.live AirPlay monitor, maps Folx TV current item.
// song -> title (artist - title)
// spot+GENIUS-> "GENIUS"
// spot -> "Werbung"
// jingle/animation -> skipped (label null; client keeps last song)
const NOWPLAYING_URL = process.env.NOWPLAYING_URL || 'https://master.biba.live/api/live';
const NOWPLAYING_STATION = process.env.NOWPLAYING_STATION || 'folx_tv';
function mapNowPlaying(np) {
if (!np) return { kind: 'none', label: null };
const type = (np.play_type || '').toLowerCase();
const camp = (np.campaign || '').toUpperCase();
if (type === 'song') {
const artist = (np.artist || '').trim();
const title = (np.display_title || np.title || '').trim();
const label = artist ? `${artist}${title}` : title;
return { kind: 'song', label, artist, title, started_at: np.started_at };
}
if (type === 'spot') {
if (camp === 'GENIUS') return { kind: 'genius', label: 'GENIUS', started_at: np.started_at };
return { kind: 'werbung', label: 'Werbung', started_at: np.started_at };
}
// jingle, animation (PASICA), contribution -> skip
return { kind: 'skip', label: null };
}
app.get('/api/now-playing', async (_req, res) => {
try {
const r = await fetch(NOWPLAYING_URL, { headers: { 'User-Agent': 'folx-live/1.0' } });
if (!r.ok) return res.status(502).json({ ok: false, error: `origin ${r.status}` });
const arr = await r.json();
const row = Array.isArray(arr) ? arr.find(x => x.station_slug === NOWPLAYING_STATION) : null;
const mapped = mapNowPlaying(row && row.now_playing);
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.json({ ok: true, station: NOWPLAYING_STATION, ...mapped });
} catch (err) {
res.status(502).json({ ok: false, error: err.message });
}
});
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
host: BUNNY_HOST,
tokenTtlSeconds: TOKEN_TTL,
ts: Date.now(),
});
});
app.listen(PORT, () => {
console.log(`[folx-live] listening on :${PORT}`);
console.log(`[folx-live] CDN host: ${BUNNY_HOST}`);
console.log(`[folx-live] Token TTL: ${TOKEN_TTL}s (${TOKEN_TTL / 3600}h)`);
});