Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions admin/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3115,7 +3115,7 @@ async function setupVideoPlayer() {
source: ch.source,
hdhomerun: Boolean(ch.hdhomerun),
streamUrl,
transcodeUrl: `/transcode/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`,
transcodeUrl: getPreviewChannelRoute('/transcode', ch),
probeUrl: null,
probeResult: null,
playerMode: null,
Expand Down Expand Up @@ -3163,7 +3163,7 @@ async function setupVideoPlayer() {
// The probe runs in parallel with HLS.js so it does not add latency when the
// codecs are already browser-compatible.
const probeChannel = state.previewWatchingChannel;
const probeBase = `/api/stream-probe/${encodeURIComponent(probeChannel?.source || '')}/${encodeURIComponent(probeChannel?.name || '')}`;
const probeBase = getPreviewChannelRoute('/api/stream-probe', probeChannel);
// Do NOT append ?streamMode=hls for HDHomeRun — see comment above.
const probeUrl = probeBase;
dbg.probeUrl = probeUrl;
Expand Down Expand Up @@ -4271,7 +4271,7 @@ const previewTableRows = computed(() =>
const previewStreamUrl = computed(() => {
const ch = state.previewWatchingChannel;
if (!ch) return '';
const base = `/stream/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`;
const base = getPreviewChannelRoute('/stream', ch);
// HDHomeRun OTA broadcasts use MPEG-2 video and AC-3 audio — codecs not supported
// by browser MSE. Append ?streamMode=hls so the server requests the HLS variant
// from the HDHomeRun device. Note: HLS mode wraps the MPEG-TS in an HLS playlist
Expand All @@ -4285,9 +4285,24 @@ const previewTranscodeUrl = computed(() => {
if (!ch) return '';
// Server-side transcoding endpoint — converts MPEG-2/AC-3 MPEG-TS to H.264/AAC
// using ffmpeg so the browser can play the stream natively via mpegts.js.
return `/transcode/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`;
return getPreviewChannelRoute('/transcode', ch);
});

/**
* Build a playback route for a persisted source channel. Source and display
* names are editable, while sourceChannelId is the database identity exposed
* by output profiles, so prefer it whenever it is available. The name route
* remains the compatibility fallback for preview data created from a temporary
* (not-yet-persisted) configuration.
*/
function getPreviewChannelRoute(prefix, channel) {
const sourceChannelId = String(channel?.sourceChannelId || '').trim();
if (sourceChannelId) {
return `${prefix}/channel/${encodeURIComponent(sourceChannelId)}`;
}
return `${prefix}/${encodeURIComponent(channel?.source || '')}/${encodeURIComponent(channel?.name || '')}`;
}

// Show the transcoding button only when the unsupported-codec error is active
// (i.e. the stream is live but uses codecs the browser cannot decode).
const showTranscodeButton = computed(() => state.playerError === ERR_UNSUPPORTED_CODEC);
Expand Down
13 changes: 12 additions & 1 deletion libs/source-sync-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function replaceDiscoveredSourceChannels(sourceId, channels) {

const seenAt = new Date().toISOString();
const retainedIds = new Set();
const sourceChannelIds = new Map();
for (const channel of nextChannels) {
const existing =
(channel.external_key ? existingByExternalKey.get(channel.external_key) : null) ||
Expand All @@ -123,6 +124,14 @@ export function replaceDiscoveredSourceChannels(sourceId, channels) {
);

const channelId = existing?.id || crypto.randomUUID();
// Expose the persistent identity to the in-memory snapshot as well as
// the normalized source_channels row. Playback clients must not use the
// editable provider/channel names as an identifier.
channel.sourceChannelId = channelId;
sourceChannelIds.set(
channel.external_key || getChannelIdentity(channel),
channelId
);
retainedIds.add(channelId);

if (existing) {
Expand Down Expand Up @@ -160,9 +169,11 @@ export function replaceDiscoveredSourceChannels(sourceId, channels) {
deleteChannel.run(row.id);
}
}

return sourceChannelIds;
});

saveChannels(channels);
return saveChannels(channels);
}

export default {
Expand Down
9 changes: 8 additions & 1 deletion scripts/parseM3U.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,14 @@ async function processSource(source, map) {
}

if (source.id) {
replaceDiscoveredSourceChannels(source.id, discoveredChannels);
const sourceChannelIds = replaceDiscoveredSourceChannels(source.id, discoveredChannels);
for (const channel of channels) {
const key = channel.external_key || '';
const sourceChannelId = sourceChannelIds?.get(key);
if (sourceChannelId) {
channel.sourceChannelId = sourceChannelId;
}
}
}
if (syncRunId) {
finishSourceSyncRun(syncRunId, { status: 'success' });
Expand Down
4 changes: 4 additions & 0 deletions server/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,10 @@ function createMcpServer() {
}

const channels = result.slice(0, limit).map(channel => ({
// Stable identity for agent-created playback URLs. Channel names and
// provider display names can change after a refresh, so consumers
// should prefer /stream/channel/:sourceChannelId when this is present.
sourceChannelId: channel.sourceChannelId || null,
name: channel.name,
source: channel.source,
tvg_id: channel.tvg_id || null,
Expand Down
45 changes: 37 additions & 8 deletions server/transcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ const transcodeLimiter = RateLimit({

// A request rate is not a bound on the amount of work currently running. Keep
// this intentionally small: every worker encodes video and can consume a core.
const MAX_ACTIVE_WORKERS = Number.parseInt(process.env.TRANSCODE_MAX_WORKERS || '3', 10);
const STARTUP_TIMEOUT_MS = Number.parseInt(process.env.TRANSCODE_STARTUP_TIMEOUT_MS || '15000', 10);
const IDLE_TIMEOUT_MS = Number.parseInt(process.env.TRANSCODE_IDLE_TIMEOUT_MS || '30000', 10);
function positiveInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}

const MAX_ACTIVE_WORKERS = positiveInteger(process.env.TRANSCODE_MAX_WORKERS, 3);
const STARTUP_TIMEOUT_MS = positiveInteger(process.env.TRANSCODE_STARTUP_TIMEOUT_MS, 15000);
const IDLE_TIMEOUT_MS = positiveInteger(process.env.TRANSCODE_IDLE_TIMEOUT_MS, 30000);
const KILL_GRACE_MS = 5000;
const MAX_STDERR_BYTES = 16 * 1024;
const activeWorkers = new Set();

Expand Down Expand Up @@ -101,10 +107,23 @@ export function setupTranscodeRoutes(app) {
'pipe:1',
];

const ffmpegProcess = spawn(ffmpegCommand(), ffmpegArgs, {
shell: false,
windowsHide: true,
});
let ffmpegProcess;
try {
ffmpegProcess = spawn(ffmpegCommand(), ffmpegArgs, {
shell: false,
windowsHide: true,
});
} catch (err) {
// child_process.spawn normally reports failures through its error event,
// but can throw synchronously for OS policy failures and invalid commands.
console.warn('[transcode] spawn error %s/%s: %s', source, name, err.message);
if (err.code === 'ENOENT') {
return res.status(503).json({
error: 'ffmpeg is not installed on this server. Install ffmpeg to enable server-side transcoding.',
});
}
return res.status(502).json({ error: 'Transcoding failed' });
}
activeWorkers.add(ffmpegProcess);

res.setHeader('Content-Type', 'video/MP2T');
Expand All @@ -118,18 +137,27 @@ export function setupTranscodeRoutes(app) {
let stderrText = '';
let cleanedUp = false;
let idleTimer;
let forceKillTimer;

const stopWorker = signal => {
if (ffmpegProcess.exitCode === null && !ffmpegProcess.killed) ffmpegProcess.kill(signal);
if (ffmpegProcess.exitCode !== null || ffmpegProcess.killed) return;
ffmpegProcess.kill(signal);
// SIGTERM is advisory on Windows. Escalate so a stalled upstream cannot
// retain an expensive encoder worker indefinitely.
forceKillTimer = setTimeout(() => {
if (ffmpegProcess.exitCode === null && !ffmpegProcess.killed) ffmpegProcess.kill('SIGKILL');
}, KILL_GRACE_MS);
Comment on lines +145 to +149
};
const clearTimers = () => {
clearTimeout(startupTimer);
clearTimeout(idleTimer);
clearTimeout(forceKillTimer);
};
const resetIdleTimer = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
console.warn('[transcode] ffmpeg idle timeout for %s/%s', source, name);
if (!res.writableEnded) res.end();
stopWorker('SIGTERM');
}, IDLE_TIMEOUT_MS);
};
Expand All @@ -143,6 +171,7 @@ export function setupTranscodeRoutes(app) {
const startupTimer = setTimeout(() => {
if (!responseStarted) {
console.warn('[transcode] ffmpeg startup timeout for %s/%s', source, name);
if (!res.headersSent) res.status(504).json({ error: 'Transcoding startup timed out' });
stopWorker('SIGTERM');
}
}, STARTUP_TIMEOUT_MS);
Expand Down
56 changes: 54 additions & 2 deletions test/integration/improvements.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import os from 'os';
import path from 'path';
import sinon from 'sinon';
import { closeDatabase } from '../../libs/database.js';
import getBaseUrl from '../../libs/getBaseUrl.js';

// ──────────────────────────────────────────────────────────────────────────────
// Helpers
Expand Down Expand Up @@ -560,7 +561,58 @@ describe('Rate limiting', () => {
});

// ──────────────────────────────────────────────────────────────────────────────
// 6. GET /channels?mapped_only=true
// 6. Reverse proxy trust
// ──────────────────────────────────────────────────────────────────────────────
describe('Reverse proxy trust', () => {
async function requestIdentity(trustedProxies) {
const app = express();
app.set('trust proxy', trustedProxies);
app.get('/identity', (req, res) => {
res.json({
ip: req.ip,
protocol: req.protocol,
host: req.host,
baseUrl: getBaseUrl(req),
});
});

const { server, baseUrl } = await startServer(app);
try {
return await axios.get(`${baseUrl}/identity`, {
headers: {
'X-Forwarded-For': '203.0.113.50',
'X-Forwarded-Host': 'forged.example.test',
'X-Forwarded-Proto': 'https',
},
});
} finally {
await stopServer(server);
}
}

it('ignores forged forwarded identity, host, and protocol headers by default', async () => {
const response = await requestIdentity(false);

expect(response.data.ip).to.equal('127.0.0.1');
expect(response.data.protocol).to.equal('http');
expect(response.data.host).to.match(/^127\.0\.0\.1:\d+$/);
expect(response.data.baseUrl).to.match(/^http:\/\/localhost:\d+$/);
});

it('honors forwarded headers when the direct peer is an explicitly trusted proxy', async () => {
const response = await requestIdentity(['127.0.0.1']);

expect(response.data).to.deep.equal({
ip: '203.0.113.50',
protocol: 'https',
host: 'forged.example.test',
baseUrl: 'https://forged.example.test',
});
});
});

// ──────────────────────────────────────────────────────────────────────────────
// 7. GET /channels?mapped_only=true
// ──────────────────────────────────────────────────────────────────────────────
describe('GET /channels?mapped_only=true', () => {
let tmpDir;
Expand Down Expand Up @@ -657,7 +709,7 @@ describe('GET /channels?mapped_only=true', () => {
});

// ──────────────────────────────────────────────────────────────────────────────
// 7. GET /channels?mapped_only=true — HDHomeRun channel filtering
// 8. GET /channels?mapped_only=true — HDHomeRun channel filtering
// ──────────────────────────────────────────────────────────────────────────────
describe('GET /channels?mapped_only=true with HDHomeRun channels', () => {
let tmpDir;
Expand Down
10 changes: 9 additions & 1 deletion test/integration/mcp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ describe('MCP Route Integration', () => {

// Write test channels to the isolated temp data dir
const testChannels = [
{ name: 'CNN', tvg_id: 'cnn.us', source: 'TestProvider', group: 'News' },
{
name: 'CNN',
tvg_id: 'cnn.us',
source: 'TestProvider',
sourceChannelId: 'source-channel-cnn',
group: 'News',
},
{ name: 'ESPN', tvg_id: 'espn.us', source: 'TestProvider', group: 'Sports' },
{
name: 'Fox News',
Expand Down Expand Up @@ -231,13 +237,15 @@ describe('MCP Route Integration', () => {
const channels = payload.data;
expect(channels).to.have.lengthOf(3);
expect(channels[0]).to.have.all.keys(
'sourceChannelId',
'name',
'source',
'tvg_id',
'guideNumber',
'group',
'logo'
);
expect(channels[0].sourceChannelId).to.equal('source-channel-cnn');
});

it('list_channels filters by source', async () => {
Expand Down
11 changes: 11 additions & 0 deletions test/integration/output-profile-routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,17 @@ describe('output profile routes', () => {
`${publicBaseUrl}/stream/IPTV%20One/Living%20Room%20One`
);

// The stable source-channel route must remain playable after the canonical
// display name changes. This is the URL emitted to clients, so exercise
// the real proxy rather than only asserting the playlist text.
nock('http://streams.example')
.get('/one')
.reply(200, 'stable source-channel stream', { 'content-type': 'video/mp2t' });
const stableStreamResponse = await axios.get(stableStreamUrl, {
responseType: 'arraybuffer',
});
expect(Buffer.from(stableStreamResponse.data).toString()).to.equal('stable source-channel stream');

const xmltvResponse = await axios.get(`${baseUrl}/xmltv.xml`);
expect(xmltvResponse.data).to.include('<channel id="output.1">');
expect(xmltvResponse.data).to.include('Living Room One');
Expand Down
16 changes: 16 additions & 0 deletions test/integration/parseM3U-sqlite.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ describe('parseAll SQLite persistence', () => {
},
]);

// The snapshot feeds playback consumers (including MCP), so it must carry
// the durable source_channels id rather than requiring callers to rebuild
// a route from editable source/name strings.
const snapshot = JSON.parse(
databaseModule.get('SELECT channels_json FROM channel_snapshots WHERE id = 1').channels_json
);
const sourceIdsByName = new Map(
databaseModule
.all('SELECT id, name FROM source_channels WHERE source_id = ?', [source.id])
.map(row => [row.name, row.id])
);
expect(snapshot).to.have.lengthOf(2);
for (const channel of snapshot) {
expect(channel.sourceChannelId).to.equal(sourceIdsByName.get(channel.name));
}

const syncRun = databaseModule.get(
'SELECT kind, status, error FROM source_sync_runs WHERE source_id = ? ORDER BY started_at DESC LIMIT 1',
[source.id]
Expand Down
Loading
Loading