Skip to content
Draft
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
"test:all": "npm run test:consumer && npm run test:tv-smoke",
"test:desktop-smoke": "node tests/run-desktop-smoke.cjs",
"test:packaged-smoke": "node tests/run-packaged-smoke.cjs",
"test:tv-smoke": "node tests/run-tv-smoke.cjs && node tests/tv-audio-controls.cjs",
"test:tv-smoke": "node tests/run-tv-smoke.cjs && node tests/tv-audio-controls.cjs && node tests/tv-playback-evidence-smoke.cjs",
"qa:serve": "node tests/serve-static.cjs",
"test:static": "node --check shared/series-metadata.js && node --check shared/search-ranking.js && node --check shared/live-tv.js && node --check shared/watch-browse.js && node --check shared/playback-providers.js && node --check shared/user-state.js && node --check shared/artwork-cache.js && node --check shared/release-channel.js && node --check shared/card-artwork.js && node --check app/catalog-data.js && node --check app/site-artwork.js && node --check app/config.js && node --check app/renderer.js && node --check electron/main.cjs && node --check electron/preload.cjs && node --check tv/config.js && node --check tv/tv.js && node --check scripts/configure-build.cjs && node --check scripts/render-icon.cjs && node --check scripts/render-tv-icon.cjs && node --check scripts/stage-tv.cjs && node --check scripts/package-current-release.cjs && node --check tests/run-desktop-smoke.cjs && node --check tests/run-packaged-smoke.cjs && node --check tests/run-tv-smoke.cjs && node --check tests/capture-tv.cjs && node --check tests/tv-harness.cjs && node --check tests/search-smoke.cjs && node --check tests/watch-filters-smoke.cjs && node --check tests/smoke-library.cjs && node --check tests/card-artwork-smoke.cjs && node tests/catalog-data.test.cjs && node tests/series-metadata.test.cjs && node tests/search-ranking.test.cjs && node tests/live-tv.test.cjs && node tests/watch-browse.test.cjs && node tests/playback-providers.test.cjs && node tests/user-state.test.cjs && node tests/artwork-cache.test.cjs && node tests/release-channel.test.cjs && node tests/static-audit.cjs && node tests/release-config.test.cjs",
"test:static": "node --check shared/series-metadata.js && node --check shared/search-ranking.js && node --check shared/live-tv.js && node --check shared/watch-browse.js && node --check shared/playback-providers.js && node --check shared/user-state.js && node --check shared/artwork-cache.js && node --check shared/release-channel.js && node --check shared/card-artwork.js && node --check app/catalog-data.js && node --check app/site-artwork.js && node --check app/config.js && node --check app/renderer.js && node --check electron/main.cjs && node --check electron/preload.cjs && node --check tv/config.js && node --check tv/tv.js && node --check scripts/configure-build.cjs && node --check scripts/render-icon.cjs && node --check scripts/render-tv-icon.cjs && node --check scripts/stage-tv.cjs && node --check scripts/package-current-release.cjs && node --check tests/run-desktop-smoke.cjs && node --check tests/run-packaged-smoke.cjs && node --check tests/run-tv-smoke.cjs && node --check tests/capture-tv.cjs && node --check tests/tv-harness.cjs && node --check tests/search-smoke.cjs && node --check tests/watch-filters-smoke.cjs && node --check tests/smoke-library.cjs && node --check tests/card-artwork-smoke.cjs && node tests/catalog-data.test.cjs && node tests/series-metadata.test.cjs && node tests/search-ranking.test.cjs && node tests/live-tv.test.cjs && node tests/watch-browse.test.cjs && node tests/playback-providers.test.cjs && node tests/tv-playback-evidence.test.cjs && node tests/user-state.test.cjs && node tests/artwork-cache.test.cjs && node tests/release-channel.test.cjs && node tests/static-audit.cjs && node tests/release-config.test.cjs",
"configure:build": "node scripts/configure-build.cjs --desktop",
"tv:stage": "node scripts/stage-tv.cjs",
"tv:lg": "npm run tv:stage && npx --yes --package @webos-tools/cli@3.2.5 ares-package tv/build/lg -o tv/release",
Expand Down
4 changes: 3 additions & 1 deletion scripts/stage-tv.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const run = async () => {
.replace('../node_modules/hls.js/dist/hls.min.js', 'shared/hls.min.js');
fs.writeFileSync(path.join(androidAssets, 'index.html'), bundledTvHtml);
for (const file of ['config.js', 'tv.css', 'tv.js']) fs.copyFileSync(path.join(tvRoot, file), path.join(androidAssets, file));
for (const file of ['series-metadata.js', 'live-tv.js', 'watch-browse.js']) fs.copyFileSync(path.join(root, 'shared', file), path.join(androidAssets, 'shared', file));
for (const file of ['series-metadata.js', 'live-tv.js', 'watch-browse.js', 'playback-providers.js']) fs.copyFileSync(path.join(root, 'shared', file), path.join(androidAssets, 'shared', file));
fs.copyFileSync(path.join(root, 'node_modules', 'hls.js', 'dist', 'hls.min.js'), path.join(androidAssets, 'shared', 'hls.min.js'));
fs.copyFileSync(path.join(root, 'assets', 'harbor-mark.svg'), path.join(androidAssets, 'assets', 'harbor-mark.svg'));
fs.copyFileSync(icon, path.join(androidAssets, 'assets', 'icon.png'));
Expand All @@ -84,6 +84,7 @@ const run = async () => {
const seriesMetadataSource = fs.readFileSync(path.join(root, 'shared', 'series-metadata.js'), 'utf8');
const liveTvSource = fs.readFileSync(path.join(root, 'shared', 'live-tv.js'), 'utf8');
const watchBrowseSource = fs.readFileSync(path.join(root, 'shared', 'watch-browse.js'), 'utf8');
const playbackProvidersSource = fs.readFileSync(path.join(root, 'shared', 'playback-providers.js'), 'utf8');
const hlsSource = fs.readFileSync(path.join(root, 'node_modules', 'hls.js', 'dist', 'hls.min.js'), 'utf8');
const tvConfigSource = stagedApiKey
? `window.HARBOR_CONFIG = Object.freeze({\n tmdbApiKey: ${JSON.stringify(stagedApiKey)}\n});\n`
Expand All @@ -98,6 +99,7 @@ const run = async () => {
.replace('<script src="../shared/series-metadata.js"></script>', `<script nonce="${lgNonce}">${inlineScript(seriesMetadataSource)}</script>`)
.replace('<script src="../shared/live-tv.js"></script>', `<script nonce="${lgNonce}">${inlineScript(liveTvSource)}</script>`)
.replace('<script src="../shared/watch-browse.js"></script>', `<script nonce="${lgNonce}">${inlineScript(watchBrowseSource)}</script>`)
.replace('<script src="../shared/playback-providers.js"></script>', () => `<script nonce="${lgNonce}">${inlineScript(playbackProvidersSource)}</script>`)
.replace('<script src="config.js"></script>', `<script nonce="${lgNonce}">${inlineScript(tvConfigSource)}</script>`)
.replace('<script src="../node_modules/hls.js/dist/hls.min.js"></script>', `<script nonce="${lgNonce}">${inlineScript(hlsSource)}</script>`)
.replace('<script src="tv.js"></script>', `<script nonce="${lgNonce}">${inlineScript(fs.readFileSync(path.join(tvRoot, 'tv.js'), 'utf8'))}</script>`);
Expand Down
25 changes: 25 additions & 0 deletions shared/playback-providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@
return [provider.id, provider];
}));

// VidLink's official event schema spells its TMDB field "mtmdbId".
// https://vidlink.pro/ (Player Events). Only a title-bound positive playback
// event is evidence; page loads, metadata and failed attempts remain unknown.
function vidLinkPlaybackEvidence(payload, target, season, episode) {
function integer(value, allowZero) {
return (typeof value === 'string' || typeof value === 'number')
&& (allowZero ? /^(0|[1-9]\d*)$/ : /^[1-9]\d*$/).test(String(value))
&& Number.isSafeInteger(Number(value));
}
if (!target || !integer(target.tmdbId, false) || !payload || payload.type !== 'PLAYER_EVENT') return false;
var data = payload.data;
var mediaType = target.type === 'movie' ? 'movie' : (target.type === 'tv' || target.type === 'anime') ? 'tv' : '';
if (!data || !mediaType || data.mediaType !== mediaType || !integer(data.mtmdbId, false)
|| String(data.mtmdbId) !== String(target.tmdbId)) return false;
if (mediaType === 'tv' && (!integer(season, true) || !integer(episode, false)
|| !integer(data.season, true) || !integer(data.episode, false)
|| String(data.season) !== String(season) || String(data.episode) !== String(episode))) return false;
return (data.event === 'play' || data.event === 'timeupdate')
&& typeof data.currentTime === 'number' && Number.isFinite(data.currentTime)
&& typeof data.duration === 'number' && Number.isFinite(data.duration)
&& data.duration > 0 && data.currentTime >= 0 && data.currentTime <= data.duration
&& (data.event !== 'timeupdate' || data.currentTime > 0);
}

function resolve(providerId, tmdbId, isSeries, season, episode) {
var provider = providerById[providerId] || providerById.vidlink;
var template = isSeries ? provider.seriesTemplate : provider.movieTemplate;
Expand All @@ -110,6 +134,7 @@
allowedHosts: Object.freeze([...new Set(providers.flatMap(function (provider) {
return [provider.host, ...(provider.navigationHosts || [])];
}))]),
vidLinkPlaybackEvidence: vidLinkPlaybackEvidence,
resolve: resolve
});
});
2 changes: 1 addition & 1 deletion tests/static-audit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ assert(!tvHtml.includes('allow-popups'), 'TV player grants popup permission.');
assert(tvHtml.includes('id="tv-frame" class="player-frame focusable"') && tvHtml.includes('tabindex="0"'), 'TV player is not reachable with a 5-way remote.');
assert(tvScript.includes('function focusPlayerFrame()') && tvScript.includes('tvFrame.contentWindow.focus()'), 'TV player does not hand remote focus to the embedded controls.');
assert(tvScript.includes('function revealPlayerControls(route)') && tvScript.includes('if (route) revealPlayerControls(route);'), 'TV player controls stay covered while waiting for autoplay.');
assert(tvScript.includes("playerEvent === 'play' || playerEvent === 'timeupdate'"), 'TV player hides its loading screen before playback is proven ready.');
assert(tvScript.includes('playbackProvidersApi.vidLinkPlaybackEvidence(event.data, state.active, state.season, state.episode)'), 'TV player does not validate title-bound playback evidence.');
assert(!tvScript.includes("event.data.type === 'MEDIA_DATA' ||"), 'TV player still treats catalog metadata as playback readiness.');
assert(tvScript.includes('autoplay=true'), 'TV playback does not start immediately after an episode is selected.');
assert(tvScript.includes('https://vidsrc.to/embed/'), 'TV playback has no secure fallback provider.');
Expand Down
71 changes: 71 additions & 0 deletions tests/tv-playback-evidence-smoke.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const net = require('node:net');
const { spawn } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const reservePort = () => new Promise(resolve => { const server = net.createServer(); server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); });

(async () => {
const port = await reservePort();
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-audio-controls-'));
const child = spawn(require('electron'), [path.join(root, 'tests/tv-harness.cjs'), String(port), '--in-process-gpu', '--disable-gpu-sandbox'], { cwd: root, env: { ...process.env, HARBOR_QA_PROFILE: profile }, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
child.stderr.on('data', data => process.stderr.write(data));
let socket;
try {
let target;
for (let i = 0; i < 120; i++) { try { target = (await fetch(`http://127.0.0.1:${port}/json`).then(r => r.json())).find(t => /\/tv\/index.html/.test(t.url)); } catch {} if (target) break; await delay(100); }
assert.ok(target, 'TV renderer loaded');
socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => socket.addEventListener('open', resolve, { once: true }));
let id = 0;
const pending = new Map();
socket.addEventListener('message', event => { const message = JSON.parse(event.data); if (pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id); } });
const command = async (method, params = {}) => { const key = ++id; const result = new Promise(resolve => pending.set(key, resolve)); socket.send(JSON.stringify({ id: key, method, params })); const message = await result; if (message.error) throw Error(JSON.stringify(message.error)); return message.result; };
const evaluate = async expression => { const result = await command('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true }); if (result.exceptionDetails) throw Error(JSON.stringify(result.exceptionDetails)); return result.result.value; };
await command('Page.enable');
await command('Network.enable');
await command('Network.setBlockedURLs', { urls:['https://*','http://*'] });
await command('Page.addScriptToEvaluateOnNewDocument', { source: `
window.fetch = () => Promise.reject(Error('Offline evidence fixture'));
window.evidenceTimers=[];
const nativeTimeout=window.setTimeout;
window.setTimeout=function(fn,ms,...args) { window.evidenceTimers.push(ms); return nativeTimeout(fn,ms,...args); };
const frameSrc=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'src');
Object.defineProperty(HTMLIFrameElement.prototype,'src',{...frameSrc,
get() { return this.__fixtureSrc || frameSrc.get.call(this); },
set(url) { this.__fixtureSrc=url; frameSrc.set.call(this,'about:blank'); }
});
` });
if (process.argv[2]) await command('Page.navigate',{url:pathToFileURL(path.resolve(process.argv[2])).href});
else await command('Page.reload');
await evaluate(`(async()=>{ for(let n=0;n<160;n++) { if(window.HarborPlaybackProviders && document.querySelector('.media-card')) return; await new Promise(r=>setTimeout(r,50)); } throw Error('Evidence UI did not load'); })()`);
await evaluate(`document.querySelector('.media-card').click();document.querySelector('#detail-play').click()`);
await delay(1200);
assert.equal(await evaluate(`document.querySelector('#player-status').hidden`),true,'Loaded provider reveals usable controls');
const results=await evaluate(`(()=>{
const frame=document.querySelector('#tv-frame');
const id=Number(new URL(frame.src).pathname.split('/')[2]);
const payload={type:'PLAYER_EVENT',data:{event:'play',mtmdbId:id,mediaType:'movie',currentTime:0,duration:120}};
const send=(data,extra={})=>{ evidenceTimers=[]; window.dispatchEvent(new MessageEvent('message',{origin:'https://vidlink.pro',source:frame.contentWindow,data,...extra})); return evidenceTimers.filter(n=>n===900).length; };
const result={ wrongTitle:send({...payload,data:{...payload.data,mtmdbId:id+1}}),
missingTitle:send({...payload,data:{...payload.data,mtmdbId:null}}),
wrongOrigin:send(payload,{origin:'https://vidlink.pro.evil.test'}),
wrongSource:send(payload,{source:window}),
paused:send({...payload,data:{...payload.data,event:'pause'}}),
metadata:send({type:'MEDIA_DATA',data:{[id]:{progress:{watched:10,duration:120}}}}),
valid:send(payload) };
return result;
})()`);
assert.deepEqual(results,{wrongTitle:0,missingTitle:0,wrongOrigin:0,wrongSource:0,paused:0,metadata:0,valid:1});
await delay(1000);
assert.equal(await evaluate(`document.activeElement.id`),'tv-frame','Confirmed playback focuses usable player');
await evaluate(`document.querySelector('#player-back').click()`);
assert.equal(await evaluate(`document.querySelector('#player-panel').hidden`),true);
assert.equal(await evaluate(`(()=>{evidenceTimers=[];window.dispatchEvent(new MessageEvent('message',{origin:'https://vidlink.pro',source:document.querySelector('#tv-frame').contentWindow,data:{type:'PLAYER_EVENT',data:{event:'play',mtmdbId:1,mediaType:'movie',currentTime:0,duration:120}}}));return evidenceTimers.filter(n=>n===900).length;})()`),0,'Closed player ignores late messages');
console.log('Offline real TV DOM: frame reveal, forged/mismatched/metadata/paused rejection, positive title-bound readiness, focus and Back cleanup passed.');
} finally { socket?.close(); child.kill(); await Promise.race([new Promise(resolve => child.once('exit', resolve)), delay(3000)]); fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }
})().catch(error => { console.error(error); process.exitCode = 1; });
Loading
Loading