Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@Vigrond
Last active April 20, 2024 18:31
Show Gist options
  • Star 22 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Vigrond/1de5fc5ff468a48f053fd455a69c8766 to your computer and use it in GitHub Desktop.
Save Vigrond/1de5fc5ff468a48f053fd455a69c8766 to your computer and use it in GitHub Desktop.
Jellyfin with Chromecast

Setting up Jellyfin and Chromecast using Docker, Nginx, and dnsmasq

1/03/2023

Introduction

This will serve as a guide for setting up Jellyfin Web UI with Chromecast. This has proven to be a challenge because:

  • Chromecast requires that HTTP traffic be encrypted into HTTPS with a valid certificate.
  • Chromecast is hard coded with google DNS servers (8.8.8.8,8.8.4.4)
  • Basically, Chromecast assumes that you are a 3rd party website hosted on the public internet somewhere like Youtube. Which you are not, you are a local server hosting Jellyfin. This guide will make your Jellyfin server appear as such.

The above points introduce some challenges such as:

  • Creating a valid signed HTTPS certificate for a public domain name
  • Making the above certificate work properly on a local network
  • Rerouting Google DNS addresses to our own internal DNS server
  • Creating a Content-Security-Policy so browsers do not block cross origin Chromecast js scripts

Requirements:

  • A valid public domain name and access to its DNS records. This guide uses a domain registered on domains.google.com. Creating a subdomain / CNAME in your DNS settings is NOT necessary.
  • A router that allows static routing settings (most modern routers).
  • An internal DNS server on your local network. This guide uses an Ubuntu laptop as the DNS server, NGINX proxy server, and Jellyfin server.

Guide Specific Requirements:

This guide uses a specific setup that may or may not apply to your environment.

  • Ubuntu 22.04 for hosting Jellyfin, a DNS server, and an NGINX proxy
  • Jellyfin 10.8.8
  • Docker 20.10.17 and docker-compose 1.29.2 for containerization of jellyfin and NGINX
  • dnsmasq DNS server

Installing Docker

Follow instructions here https://docs.docker.com/desktop/install/ubuntu/ and ensure the test script works. Consider linking docker-compose (vs using docker compose without the dash) for accuracy of the guide.

Installing Jellyfin and Nginx

We will setup a docker compose file to handle Jellyfin and NGINX

Create the necessary directories:

mkdir -p ~/projects/jellyfin && cd ~/projects/jellyfin && mkdir -p cache config media nginx

This will create the jellyfin folder in a projects folder in your home directory. It will also create the necessary docker volume folders for jellyfin and nginx.

Create the Nginx Dockerfile

cat <<"EOT" >> ~/projects/jellyfin/nginx/Dockerfile
FROM nginx:latest

RUN apt-get update
RUN apt-get install -y certbot
EOT

This creates an Nginx container with certbot installed.

Create the Nginx configuration file

This config will need to be updated:

  • Replace mentions of yourdomain.com with your actual domain name.
  • Once jellyfin is running, update the Content-Security-Policy header with the appropriate cast_sender.js links. If cast_sender.js is not loading, it will be obvious in the console errors in the jellyfin web ui. (will remind again at the end of the guide). These cast_sender.js links may need to be updated as jellyfin is updated. (This will work without add_header Content-Security-Policy, but do you trust code running on your local network that much?)

Copy the below config into ~/projects/jellyfin/nginx/nginx.conf.

    # nginx config Borrowed from https://jellyfin.org/docs/general/networking/nginx/

    user       nginx;  ## Default: nobody
    worker_processes  1;  ## Default: 1
    pid        /tmp/nginx.pid;

    events {
      worker_connections  1024;  ## Default: 1024
    }

    http {
      index    index.html index.htm index.php;

      server {
          listen 80;
          listen [::]:80;
          server_name jellyfin.yourdomain.com;

          # Uncomment to redirect HTTP to HTTPS
          return 301 https://$host$request_uri;
      }

      server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;

        server_name jellyfin.yourdomain.com;

        ## The default `client_max_body_size` is 1M, this might not be enough for some posters, etc.
        client_max_body_size 20M;

        ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
        add_header Strict-Transport-Security "max-age=31536000" always;
        ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem;
        ssl_stapling on;
        ssl_stapling_verify on;

        # Security / XSS Mitigation Headers
        # NOTE: X-Frame-Options may cause issues with the webOS app
        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-XSS-Protection "1; mode=block";
        add_header X-Content-Type-Options "nosniff";

        # Content Security Policy
        # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
        # Enforces https content and restricts JS/CSS to origin
        # External Javascript (such as cast_sender.js for Chromecast) must be whitelisted.
        # NOTE: The default CSP headers may cause issues with the webOS app
        # NOTE 2:  cast_sender.js links may need to be updated.  Check jellyfin console for load errors
        add_header Content-Security-Policy "default-src https: data: blob: http://image.tmdb.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com/cv/js/sender/v1/cast_sender.js https://www.gstatic.com/eureka/clank/108/cast_sender.js https://www.gstatic.com/eureka/clank/107/cast_sender.js https://www.gstatic.com/eureka/clank/cast_sender.js https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'";

        location = / {
            return 302 http://$host/web/;
            #return 302 https://$host/web/;
        }

        location / {
            # Proxy main Jellyfin traffic
            proxy_pass http://jellyfin:8096;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Protocol $scheme;
            proxy_set_header X-Forwarded-Host $http_host;

            # Disable buffering when the nginx proxy gets very resource heavy upon streaming
            proxy_buffering off;
        }

        # location block for /web - This is purely for aesthetics so /web/#!/ works instead of having to go to /web/index.html/#!/
        location = /web/ {
            # Proxy main Jellyfin traffic
            proxy_pass http://jellyfin:8096/web/index.html;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Protocol $scheme;
            proxy_set_header X-Forwarded-Host $http_host;
        }

        location /socket {
            # Proxy Jellyfin Websockets traffic
            proxy_pass http://jellyfin:8096;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Protocol $scheme;
            proxy_set_header X-Forwarded-Host $http_host;
        }
      }

    }

Create the docker-compose file

This config utilizes hardware acceleration as described here https://jellyfin.org/docs/general/administration/hardware-acceleration#hardware-acceleration-on-docker-linux

This will enable jellyfin to use a GPU to do encoding and decoding.

In this example, I have an NVIDIA Quadro card I want to take advantage of, so I have installed the nvidia container toolkit documented here https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#getting-started

If you do not want to utilize hardware acceleration, remove the deploy tree in the config

Update the environment variable JELLYFIN_PublishedServerUrl in this configuration to match your domain name.

Copy the below config into ~/projects/jellyfin/docker-compose.yml.

version: '3.5'
services:
  web:
    build: nginx
    container_name: jellyfin_web
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/letsencrypt:/etc/letsencrypt
      - ./nginx/ssl:/etc/nginx/ssl
    logging:
      driver: "json-file"
      options:
        max-size: "500k"
        max-file: "10"
  jellyfin:
    image: jellyfin/jellyfin
    container_name: jellyfin
    volumes:
      - ./config:/config
      - ./cache:/cache
      - ./media:/media
    # Optional - Hardware acceleration
    deploy:
      resources:
        reservations:
          devices:
            - capabilities: [gpu]
    restart: 'unless-stopped'
    # Optional - alternative address used for autodiscovery
    environment:
      - JELLYFIN_PublishedServerUrl=https://jellyfin.yourdomain.com

Create the SSL Certificate using certbot

First we need to run our docker-compose file. Nginx will automatically fail because it will not be able to find the SSL certificate specified in our nginx.conf. But we don't need to worry about that right now.

Go ahead and run docker-compose up while in the ~/projects/jellyfin directory

Once it loads, you'll probably see something like:

user@group:~/projects/jellyfin$ docker-compose up
Starting jellyfin_web ... done
Starting jellyfin     ... done
Attaching to jellyfin_web, jellyfin
web_1       | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
web_1       | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
web_1       | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
web_1       | 10-listen-on-ipv6-by-default.sh: info: IPv6 listen already enabled
web_1       | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
web_1       | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
web_1       | /docker-entrypoint.sh: Configuration complete; ready for start up
web_1       | 2023/01/04 09:46:55 [emerg] 1#1: cannot load certificate...
...

Let us spin up another nginx container and setup the SSL certificate.

Open up a new terminal and navigate to our project directory

cd ~/projects/jellyfin

Let us start a new nginx container with a bash prompt presented to us:

docker-compose run web /bin/bash

Now with certbot already installed in our Dockerfile, we can simply run:

certbot certonly --manual -d *.yourdomain.com --agree-tos -m youremail@address.com --preferred-challenges=dns

  • Replace yourdomain.com while preserving the *. wildcard in front of it.
  • Replace youremail@address.comwith your own email

This command will perform a DNS-01 challenge to validate you control your domain name:

Performing the following challenges:
dns-01 challenge for yourdomain.com

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record under the name
_acme-challenge.yourdomain.com with the following value:

917nbBrGIYv0WbvM0URhPvcWFKh5wpryZQtXtJfti_8

Before continuing, verify the record is deployed.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue

Create a custom DNS record

Now we need to login to our domain registrar and navigate to the DNS settings.

We will set a custom record using the info certbot is giving us:

Host name:
_acme-challenge.yourdomain.com

Type:
TXT

TTL:
1 Hour

Data:
917nbBrGIYv0WbvM0URhPvcWFKh5wpryZQtXtJfti_8

Every registrar is different and may require some fiddling. For example, with domains.google.com, we only want to put in _acme-challenge into the Host name field, as Google autofills the .yourdomain.com afterwards.

Once this is done, press Enter to continue.

If successful, certbot will place the created SSL certificates into /etc/letsencrypt.

Type exit to shut down the container and return to our project directory.

Verify server is running

Type docker ps to verify that jellyfin and jellyfin_web containers are running and the status is healthy. You may also look at the output of docker-compose up.

Set up our DNS server

We will use dnsmasq for our DNS service.

Keep in mind that if libvirtd is installed, it uses its own installation of dnsmasq. We will not be using this version, and install one that automatically sets up a service for us.

sudo apt-get install dnsmasq

You may receive and error message that the service failed to start on a specified address. This will be fixed in the next step.

Configuring dnsmasq

Open the dnsmasq configuration file:

sudo gedit /etc/dnsmasq.conf

We are presented with a well-commented configuration file. Luckily, we only need to worry about a few settings:

Ensure no-resolv is uncommented:

# If you don't want dnsmasq to read /etc/resolv.conf or any other
# file, getting its servers from this file instead (see below), then
# uncomment this.
no-resolv

Ensure DNS forwarding addresses are set. This example uses Quad9's DNS service:

# Add other name servers here, with domain specs if they are for
# non-public domains.
server=9.9.9.9
server=149.112.112.112

Set our jellyfin redirect address where 192.168.0.2 is replaced your jellyfin machine's local IP address. Static/Manual IP settings on your network configuration is highly recommended:

# Add domains which you want to force to an IP address here.
# The example below send any host in double-click.net to a local
# web-server.
address=/jellyfin.yourdomain.com/192.168.0.2

Set the ethernet interface. You can find yours by using ip -br -f inet address and looking for the name of the device that lists the correct IP.

(alternatively, you may set the listen-address)

Whatever IP is assigned to the device (or the listen-address) will be the local DNS server address.

# If you want dnsmasq to listen for DHCP and DNS requests only on
# specified interfaces (and the loopback) give the name of the
# interface (eg eth0) here.
# Repeat the line for more than one interface.
interface=eth0

Ensure bind-interfaces is uncommented:

# On systems which support it, dnsmasq binds the wildcard address,
# even when it is listening on only some interfaces. It then discards
# requests that it shouldn't reply to. This has the advantage of
# working even when interfaces come and go and change address. If you
# want dnsmasq to really bind only the interfaces it is listening on,
# uncomment this option. About the only time you may need this is when
# running another nameserver on the same machine.
bind-interfaces

Ensure you save the file after done editing.

Restart the dnsmasq service

service dnsmasq restart

Ensure it is active and running

service dnsmasq status

Block Chromecast's hardcoded DNS server

To block Chromecast's access to 8.8.8.8 and instead use your own DNS server you need to login to your router admin panel and find the Routing settings.

For a TP-Link AX1800 router, the route entry would look as follows:

Network Destination:
8.8.8.8

Subnet Mask:
255.255.255.255

Default Gateway (your router's address)
192.168.0.1

Interface
LAN

Description
Google DNS 1

Any device trying to access 8.8.8.8 is now redirected to your router.

Set your router's Primary DNS to your local DNS server

Now that 8.8.8.8 is blocked, set the router's Primary DNS server to your local DNS server address.

Now would be a good time to restart the Chromecast if it has been on.

Ensure Content-Security-Policy is correct

Start up your Chrome browser and ensure the dev tools are open. ( right click -> inspect )

In the Network tab ensure Disable Cache is enabled.

Switch to the Console tab and then enter in jellyfin.yourserver.com

This should automatically load https and display a secure connection (lock icon next to the address bar)

Once logged into your jellyfin dashboard, ensure the console log does not display any errors that look like:

Refused to load the script 'https://www.gstatic.com/eureka/clank/108/cast_sender.js' because it violates the following Content Security Policy directive...

If you do, you need to add the URL in the error to the add_header Content-Security-Policy line in your nginx.conf.

As mentioned before, this will work without add_header Content-Security-Policy, but do you trust the code running on your local server that much?

Configure jellyfin networking settings

Access the Networking settings by going to Dashboard - > Networking

Set LAN Networks to your local area network range with slash notation. Example: 192.168.0.0/24

Set Known Proxies to your NGINX Proxy server name, jellyfin.yourserver.com

Do not change any HTTPS settings, it is not necessary with the nginx proxy

Troubleshooting

jellyfin.yourserver.com isn't loading

Ensure docker-compose up has been ran in your project folder

Check docker logs docker logs jellyfin, docker logs jellyfin_web for any errors

Ensure your device's network settings is set to use your local DNS address. You can check using nmap: nmap jellyfin.yourserver.com and it should point to the correct local IP address.

Ensure the DNS server is running. service dnsmasq status

Ensure jellyfin's networking HTTPS settings are disabled


Google Cast isn't showing up in the cast list

Ensure you're running the site in Google Chrome or Chromium browsers

Ensure there are no errors loading cast_sender.js in the browser console logs

Ensure your Chromecast is plugged in and powered on.


I get an error saying Chromecast is unable to communicate with my jellyfin server

Your Chromecast likely still has access to the public 8.8.8.8 DNS server. Double check your router settings.

nmap 8.8.8.8 should fail


jellyfin acts like it is playing, but the screen is black

Check your supported bitrate. For example, my first generation Chromecast only supports bitrates up to 8 Mbps.

You can set the bitrate in user -> settings -> playback -> Google Cast Streaming Quality. Note: This seems to only change bitrate, not resolution as implied by the dropdown menu.

@bbklopfer
Copy link

Just a comment --- instead of playing with 8.8.8.8 on your home network, it also works to set a (fully-qualified) subdomain to point to the local address of your Jellyfin server (or reverse proxy). For my setup this was super easy, as I didn't need to modify any router settings, just add a DNS entry using my registrar's DNS tools.

So, something like jellyfin.myawesomeserver.net might resolve to 192.168.0.2, which only makes sense if you're plugged into your home network. This works fine with my Chromecast (gen 3).

Make sure that this device can't be accessed from the outside world via e.g. port forwarding. Even still, some folks might consider this a security issue, as it does give outsiders some insight into your home network; however, my feeling is if someone is at the point where my home subnet matters, my network is already completely compromised :)

@AndrewBedscastle
Copy link

@bbklopfer
This is genius!
Until today I did not know this is possible (Assigning private IP addresses to public A records)
Worked flawlessly and took 2 minutes to apply.

@asg0451
Copy link

asg0451 commented Feb 10, 2024

thanks for the guide, this works great on web. however, the iphone app doesnt like it -- error code NSURLErrorDomain on server connect. i see that nginx got hit at / and returned 302, but that's it. any advice?

@Shellfishgene
Copy link

I wonder how VLC works with Chromecast given all the requirements above? It seems to cast just fine without any of this, from PC or Android.

@asg0451
Copy link

asg0451 commented Mar 27, 2024

@Shellfishgene my guess is that vlc is streaming from your laptop/phone rather than from the server directly. does your phone get a little toasty when that's happening?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment