mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(playback): send Origin as a real VLC header and cover header IPC in E2E
Review follow-ups: VLC only used the Origin value as an :http-referrer fallback while MPV already sent it via --http-header-fields; both VLC paths (fresh spawn and RC enqueue) now emit the same buildHttpHeaderFields list, so a real `Origin: ...` header reaches the provider, deduplicated against an explicit headers-map Origin. The legacy origin-as-Referer fallback stays. The dash-clearkey Electron E2E now asserts the new IPC contract (blank channel-level headers arrive as undefined, not empty strings) and gains a scenario that sets a playlist-level User-Agent through the source editor and verifies the captured MPV fallback launch carries it across the renderer/main IPC boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL
This commit is contained in:
6 files changed
+104
-19
No files matched your search
@@ -7,4 +7,5 @@ issues: [1221]
|
||||
When a channel plays in MPV, VLC or the embedded MPV player, the custom
|
||||
User-Agent, Referer and Origin saved on the M3U playlist now reach the player.
|
||||
Per-channel `#EXTVLCOPT` headers still win; the playlist values only fill the
|
||||
gaps.
|
||||
gaps. VLC now also sends the Origin value as a real HTTP header, as MPV
|
||||
already did.
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
openAddPlaylistDialog,
|
||||
openSourceEditor,
|
||||
openSources,
|
||||
saveSourceDialog,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
updateSourceDialog,
|
||||
waitForM3uCatalog,
|
||||
workspaceRoot,
|
||||
} from './electron-test-fixtures';
|
||||
@@ -479,14 +484,17 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
|
||||
/web-player-diagnostic__player-card--primary/
|
||||
);
|
||||
|
||||
// Blank channel-level #EXTVLCOPT values resolve to `undefined` (not
|
||||
// empty strings) since the playlist-level header fallback landed —
|
||||
// absent means absent on the IPC boundary.
|
||||
const expectedLaunches = [
|
||||
{
|
||||
args: [
|
||||
`${fixtureServer.origin}/unsupported.mkv`,
|
||||
'Unsupported MKV',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -628,6 +636,61 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la
|
||||
).toBeVisible();
|
||||
await dock.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(dock).toBeHidden();
|
||||
|
||||
// Playlist-level custom headers must cross the IPC boundary when the
|
||||
// channel itself carries no #EXTVLCOPT values (#1221): set a
|
||||
// User-Agent on the source, relaunch the MPV fallback, and expect the
|
||||
// captured launch to carry it.
|
||||
await openSources(app.mainWindow);
|
||||
const sourceDialog = await openSourceEditor(
|
||||
app.mainWindow,
|
||||
'Imported as text'
|
||||
);
|
||||
await updateSourceDialog(sourceDialog, {
|
||||
userAgent: 'Playlist Agent E2E/1.0',
|
||||
});
|
||||
await saveSourceDialog(app.mainWindow, sourceDialog);
|
||||
await sourceRowByTitle(app.mainWindow, 'Imported as text')
|
||||
.first()
|
||||
.click();
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
|
||||
await channelItemByTitle(app.mainWindow, 'Unsupported MKV')
|
||||
.first()
|
||||
.click();
|
||||
await expect(banner).toContainText(
|
||||
/container is likely unsupported by the browser player/i,
|
||||
{ timeout: 15_000 }
|
||||
);
|
||||
await expect(mpvFallback).toBeVisible();
|
||||
await mpvFallback.click();
|
||||
const expectedPlaylistHeaderLaunch = {
|
||||
args: [
|
||||
`${fixtureServer.origin}/unsupported.mkv`,
|
||||
'Unsupported MKV',
|
||||
'',
|
||||
'Playlist Agent E2E/1.0',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
player: 'mpv',
|
||||
} satisfies CapturedExternalPlayerLaunch;
|
||||
await expect
|
||||
.poll(() => getPlaybackRecommendationCapture(app), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toEqual({
|
||||
closed: ['e2e-recommended-mpv-1'],
|
||||
completed: 3,
|
||||
launches: [
|
||||
...expectedBothLaunches,
|
||||
expectedPlaylistHeaderLaunch,
|
||||
],
|
||||
released: true,
|
||||
});
|
||||
} finally {
|
||||
await releasePlaybackRecommendationCapture(app).catch(() => undefined);
|
||||
await closeElectronApp(app);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from 'net';
|
||||
import { buildHttpHeaderFields } from './external-player-playback-request';
|
||||
import { ExternalPlaybackSnapshot } from './external-player-runtime';
|
||||
|
||||
export function buildVlcEnqueueCommands(options: {
|
||||
@@ -20,11 +21,10 @@ export function buildVlcEnqueueCommands(options: {
|
||||
} else if (options.origin) {
|
||||
inputOptions.push(`:http-referrer=${options.origin}`);
|
||||
}
|
||||
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
|
||||
if (!name || value === undefined || value === null) return;
|
||||
const trimmedValue = String(value).trim();
|
||||
if (!trimmedValue) return;
|
||||
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
|
||||
// Same field list MPV sends: a real `Origin: ...` header (unless the
|
||||
// headers map already carries one) plus every non-empty custom header.
|
||||
buildHttpHeaderFields(options.origin, options.headers).forEach((field) => {
|
||||
inputOptions.push(`:http-header=${field}`);
|
||||
});
|
||||
if (options.title) {
|
||||
inputOptions.push(`:meta-title=${options.title}`);
|
||||
|
||||
@@ -134,13 +134,29 @@ describe('vlc-session.service helpers and launch args', () => {
|
||||
})
|
||||
).toEqual([
|
||||
'clear',
|
||||
'add http://srv/2 :http-referrer=https://origin.example',
|
||||
'add http://srv/2 :http-referrer=https://origin.example ' +
|
||||
':http-header=Origin: https://origin.example',
|
||||
]);
|
||||
expect(buildVlcEnqueueCommands({ url: 'http://srv/3' })).toEqual([
|
||||
'clear',
|
||||
'add http://srv/3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate an Origin already present in the headers map', () => {
|
||||
expect(
|
||||
buildVlcEnqueueCommands({
|
||||
url: 'http://srv/4',
|
||||
referer: 'https://ref.example',
|
||||
origin: 'https://origin.example',
|
||||
headers: { Origin: 'https://explicit.example' },
|
||||
})
|
||||
).toEqual([
|
||||
'clear',
|
||||
'add http://srv/4 :http-referrer=https://ref.example ' +
|
||||
':http-header=Origin: https://explicit.example',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RC response parsing', () => {
|
||||
@@ -193,7 +209,7 @@ describe('vlc-session.service helpers and launch args', () => {
|
||||
expect(session.status).toBe('opened');
|
||||
});
|
||||
|
||||
it('uses the origin as referrer fallback when no referer is given', async () => {
|
||||
it('uses the origin as referrer fallback and sends it as a real header', async () => {
|
||||
const proc = createMockChildProcess();
|
||||
spawnMock.mockReturnValueOnce(proc);
|
||||
|
||||
@@ -208,6 +224,7 @@ describe('vlc-session.service helpers and launch args', () => {
|
||||
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
':http-referrer=https://origin.example',
|
||||
':http-header=Origin: https://origin.example',
|
||||
streamUrl,
|
||||
':meta-title=Origin Stream',
|
||||
]);
|
||||
|
||||
@@ -192,6 +192,7 @@ export async function openVlcPlayer({
|
||||
effectiveOrigin,
|
||||
effectiveReferer,
|
||||
effectiveUserAgent,
|
||||
headerFields,
|
||||
} = resolveEffectiveExternalPlaybackRequest({
|
||||
url,
|
||||
userAgent,
|
||||
@@ -262,14 +263,12 @@ export async function openVlcPlayer({
|
||||
args.push(`:http-referrer=${effectiveOrigin}`);
|
||||
}
|
||||
|
||||
if (Object.keys(mergedHeaders).length > 0) {
|
||||
Object.entries(mergedHeaders).forEach(([name, value]) => {
|
||||
if (!name || value === undefined || value === null) return;
|
||||
const trimmedValue = String(value).trim();
|
||||
if (!trimmedValue) return;
|
||||
args.push(`:http-header=${name}: ${trimmedValue}`);
|
||||
});
|
||||
}
|
||||
// Same field list MPV sends via --http-header-fields: a real
|
||||
// `Origin: ...` header (deduplicated against the merged headers)
|
||||
// plus every non-empty custom header.
|
||||
headerFields.forEach((field) => {
|
||||
args.push(`:http-header=${field}`);
|
||||
});
|
||||
|
||||
if (startTime) {
|
||||
args.push(`--start-time=${startTime}`);
|
||||
|
||||
@@ -969,7 +969,12 @@ wins, the playlist-level value (import dialog / playlist settings) is the
|
||||
fallback, and blank values count as absent. Every M3U external launch path
|
||||
goes through it: the auto-launch and catch-up effects in `m3u-state`, the
|
||||
manual MPV/VLC fallback in `VideoPlayerComponent`, and the embedded MPV
|
||||
payload (`embeddedPlayback`).
|
||||
payload (`embeddedPlayback`). In the main process both players emit the same
|
||||
header field list (`buildHttpHeaderFields`): a real `Origin: ...` header
|
||||
(deduplicated against the custom headers map) plus every non-empty custom
|
||||
header — MPV via `--http-header-fields`, VLC via per-input `:http-header=`
|
||||
options in both the fresh-spawn and RC-enqueue paths. VLC additionally keeps
|
||||
its legacy origin-as-Referer fallback when no Referer is set.
|
||||
|
||||
### DASH + ClearKey Playback
|
||||
|
||||
|
||||
Reference in new issue
Block a user