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
165 changes: 107 additions & 58 deletions admin/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@
</n-form-item>
</n-form>
<div v-if="setupError" style="color: #d9534f; margin-bottom: 0.75rem">{{ setupError }}</div>
<n-button type="primary" :loading="savingSetup" @click="submitSetup" block>{{

Check warning on line 40 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break after opening tag (`<n-button>`), but no line breaks found
savingSetup ? 'Saving...' : 'Save Credentials'
}}</n-button>

Check warning on line 42 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break before closing tag (`</n-button>`), but no line breaks found
</n-modal>

<n-layout>
Expand All @@ -48,8 +48,8 @@
style="padding: 1rem; display: flex; align-items: center; gap: 1rem"
>
<h1 style="margin: 0; font-size: 1.2rem; flex: 1">IPTV Proxy Admin</h1>
<n-button v-if="authConfigured" size="small" secondary @click="logout" :loading="loggingOut"

Check warning on line 51 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected no line breaks before closing bracket, but 1 line break found
>Sign Out</n-button

Check warning on line 52 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected no line breaks before closing bracket, but 1 line break found

Check warning on line 52 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break before closing tag (`</n-button>`), but no line breaks found

Check warning on line 52 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break after opening tag (`<n-button>`), but no line breaks found
>
</n-layout-header>
<n-layout-content style="padding: 1rem">
Expand All @@ -60,9 +60,9 @@
<n-input v-model:value="app.base_url" placeholder="https://example.com" />
</n-form-item>
<n-space>
<n-button type="primary" @click="saveApp" :loading="savingApp">{{

Check warning on line 63 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break after opening tag (`<n-button>`), but no line breaks found
savingApp ? 'Saving...' : 'Save App'
}}</n-button>

Check warning on line 65 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break before closing tag (`</n-button>`), but no line breaks found
</n-space>
</n-form>
<div class="foot">
Expand Down Expand Up @@ -101,9 +101,9 @@
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="savingPassword" @click="changePassword">{{

Check warning on line 104 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break after opening tag (`<n-button>`), but no line breaks found
savingPassword ? 'Saving...' : 'Change Password'
}}</n-button>

Check warning on line 106 in admin/src/App.vue

View workflow job for this annotation

GitHub Actions / Lint and Security Checks

Expected 1 line break before closing tag (`</n-button>`), but no line breaks found
</n-form-item>
</n-form>
</div>
Expand Down Expand Up @@ -1674,69 +1674,109 @@
return;
}

// HDHomeRun OTA channels (ATSC 1.0) return raw MPEG-TS with MPEG-2 video even
// when ?streamMode=hls is requested. mpegts.js can parse the TS container but
// silently drops MPEG-2 video (stream type 0x02) without emitting an error,
// leaving a black screen. Start a HEAD probe in parallel so playback setup is
// not delayed on every preview, and only interrupt if the probe confirms TS.
// The probe is only issued for HDHomeRun channels since the result is only used
// for this check — other providers skip the probe and start playback immediately.
if (state.previewWatchingChannel?.hdhomerun) {
const previewChannel = state.previewWatchingChannel;

// Shared teardown used by both the HEAD and GET probe paths.
function handleMpegTsDetected() {
// Ignore stale probe results if the user closed/switched the player.
if (
videoPlayerEl.value !== video ||
state.previewWatchingChannel !== previewChannel ||
previewStreamUrl.value !== streamUrl
) {
return;
}
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (mpegtsInstance) {
mpegtsInstance.destroy();
mpegtsInstance = null;
}
video.removeAttribute('src');
video.load();
showPlayerError(ERR_UNSUPPORTED_CODEC);
}
// Start a parallel codec probe for all channels. The server-side probe reads
// a small header of the upstream stream and parses the MPEG-TS PAT/PMT tables
// to identify the video and audio stream types. If the codecs are incompatible
// with browser MSE (e.g. MPEG-2 video or AC-3 audio from ATSC OTA broadcasts),
// the probe returns { browserCompatible: false } and we automatically switch to
// server-side transcoding via ffmpeg.
//
// For HDHomeRun channels the probe URL includes ?streamMode=hls so the server
// probes the same content the browser will receive (modern HDHomeRun firmware
// re-encodes to HLS when that parameter is present; older firmware ignores it
// and returns raw MPEG-TS regardless).
//
// The probe runs in parallel with HLS.js so it does not add latency when the
// stream is already HLS or the codecs are browser-compatible.
const probeChannel = state.previewWatchingChannel;
const probeBase = `/api/stream-probe/${encodeURIComponent(probeChannel?.source || '')}/${encodeURIComponent(probeChannel?.name || '')}`;
const probeUrl = probeChannel?.hdhomerun ? `${probeBase}?streamMode=hls` : probeBase;

// probeSettled / probeResult track the async probe lifecycle.
// pendingAfterProbe is set by the HLS.js error handler when the probe has not
// yet returned and it needs to defer the player-type decision.
let probeSettled = false;
let probeResult = null;
let pendingAfterProbe = null;

// Return true when the channel has changed or the player was replaced since
// setupVideoPlayer() was called — used to discard stale async results.
function isStale() {
return (
videoPlayerEl.value !== video ||
state.previewWatchingChannel !== probeChannel ||
previewStreamUrl.value !== streamUrl
);
}

fetch(streamUrl, { method: 'HEAD', signal: AbortSignal.timeout(1000) })
.then((probeResponse) => {
if (!probeResponse.ok) return;
const contentType = (probeResponse.headers.get('content-type') || '').toLowerCase();
const isMpegTs =
contentType.startsWith('video/mpeg') || contentType.startsWith('video/mp2t');
if (isMpegTs) handleMpegTsDetected();
})
.catch(() => {
// HEAD not supported by the upstream device — fall back to a GET probe.
// The body is cancelled immediately after reading headers so we don't
// download the stream alongside HLS.js.
fetch(streamUrl, { signal: AbortSignal.timeout(3000) })
.then(async getResponse => {
const ct = (getResponse.headers.get('content-type') || '').toLowerCase();
await getResponse.body?.cancel().catch(() => {});
if (!getResponse.ok) return;
const isMpegTs =
ct.startsWith('video/mpeg') || ct.startsWith('video/mp2t');
if (isMpegTs) handleMpegTsDetected();
})
.catch(() => {}); // GET also failed — give up and leave HLS.js running.
// Choose the right player once the probe result is available.
function applyProbeResult(probe) {
if (isStale()) return;
if (probe && !probe.browserCompatible) {
setupTranscodePlayer().catch(err => {
console.warn(
'[player] probe-triggered transcode setup failed for %s/%s:',
probeChannel?.source,
probeChannel?.name,
err,
);
});
} else {
setupMpegtsPlayer(video, streamUrl);
}
}

fetch(probeUrl, { signal: AbortSignal.timeout(15000) })
.then(r => (r.ok ? r.json() : null))
.then(result => {
if (isStale()) return;
probeResult = result;
probeSettled = true;

if (pendingAfterProbe) {
// HLS.js already reported a manifest error while the probe was in flight.
pendingAfterProbe();
pendingAfterProbe = null;
} else if (result && !result.browserCompatible && result.container === 'mpeg-ts') {
// Codec probe identified incompatible streams before HLS.js fired an error
// (e.g. an old HDHomeRun tuner that ignores ?streamMode=hls and returns
// MPEG-2/AC-3). Pre-empt the running player and switch to transcoding.
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (mpegtsInstance) {
mpegtsInstance.destroy();
mpegtsInstance = null;
}
video.removeAttribute('src');
video.load();
setupTranscodePlayer().catch(err => {
console.warn(
'[player] probe-triggered transcode setup failed for %s/%s:',
probeChannel?.source,
probeChannel?.name,
err,
);
});
}
})
.catch(() => {
if (isStale()) return;
probeSettled = true;
// Probe failed — if the HLS.js error handler is already waiting, unblock it.
if (pendingAfterProbe) {
pendingAfterProbe();
pendingAfterProbe = null;
}
});

// Use bundled HLS.js for other browsers.
// For HDHomeRun channels the stream URL includes ?streamMode=hls (see previewStreamUrl),
// so HLS.js receives the server-proxied HLS playlist from the device. If the device
// returns raw MPEG-TS instead (older firmware that ignores ?streamMode=hls), HLS.js
// will fire a MANIFEST_PARSING_ERROR and the error handler below falls back to mpegts.js.
// will fire a MANIFEST_PARSING_ERROR. We then use the codec probe result to decide
// whether to route to mpegts.js (compatible codecs) or ffmpeg transcoding (incompatible).
if (Hls.isSupported()) {
hlsInstance = new Hls();
hlsInstance.loadSource(streamUrl);
Expand All @@ -1755,15 +1795,24 @@
Hls.ErrorDetails.LEVEL_PARSING_ERROR,
];

// Only fall back to mpegts.js when the response is not a valid HLS playlist
// Only route to an alternative player when the response is not a valid HLS
// playlist (as opposed to a network/auth error, which shows a stream-unavailable
// message instead).
const isManifestFormatError = manifestFormatErrorDetails.includes(data.details);

hlsInstance.destroy();
hlsInstance = null;

if (isManifestFormatError) {
// Stream is not HLS (e.g. raw MPEG-TS from HDHomeRun); try mpegts.js
setupMpegtsPlayer(video, streamUrl);
// Stream is raw MPEG-TS (or some other non-HLS format). Use the codec
// probe result to choose between mpegts.js (compatible) and server
// transcoding (incompatible codecs such as MPEG-2 video or AC-3 audio).
if (probeSettled) {
applyProbeResult(probeResult);
} else {
// Probe is still in flight — defer the decision until it resolves.
pendingAfterProbe = () => applyProbeResult(probeResult);
}
} else {
showPlayerError(ERR_STREAM_UNAVAILABLE);
}
Expand Down
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getConfigPath } from './libs/paths.js';
import { setupHDHRRoutes } from './server/hdhr.js';
import { setupLineupRoutes, invalidateLineupCaches } from './server/lineup.js';
import { setupTranscodeRoutes } from './server/transcode.js';
import { setupStreamProbeRoutes } from './server/stream-probe.js';
import { setupEPGRoutes } from './server/epg.js';
import { imageProxyRoute } from './libs/proxy-image.js';
import { setupMCPRoutes } from './server/mcp.js';
Expand Down Expand Up @@ -261,6 +262,7 @@ imageProxyRoute(app);
setupHDHRRoutes(app, config);
setupLineupRoutes(app, config, { registerUsage, touchUsage, unregisterUsage });
setupTranscodeRoutes(app);
setupStreamProbeRoutes(app);
await setupEPGRoutes(app);
setupMCPRoutes(app);

Expand Down
Loading
Loading