Kayo for Channels

I dont have the exact url, but matthuisman has epg data for foxtel stuff

1 Like

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!

2 Likes

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.

1 Like

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!

1 Like

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!

1 Like

hi @tmm1 . I think we could unpin this topic :slight_smile:

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.

1 Like

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.

2 Likes

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.

1 Like

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).