I dont have the exact url, but matthuisman has epg data for foxtel stuff
Great find!
Any further deep links?
I tried to have a look around but couldnāt even find the link to open the app.
Would be great to get a link straight to open the live channels!
I'm still trying to work that out.
From my initial looking I've found what I think links to the direct streams, but you somehow have to include the keys because it's all encrypted. I've done some basic searching online on how to get those - it looks possible - but it's beyond my skillset to work out.
The other thing is to find some sort of "on.click" code that works. Even if I find that though, it probably won't work for iOS or tvOS. I'll see what I can dig up though.
Yeah no problems.
I feel like they may let you link to an āEvent Tileā or a tab. But probably not to the live channels.
Can only hope tho! Iāll give it a try at some stage but will be mostly guess work from me!
Awesome stuff.
this is great - thankyou for sharing progress, I hope that we can at least get to deep linking to the live channel. I agree having the guide to see what is on in the one spot is optimal - I still hold out hope that integration of the live stream will live again one day!
From what I could find it's just not possible at the moment because there's nothing useable to actually link to. It doesn't even seem possible to link to the live stream carousel to save a bit of scrolling. Maybe with Hubbl out now there'll be a developer who can work out what they've done there and try to replicate it in Channel. But even then I suspect the Hubbl being owned by Foxtel has special access to your keys making the streams play. I hold on hope!
Hey all,
Been working on a work around to make Kayo live channels compatible with CC4C.
Due to there being no direct channel links it relies on some things that could break fairly easily, so doing a bit of testing atm to see how stable I can get it.
Let me know anyone, if you're interested in trying it out.
On my m2 pro Mac mini I use about 15-20% cpu per live stream FYI.
Iād love to test this if possible! Let me know! Thanks mate.
I imagine most aussies here are interested in this so I think its a good idea to post it here
Too easy.
Let me just try to refine the tuning abit more, there is still some inconsistencies Iām trying to iron out when I get some time.
Also it relies on the images that appear in the live carousel - which can change for events (pga f1 etc) and can also change order - so Iām unsure what the best way to deal with that atm is, have a couple ideas but will see what happens next time and image changes with the link.
ATM itās pretty much going to the front page, waiting a second for the page to load, looking for the image name and then clicks on the button closest to that (all the buttons are named the same).
Iāve had some errors if the pages hasnāt loaded properly when trying to find the image so will try make that more consistent too.
Unfortunately Iām not coder so this is all pasting bits of code into ChatGPT and asking it to do something and then testing it. But so far atleast have gotten it to work.
Edit: also the timing with going full screen in relation to when the Cc4c capture starts matters too, so thatās been abit finicky.
Will post here as soon as Iāve got it loading streams 99% consistently and then can try update the channel selector later if itās needed.
Edit2: some positive news is the channel image has recently changed for 503 and 506 and seemed to have remained with the same name - despite being a different image which would make life a lot easier. Just need to get the tuning fine tuned.
Thanks mate! Thatās great to hear.
Depending on the setup, could even maybe get it going for Binge the same way with its live channels.
Yeah I would think so.
From memory their website is fairly similar so would just need to assign all the image names to their channel.
Hey mate. Hope you're well! Always available to test/help
Got it working fairly well atm.
Just grab the CC4C zip file from original cc4c GitHub by tmm1 and edit the main.js to the following code and then follow the setup steps on the GitHub.
const {launch: puppeteerLaunch} = require('puppeteer-core')
const {launch, getStream} = require('puppeteer-stream')
const fs = require('fs')
const child_process = require('child_process')
const process = require('process')
const path = require('path')
const express = require('express')
const morgan = require('morgan')
require('console-stamp')(console, {
format: ':date(yyyy/mm/dd HH:MM:ss.l)',
})
// --- suppress harmless first-run extension error, but still restart ---
const EXT_ID = 'jjndjgheafjngoipoacpjgeicjeomjli';
process.on('unhandledRejection', (reason) => {
const msg = String(reason?.message || reason || '');
if (
msg.includes('net::ERR_BLOCKED_BY_CLIENT') &&
msg.includes(`chrome-extension://${EXT_ID}/options.html`)
) {
console.log('[Info] Restarting following first-run puppeteer-stream extension installation');
process.exit(1); // still exit so supervisor restarts
return;
}
console.error('Unhandled rejection:', reason);
process.exit(1);
});
// ---------------------------------------------------------------------
// Parse command line arguments
const argv = require('yargs')
.option('videoBitrate', {
alias: 'v',
description: 'Video bitrate in bits per second',
type: 'number',
default: 6500000,
})
.option('audioBitrate', {
alias: 'a',
description: 'Audio bitrate in bits per second',
type: 'number',
default: 256000,
})
.option('frameRate', {
alias: 'f',
description: 'Minimum frame rate',
type: 'number',
default: 50,
})
.option('port', {
alias: 'p',
description: 'Port number for the server',
type: 'number',
default: 5589,
})
.option('width', {
alias: 'w',
description: 'Video width in pixels (e.g., 1920 for 1080p)',
type: 'number',
default: 1920,
})
.option('height', {
alias: 'h',
description: 'Video height in pixels (e.g., 1080 for 1080p)',
type: 'number',
default: 1080,
})
.option('minimizeWindow', {
alias: 'm',
description: 'Minimize window on start',
type: 'boolean',
default: false,
})
.scriptName('cc4c')
.usage('Usage: $0 [options]')
.example('$0 -v 6500000 -a 192000 -f 50 -w 1920 -h 1080', 'Capture at 6.5Mbps video, 192kbps audio, 50fps, 1920x1080')
.example(
'$0 --videoBitrate 8000000 --audioBitrate 320000 --frameRate 50 --width 1920 --height 1080',
'High quality capture at 8Mbps and 50fpsm 1920x1080'
)
.wrap(null) // Don't wrap help text
.help()
.alias('help', '?')
.version(false).argv // Disable version number in help
// Display settings
console.log('Selected settings:')
console.log(`Video Bitrate: ${argv.videoBitrate} bps (${argv.videoBitrate / 1000000}Mbps)`)
console.log(`Audio Bitrate: ${argv.audioBitrate} bps (${argv.audioBitrate / 1000}kbps)`)
console.log(`Minimum Frame Rate: ${argv.frameRate} fps`)
console.log(`Port: ${argv.port}`)
console.log(`Resolution: ${argv.width}x${argv.height}`)
const encodingParams = {
videoBitsPerSecond: argv.videoBitrate,
audioBitsPerSecond: argv.audioBitrate,
minFrameRate: argv.frameRate,
maxFrameRate: 50,
mimeType: 'video/webm;codecs=H264',
}
const viewport = {
width: argv.width,
height: argv.height,
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
var currentBrowser, dataDir, lastPage
const getCurrentBrowser = async () => {
if (!currentBrowser || !currentBrowser.isConnected()) {
currentBrowser = await launch(
{
launch: opts => {
if (process.env.DOCKER) {
opts.args = opts.args.concat([
'--use-gl=angle',
'--use-angle=gl-egl',
'--enable-features=VaapiVideoDecoder,VaapiVideoEncoder',
'--ignore-gpu-blocklist',
'--enable-zero-copy',
'--enable-drdc',
'--no-sandbox',
])
}
console.log('Launching Browser, Opts', opts)
return puppeteerLaunch(opts)
},
},
{
executablePath: getExecutablePath(),
pipe: true, // more robust to keep browser connection from disconnecting
headless: false,
defaultViewport: null, // no viewport emulation
userDataDir: path.join(dataDir, 'chromedata'),
args: [
'--no-first-run', // Skip first run wizards
'--hide-crash-restore-bubble',
'--allow-running-insecure-content', // Sling has both https and http
'--autoplay-policy=no-user-gesture-required',
'--disable-blink-features=AutomationControlled', // mitigates bot detection
'--hide-scrollbars', // Hide scrollbars on captured pages
'--window-size=' + viewport.width + ',' + viewport.height, // Set viewport resolution
'--disable-notifications', // Mimic real user behavior
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-background-media-suspend',
'--disable-backgrounding-occluded-windows',
],
ignoreDefaultArgs: [
'--enable-automation',
'--disable-extensions',
'--disable-default-apps',
'--disable-component-update',
'--disable-component-extensions-with-background-pages',
'--enable-blink-features=IdleDetection',
'--mute-audio',
],
}
)
currentBrowser.on('close', () => {
currentBrowser = null
console.log('Browser closed')
})
currentBrowser.on('targetcreated', target => {
console.log('New target page created:', target.url())
})
currentBrowser.on('targetchanged', target => {
console.log('Target page changed:', target.url())
})
currentBrowser.on('targetdestroyed', target => {
console.log('Browser page closed:', target.url())
})
currentBrowser.on('disconnected', () => {
console.log('Browser disconnected')
})
}
return currentBrowser
}
const getExecutablePath = () => {
if (process.env.CHROME_BIN) {
return process.env.CHROME_BIN
}
let executablePath
if (process.platform === 'linux') {
try {
executablePath = child_process.execSync('which chromium-browser').toString().split('\n').shift()
} catch (e) {
// NOOP
}
if (!executablePath) {
executablePath = child_process.execSync('which chromium').toString().split('\n').shift()
if (!executablePath) {
throw new Error('Chromium not found (which chromium)')
}
}
} else if (process.platform === 'darwin') {
executablePath = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
].find(fs.existsSync)
} else if (process.platform === 'win32') {
executablePath = [
`C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe`,
`C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe`,
path.join(process.env.USERPROFILE, 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env.USERPROFILE, 'AppData', 'Local', 'Chromium', 'Application', 'chrome.exe'),
].find(fs.existsSync)
} else {
throw new Error('Unsupported platform: ' + process.platform)
}
return executablePath
}
async function main() {
dataDir = process.cwd()
switch (process.platform) {
case 'darwin':
dataDir = path.join(process.env.HOME, 'Library', 'Application Support', 'ChromeCapture')
break
case 'win32':
dataDir = path.join(process.env.USERPROFILE, 'AppData', 'Local', 'ChromeCapture')
break
}
// --- KayoSports Automation Definitions START ---
// Define the unique stable IDs for the 12 channels
const CUSTOM_CHANNEL_MAP = {
"espn": "5bce8eb9e4b0a8faf3c14a94",
"footy": "5bcefacfe4b0a8faf3c14ae2",
"cricket": "5bcef5ede4b0a8faf3c14acf",
"505": "5bcefaf5e4b0cb6f1d7f46fc",
"503": "5bcef93ae4b0a8faf3c14ada",
"506": "5bcefc4ae4b0a8faf3c14aed",
"league": "5bcef901e4b0cb6f1d7f46f3",
"news": "5bcefccee4b0a8faf3c14aef",
"racing": "5ccacc4ae4b020d0a4eb3979",
"ufc": "66d524f4e4b06b17c2bfdd58",
"espn2": "5bcef583e4b0a8faf3c14acb",
"507": "5bcefc6be4b0cb6f1d7f4703",
};
// *** NOTE: The original selectChannelAndGoFullscreen function has been split
// into two parts within handleStreamRequest to control timing (Select then Stream then Fullscreen).
// The original combined function is removed here. ***
// --- KayoSports Automation Definitions END ---
const app = express()
const df = require('dateformat')
morgan.token('mydate', function (req) {
return df(new Date(), 'yyyy/mm/dd HH:MM:ss.l')
})
app.use(morgan('[:mydate] :method :url from :remote-addr responded :status in :response-time ms'))
app.get('/', (req, res) => {
res.send(
`<html>
<title>Chrome Capture for Channels</title>
<h2>Chrome Capture for Channels</h2>
<p>Usage: <code>/stream?url=URL</code> or <code>/stream/<name></code></p>
<pre>
#EXTM3U
#EXTINF:-1 channel-id="windy",Windy
chrome://${req.get('host')}/stream/windy
#EXTINF:-1 channel-id="weatherscan",Weatherscan
chrome://${req.get('host')}/stream/weatherscan
</pre>
</html>`
)
})
app.get('/debug', async (req, res) => {
res.send(`<html>
<script>
async function videoClick(e) {
e.target.focus()
let x = ((e.clientX-e.target.offsetLeft) * e.target.videoWidth)/e.target.clientWidth
let y = ((e.clientY-e.target.offsetTop) * e.target.videoHeight)/e.target.clientHeight
console.log('video click', x, y)
await fetch('/debug/click/'+x+'/'+y)
}
async function videoKeyPress(e) {
console.log('video keypress', e.key)
await fetch('/debug/keypress/'+e.key)
}
document.addEventListener('keypress', videoKeyPress)
</script>
<video style="width: 100%; height: 100%" onKeyPress="videoKeyPress(event)" onClick="videoClick(event)" src="/stream?waitForVideo=false&url=${encodeURIComponent(
req.query.url || 'https://google.com'
)}" autoplay muted />
</html>`)
})
app.get('/debug/click/:x/:y', async (req, res) => {
let browser = await getCurrentBrowser()
let pages = await browser.pages()
if (pages.length == 0) {
res.send('false')
return
}
let page = pages[pages.length - 1]
await page.mouse.click(parseInt(req.params.x), parseInt(req.params.y))
res.send('true')
})
app.get('/stream{/:name}', async (req, res) => {
var u = req.query.url
let name = req.params.name
if (name) {
u = {
gpu: 'chrome://gpu',
}[name]
}
await handleStreamRequest(req, res, u)
})
async function handleStreamRequest(req, res, u) {
async function setupPage(browser) {
// Create a new page
var newPage = await browser.newPage()
// Stabilize it
await newPage.setBypassCSP(true) // Sometimes needed for puppeteer-stream
await delay(1000) // Wait for the page to be stable
// Now try to enable stream capabilities
if (newPage.getStream) {
console.log('Stream capabilities already present')
} else {
console.log('Need to initialize stream capabilities')
// Here you might need to reinitialize puppeteer-stream
}
// Show browser error messages, but for Sling filter out Sling Mixed Content warnings
newPage.on('console', msg => {
const text = msg.text()
// Filter out messages containing "Mixed Content"
if (!text.includes('Mixed Content')) {
// UNCOMMENT THIS LINE TO SEE ALL BROWSER MESSAGES
//console.log(text);
}
})
return newPage
}
var browser, page, stream; // Declared stream variable here
try {
browser = await getCurrentBrowser()
page = await setupPage(browser)
} catch (e) {
console.log('failed to start browser page', u, e)
res.status(500).send(`failed to start browser page: ${e}`)
return
}
// --- 1. Navigate, set window, and run **CHANNEL SELECT** automation (pre-stream) ---
try {
// go to the page
await page.goto(u)
// get some additional info about the page
const uiSize = await page.evaluate(`(function() {
return {
height: window.outerHeight - window.innerHeight,
width: window.outerWidth - window.innerWidth,
}
})()`)
const session = await page.target().createCDPSession()
const {windowId} = await session.send('Browser.getWindowForTarget')
await session.send('Browser.setWindowBounds', {
windowId,
bounds: {
height: viewport.height + uiSize.height,
width: viewport.width + uiSize.width,
},
})
if (argv.minimizeWindow) {
await session.send('Browser.setWindowBounds', {
windowId,
bounds: {
windowState: 'minimized',
},
})
}
// Run Kayo Channel Select
if (u.includes('kayosports.com.au') && req.query.channel) {
const channelName = req.query.channel.toLowerCase();
const stableId = CUSTOM_CHANNEL_MAP[channelName];
if (stableId) {
console.log(`[Automation] Kayo Sports page loaded. Starting with a vertical scroll, then attempting channel selection.`);
try {
await page.evaluate(() => {
window.scrollBy(0, 300);
});
await delay(500)
let selectionSuccess = false;
selectionSuccess = await page.evaluate((id) => {
// Search for the channel tile using the stable ID in the image source
const channelImage = Array.from(document.querySelectorAll('.TM21VideoTile img'))
.find(img => img.src.includes(id));
if (channelImage) {
// Found it! Click the tile and stop.
channelImage.closest('button').click();
return true;
}
return false;
}, stableId);
if (selectionSuccess) {
console.log(`ā
Channel ID ${stableId} found and selected.`);
await delay(500); // Wait for video to initialize after clicking the tile
} else {
console.error(`ā [Automation] Channel ID "${stableId}" was not found.`);
}
} catch (e) {
console.error('[Automation] Error during Kayo Sports channel selection:', e);
}
} else {
console.error(`[Automation] Could not find stable ID for channel name: ${channelName}`);
}
}
} catch (e) {
console.log('failed to goto page and setup window', u, e)
}
// --- 2. Start Stream Capture (NO PIPE) ---
try {
stream = await getStream(page, { // Store stream in variable
video: true,
audio: true,
videoBitsPerSecond: encodingParams.videoBitsPerSecond,
audioBitsPerSecond: encodingParams.audioBitsPerSecond,
mimeType: encodingParams.mimeType,
videoConstraints: {
mandatory: {
minWidth: viewport.width,
minHeight: viewport.height,
maxWidth: viewport.width,
maxHeight: viewport.height,
minFrameRate: encodingParams.minFrameRate,
maxFrameRate: encodingParams.maxFrameRate,
},
},
})
// Handle stream events - now attached to the stored stream
stream.on('error', err => {
console.log('Stream error:', err)
})
stream.on('end', () => {
console.log('Stream ended naturally')
})
console.log('Stream capture initiated (not yet piped).', u)
} catch (e) {
console.log('failed to start stream capture', u, e)
res.status(500).send(`failed to start stream capture: ${e}`)
await page.close()
return
}
// --- 3. **FULLSCREEN TOGGLE** Automation (Post-stream capture, pre-pipe) ---
// Kayo Sports Fullscreen Toggle
if (u.includes('kayosports.com.au') && req.query.channel) {
console.log('[Automation] Kayo Sports: Toggling Fullscreen...');
try {
await delay(500)
const FULLSCREEN_TOGGLE_FUNCTION = () => {
const uniquePathData = "M8.384 22.596L8.384 8.384 35.033 8.384 35.033 0 0 0 0 22.596zM8.384 44.954L0 44.954 0 67.313 35.033 67.313 35.033 58.928 8.384 58.928zM81.049 22.596L89.434 22.596 89.434 0 57.391 0 57.391 8.384 81.049 8.384zM81.049 44.954L81.049 58.928 57.391 58.928 57.391 67.313 89.434 67.313 89.434 44.954z";
const fullscreenPath = document.querySelector(`path[d="${uniquePathData}"]`);
if (fullscreenPath) {
const fullscreenButton = fullscreenPath.closest('.gRgwBu');
if (fullscreenButton) {
fullscreenButton.click();
return true;
}
}
return false;
};
const fullscreenSuccess = await page.evaluate(FULLSCREEN_TOGGLE_FUNCTION);
if (fullscreenSuccess) {
console.log('ā
Kayo Sports: Fullscreen toggled successfully.');
} else {
console.error('ā Kayo Sports: Failed to toggle fullscreen.');
}
} catch (e) {
console.error('[Automation] Error during Kayo Sports fullscreen toggle:', e)
}
}
// --- 4. Pipe Stream to Client ---
try {
console.log('streaming', u)
stream.pipe(res)
// Handle response events - close event is expected
req.on('close', async err => {
console.log('received close event on request')
stream.destroy()
await page.close()
console.log('finished', u)
})
res.on('error', async err => {
console.log('error on response:', err)
stream.destroy()
await page.close()
})
res.on('finish', async err => {
console.log('Response finished')
stream.destroy()
await page.close()
})
} catch (e) {
console.log('failed to pipe stream', u, e)
res.status(500).send(`failed to pipe stream: ${e}`)
await page.close()
return
}
}
const server = app.listen(argv.port, () => {
console.log('Chrome Capture server listening on port', argv.port)
})
}
main().catch(err => {
console.error(err)
process.exit(1)
})
I've edited with mainly GeminiAI - I've deleted other CC4C stuff and rearranged some of the process so that more of the tuning is done before it is piped to the client (don't see the kayo webpage this way). But needed to leave the fullscreen toggle after the pipe due to some viewing issues where it would properly go fullscreen.
And here is an M3U you can try, obviously gotta put in your CC4C IP though.
#EXTM3U
#EXTINF:0 tvg-chno="500" tvg-name="Fox Sports News" tvg-id="500",Fox Sports News
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=news
#EXTINF:0 tvg-chno="501" tvg-name="Fox Cricket" tvg-id="501",Fox Cricket
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=cricket
#EXTINF:0 tvg-chno="502" tvg-name="Fox League" tvg-id="502",Fox League
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=league
#EXTINF:0 tvg-chno="503" tvg-name="Fox Sports 503" tvg-id="503",Fox Sports 503
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=503
#EXTINF:0 tvg-chno="504" tvg-name="Fox Footy" tvg-id="504",Fox Footy
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=footy
#EXTINF:0 tvg-chno="505" tvg-name="Fox Sports 505" tvg-id="505",Fox Sports 505
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=505
#EXTINF:0 tvg-chno="506" tvg-name="Fox Sports 506" tvg-id="506",Fox Sports 506
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=506
#EXTINF:0 tvg-chno="507" tvg-name="Fox Sports 507" tvg-id="507",Fox Sports 507
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=507
#EXTINF:0 tvg-chno="509" tvg-name="ESPN" tvg-id="509",ESPN
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=espn
#EXTINF:0 tvg-chno="510" tvg-name="ESPN2" tvg-id="510",ESPN2
chrome://<IP_ADDRESS>:5589/stream?url=http://kayosports.com.au/browse&channel=espn2
Again, I'm no coder so if you're having issues I probably have next to no idea why, but if you let me know what is happening I may be able to help.
Notes:
- First time you load a channel you'll obviously have to be at your computer to then sign in. After that you should be good to load up to the Max of 2 streams at once
- Streams work better if your computer is set to 100 or 50 fps/hz in my experience - was getting dropped frames at 120.
- Channels seems to load between 6-10seconds and may have a couple of stutters for the first 5secs or so.
- Sometimes (rarely) streams will load at a lower resolution, unsure why and assuming thatās just a kayo website error. Just quit the stream and try to reload
- Sometimes after closing the terminal running the main.js or the executable if you made one, I would have to close the process on port 5589 via terminal - if you're having issues re-opening (quirk of CC4C I think).
