Developing the undocumented: Jellyfin client on a Apple TV 2/3rd gen

Ah, my beloved Apple TV Gen 3. A device I once saved up for a long, long time, and held onto for all the history we shared. Making my first not so smart tv's allot smarter. okay it’s not that deep, but you get the picture. haha!
So what’s this story about? Let’s rewind two years, back to when I wrote my very first programs in Go as part of my self-teaching lifelong programming journey. Two years ago was also the first time I used AI/LLM's for tackling complex problems and asking for “code optimization advice” etc.
To push this learning-journey I was searching for a challenge; something impractical enough to meet my own funny standards.
That’s when I rediscovered my old Apple TV Gen 3. Long retired after Netflix abandoned its legacy ATV App, it had been gathering dust. But I wondered: could I make it relevant to me again? So what did i come up with? Being able to stream my Jellyfin catalog to this little guy. And that thought started this holiday project.
A bit of research later led me to PlexConnect, a project that managed to bring Plex to this generation Apple TV. That became my starting point, and also the gateway into some truly dreadful code of my own. So, let’s dig in together through the quirks, the hacks, weird front and backend choices and my overambitious plans that (spoiler) I didn’t quite manage to finish over a single holiday break. :(
Entrypoint & Go-code
Writing about this is going to be hard, I honestly can’t remember anymore what I figured out on my own and what came from late-night research (others). What I did learn is that most apps on the Apple TV point to a JavaScript file as their entry point, and XML for the menu's etc. So, I went hunting and collected all the ones still floating around online as references for how they were built. Thankfully, JavaScript and XML files are clear, nice and readable, which made that part easy.
From there, I came up with a simple base setup for my own experiment:
- A DNS server to “hijack” the requests.
- A web server to serve my tests JavaScript.
Doing that in GoLang made this easy, both things i wanted are available as packages and good documented.
Looking back, it’s funny how much I tried to push into JavaScript especially since, let’s be honest, my JavaScript skills are… not great. Bad even if i may say so myself. But my goal was that most things are done client-sided instead of by the Go App.

So now let's go through the GoLang code:
For the DNS server, I used the miekg/dns package, which made things surprisingly straightforward. The idea was simple: hijack any of the target domains and redirect them to the current app host’s IP. I nicknamed this host JCATHOST.DNS.
And yes—the project itself needed a name too. So I went with JellyCAT: Jellyfin Client for Apple TV. (now you understand why jcathost.dns, it's so my "static" XML/JS ref could reach the resources from the host server)
func shouldHijack(name string, qtype uint16) bool {
return (name == config.HijackApp || name == config.HijackImg || name == "jcathost.dns." || name == "jellyfin.dns.") && (qtype == dns.TypeA || qtype == dns.TypeAAAA)
}
You see config.HijackApp
? That’s because I was smart enough to implement a config system early on. This made it really easy so I didn’t have to recompile after every test or when changing the app domain I wanted to hijack. The config system was created by using the package BurntSushi/toml which would read the settings.cfg file and load the "settings". Example of the settings file from my repository:
# /\_/|
# { ' ' } JellyCAT
# \____\
# Edit your custom settings here
# JellyCAT Settings
# dns_server_en = Enable DNS Server (true) | Disable DNS Server (False)
# Enable WEBServer = Enable WEB Server (true) | Disable WEB Server (False)
dns_server_en = true
web_server_en = true
# DNS Settings
# hijack_ip = the ip address that the dns server sends out as the A record, make this your JellyCAT Host
# hijack_app = the main domain we want to use to hijack, we use this domain to intercept DNS
# hijack_img = the domain we intercept for the imaging (branding) of the app we want to hijack (this function is currently removed)
# forward_ip = the ip address of an dns server we want to forward uninteresting DNS requests to
# forward_port = the port of an dns server we want to forward uninteresting DNS requests to
hijack_ip = "192.168.11.23"
hijack_app = "atv.qello.com."
hijack_img = "notused.com."
forward_ip = "1.1.1.1"
forward_port = "53"
# WEBServer Settings
# https_port = the port you want to open for the https webserver with the self signed certificate
# http_port = the port you want to open for the http webserver (edit for use with reverse proxy)
# jellyfin_url = JellyCAT has a simple reverse proxy built in for setting a secure jellyfin server behind http://jellyfin.dns
https_port = ":443"
http_port = ":80"
jellyfin_url = "https://demo.jellyfin.org/stable"
# CERTGEN Settings
# common_name = hostname for generating certificate
common_name = "atv.qello.com"
With detailed explanations/comments what every setting does. And yes, don't get ahead to far, i will explain the rest.
The web server handles the rest of the important pieces, like serving the JavaScript files and the certificate. Yup, certificate; since (nearly) every app has its entry point over https://
, SSL was a must. Thankfully, Apple TV allows adding custom profiles; without that, getting it to work would have been a real headache. if not even impossible.

I wanted an easy way to generate this certificate, so i added that as an function. A complete copy-paste, where i cant find the original source from anymore... sorry!
While we’re here, at this function, that is part of the main app that takes care of everything. It lets me view the logs and run commands in a command-prompt-like environment. And is loaded at main, that loads the rest:


View all the Go-code and functions in the repository, including the settings endpoint & app log listner: https://github.com/SEPPDROID/JellyCAT/tree/master
Javascript & XML
Alright, let’s dive into the JS app and the different ways I experimented with logic & generating XML menus.
The JCAT server serves the full app folder, which makes the app I was using try to reach atv.qello.com/application.js
this gets intercepted and JCAT serves our JS file. (you can find this here in the repository). However, different apps have different entry points, so simply changing the app URI doesn’t guarantee it will work.
For example, when I tested with the Vevo app today (because it looks like Qello removed its ATV app), I had to move application.js
to \appletv\js\application.js
. This worked fine because the rest of the resources were fetched from the host specified by the server through the Settings API endpoint:

Example of JS XML menu url: atvutils.loadURL("https://" + atv.jcathost.SigHost + "/xml/home.xml");
Oh yes, I forgot to mention, the server (or JCATHOST app, as I liked to call it) has two API-like endpoints. One is for providing data to the application to be saved and used in the JS app, and the other is for sending or specifically receiving logs.
Let me explain:
When the JS loads it does the following, atv.onAppEntry
gets fired and we first fetch the settings and overwrite console.log. I did this because we are unable to debug on the Apple TV itself:
atv.onAppEntry = function () {
fetchSettings(function() {
overrideConsoleLog();
console.log("Successfully overwritten console log, sending logs to JCATHOST now.")
console.log("Received ATVCSETTINGS From JCATHOST. And starting JellyCAT-APP-JS on AppleTV...");
jcatMain();
});
}
Then we load jcatMain
The functions:
// ***************************************************
// JellyCAT Logger | Jclogger
// Function to override console.log, console.error, and console.warn and send logs to the JellyCAT stHack server
function overrideConsoleLog() {
var originalConsoleLog = console.log;
var originalConsoleError = console.error;
var originalConsoleWarn = console.warn;
console.log = function () {
// Call the original console.log
originalConsoleLog.apply(console, arguments);
// Send the log to the server
logToServer("LOG: " + JSON.stringify(arguments));
};
console.error = function () {
// Call the original console.error
originalConsoleError.apply(console, arguments);
// Send the error to the server
logToServer("ERROR: " + JSON.stringify(arguments));
};
console.warn = function () {
// Call the original console.warn
originalConsoleWarn.apply(console, arguments);
// Send the warning to the server
logToServer("WARNING: " + JSON.stringify(arguments));
};
}
// Function to log console information to the server
function logToServer(logData) {
var logEndpoint = "https://" + atv.jcathost.SigHost + "/log";
var logRequest = new XMLHttpRequest();
logRequest.open("POST", logEndpoint, true);
logRequest.setRequestHeader("Content-Type", "application/json");
var logPayload = {
timestamp: new Date().toISOString(),
logData: logData
};
logRequest.send(JSON.stringify(logPayload));
}
// ***************************************************
// JellyCAT Host fetcher
// Function to fetch information we need from the host server
// fetch JSON from the HTTP URL using XMLHttpRequest
function fetchSettings(callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://jcathost.dns/atvcsettings', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
// Store all properties in atv.jcathost
atv.jcathost = {
SigHost: data.sig_host,
SigHostPort: data.sig_host_p,
HostIP: data.host_ip,
System: data.system,
Version: data.version,
HelloMessage: data.hello,
// Add other properties as needed
};
// Execute the callback after setting atv.jcathost
callback();
} catch (jsonError) {
console.error('Error parsing JSON:', jsonError);
}
} else {
console.error('Error fetching settings. Status:', xhr.status);
}
}
};
xhr.send();
}
// ***************************************************
// JellyCAT Main | Main JS app function
// Help I'm even worse at JS
function jcatMain(){
atvutils.loadURL("https://" + atv.jcathost.SigHost + "/xml/home.xml");
}
All logging received on the JCATHOST server go to the endpoint on logEndpoint = "https://" + atv.jcathost.SigHost + "/log";
And get displayed in the console:

The rest of the helpers come from:
// ***************************************************
// ATVUtils - a JavaScript helper library for Apple TV
// Copied & Edited in full from:
// https://kortv.com/appletv/js/application.js
// https://github.com/wahlmanj/sample-aTV/blob/master/js/application.js
Which i added later and already has most of the functionality i tried creating & copying from various locations, making allot of my work unnecessary & double.
At the end, there’s a line console.log('EOF!');
to indicate that the script reached its end. I originally added this for debugging; to make sure it didn’t break somewhere and actually ran through to completion/ the end. It’s no longer touched as by design of the functions.
That's the important stuff of the client app, rest can be found in the repository here: https://github.com/SEPPDROID/JellyCAT/tree/master/app


Video Showcases
Here’s a showcase video of the app in action, and how far i had come. Unfortunately, it’s not using the built-in reverse proxy, since that was timing out, and i didn't feel like figuring out why. Instead, I opened a direct connection on my main network to the Jellyfin server.
Before i record the video's let's first wipe the Apple TV to get a clean playground:






Not many devices have that many progress bars
Then we'll go to the network settings and set a manual DNS server.

Then we add the certificate to the profiles to make SSL work.
You can activate profiles on this Apple TV by clicking the pause/play button when on the menu option of sending data to apple:
Looking back, i should've changed it to "host.jcat/cert" or even shorter to make it easier and faster to type

When you open the hijacked app, you’re greeted with a custom home screen. At this point, only the search tab and the settings screen have any real logic. The rest of the screens aren’t available and just throw the default “appname” not available error. In the settings screen you can set the server and authentication. When checked and authenticated it unlocks the search screen.
Any error's are also implemented:


We also let the Jellyin server know about this client connection:


Apple TV devicename & client
Library screens waiting for a valid configuration:


and debugging tools:
I think that's everything that is currently showable, good chance that i forgot something...
End
Right now, it’s not really a usable app but more of a gimmick, or maybe just a reference/base point for others to draw inspiration from or build on top of. Looking back, there are plenty of things I would’ve done differently. But I felt like I had to revisit this project one more time, just to play around with it and properly document it. I’m not sure why I didn’t do that when I first stopped working on it. But it was definitely a good project to kick-start my GoLang learning journey!
Thanks for reading, even if my writing here wandered all over the place.
Proost,