#!/bin/bash # Qar Install Script # Detects the OS, adds required repositories, and installs Qar. # # Usage: # curl -fsSL https://get.qar.lol | sudo bash # # Supports: Debian 12+, Ubuntu 22.04+, Fedora 40+, RHEL 9+, Rocky 9+, AlmaLinux 9+ set -euo pipefail # Where Qar's packages come from. # # Overridable so a fork, a mirror or a staging repository can be installed from # without editing this script. QAR_REPO_URL="${QAR_REPO_URL:-https://repo.qar.lol}" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' # Fedora releases older than this do not carry a Jellyfin build in RPM Fusion # that Qar can use. MIN_FEDORA=41 log() { echo -e "${GREEN}[qar]${NC} $*"; } warn() { echo -e "${YELLOW}[qar]${NC} $*"; } err() { echo -e "${RED}[qar]${NC} $*" >&2; } # Which Jellyfin suite to use, asked of the repository rather than assumed. # # This used to be a hardcoded list, and a hardcoded list is wrong in both # directions at once: Ubuntu 26.04 "resolute" is published by Jellyfin but was # not on the list, while "oracular" was on the list and has since been dropped. # The old fallback then made it worse by pairing a Debian codename with the # Ubuntu path -- repo.jellyfin.org/ubuntu bookworm, a URL that has never # existed -- which broke apt for everything else on the machine. # # Asking costs one HEAD request and cannot go stale. jellyfin_suite() { local distro="$1" want fallbacks c want=$(. /etc/os-release && echo "${VERSION_CODENAME:-}") if [ -n "$want" ] && curl -fsI -m 10 "https://repo.jellyfin.org/$distro/dists/$want/Release" >/dev/null 2>&1; then echo "$want" return 0 fi # This release is not published. Fall back to the newest one that is, for # THIS distribution -- never across to the other one's codenames. case "$distro" in ubuntu) fallbacks="noble jammy" ;; *) fallbacks="trixie bookworm bullseye" ;; esac for c in $fallbacks; do if curl -fsI -m 10 "https://repo.jellyfin.org/$distro/dists/$c/Release" >/dev/null 2>&1; then echo "$c" return 0 fi done return 0 # nothing usable; the caller decides what to do about it } # Require root if [ "$(id -u)" -ne 0 ]; then err "This script must be run as root (or with sudo)" exit 1 fi echo "" echo -e "${CYAN}╔══════════════════════════════════════════╗${NC}" echo -e "${CYAN}║ Qar Media System Installer ║${NC}" echo -e "${CYAN}╚══════════════════════════════════════════╝${NC}" echo "" # Detect OS detect_os() { if [ -f /etc/os-release ]; then . /etc/os-release OS_ID="$ID" OS_VERSION_ID="${VERSION_ID:-}" OS_ID_LIKE="${ID_LIKE:-}" else err "Cannot detect operating system (/etc/os-release not found)" exit 1 fi # Determine the package family case "$OS_ID" in debian|ubuntu|linuxmint|pop) PKG_FAMILY="deb" ;; fedora) PKG_FAMILY="rpm" RPM_VARIANT="fedora" ;; rocky|almalinux|centos|rhel|ol) PKG_FAMILY="rpm" RPM_VARIANT="el" ;; *) # Try ID_LIKE as fallback if echo "$OS_ID_LIKE" | grep -q "debian\|ubuntu"; then PKG_FAMILY="deb" elif echo "$OS_ID_LIKE" | grep -q "fedora\|rhel\|centos"; then PKG_FAMILY="rpm" if echo "$OS_ID_LIKE" | grep -q "fedora" && [ "$OS_ID" != "fedora" ]; then RPM_VARIANT="el" else RPM_VARIANT="fedora" fi else err "Unsupported distribution: $OS_ID" err "Qar supports Debian/Ubuntu, Fedora, RHEL/Rocky/AlmaLinux" exit 1 fi ;; esac log "Detected: $PRETTY_NAME ($PKG_FAMILY)" } # Stop the machine suspending, before anything slow starts. # # This runs here rather than being left to the package because the install # itself is the first thing long enough to trip an idle timer: several hundred # packages and the better part of a gigabyte, on a desktop-flavoured install # that sleeps after fifteen minutes of nobody touching the keyboard. A host # that suspends mid-transaction leaves dnf or apt half finished and the person # watching with a terminal that simply stopped. # # The package installs /opt/qar/qar-sleepctl, which makes this permanent and # reversible; what happens here is the same thing done early, and it is # idempotent, so the two agreeing costs nothing. prevent_sleep() { [ -d /run/systemd/system ] || return 0 log "Keeping this machine awake for the install..." systemctl mask --quiet \ sleep.target suspend.target hibernate.target hybrid-sleep.target \ suspend-then-hibernate.target 2>/dev/null || true mkdir -p /etc/systemd/logind.conf.d cat > /etc/systemd/logind.conf.d/90-qar-nosleep.conf << 'EOF' # Installed by Qar. Remove with `sudo /opt/qar/qar-sleepctl restore`. # # Qar serves media to other devices on the network, which it cannot do while # the host is asleep. These keep logind from suspending the machine on its own. [Login] IdleAction=ignore HandleLidSwitch=ignore HandleLidSwitchDocked=ignore HandleLidSwitchExternalPower=ignore EOF systemctl reload systemd-logind.service 2>/dev/null \ || systemctl kill -s HUP systemd-logind.service 2>/dev/null \ || true } # Open the ports a TV or phone on the LAN needs to reach Jellyfin, plus the # Qar web interface. QBittorrent's WebUI is deliberately left closed -- it is # bound to loopback and reached through Qar. configure_firewall() { command -v firewall-cmd >/dev/null 2>&1 || return 0 firewall-cmd --state &>/dev/null || return 0 log "Opening firewall ports for Jellyfin and the Qar web interface..." if firewall-cmd --get-services 2>/dev/null | grep -qw jellyfin; then firewall-cmd --permanent --add-service=jellyfin >/dev/null 2>&1 || true else # No jellyfin-firewalld on this system; open the ports directly. firewall-cmd --permanent --add-port=8096/tcp >/dev/null 2>&1 || true firewall-cmd --permanent --add-port=8920/tcp >/dev/null 2>&1 || true firewall-cmd --permanent --add-port=1900/udp >/dev/null 2>&1 || true firewall-cmd --permanent --add-port=7359/udp >/dev/null 2>&1 || true fi firewall-cmd --permanent --add-port=3000/tcp >/dev/null 2>&1 || true # 3001 is the backend. Jellyfin hands its address straight to a TV or phone # for the download-progress video, so playback devices must be able to reach # it, not just this machine. firewall-cmd --permanent --add-port=3001/tcp >/dev/null 2>&1 || true firewall-cmd --reload >/dev/null 2>&1 || true } # Put Jellyfin's database on a filesystem setting that suits it. # # Qar's own postinstall does this, but on a fresh install Qar is installed # before Jellyfin, so /var/lib/jellyfin does not exist yet and there is nothing # there to mark. Jellyfin is then created copy-on-write and its database # fragments from the very first library scan -- 44MB in 10,053 extents within # the hour, on a machine measured here. # # So it runs again once Jellyfin is on disk. It is a no-op when there is # nothing to fix, and on btrfs it is the difference between a database that # reads at disk speed and one that reads at a few MB/s. prepare_jellyfin_storage() { [ -x /opt/qar/qar-fsctl ] || return 0 log "Checking filesystem settings for the databases..." # Direct, because there is no sandbox to escape here: this script is already # running as root. The self-re-exec exists for when the backend calls this # through sudo and inherits ProtectSystem=strict. QAR_FSCTL_DIRECT=1 /opt/qar/qar-fsctl repair || true } # Install btm, the system monitor worth having when this host feels slow. # # Every performance problem Qar has had looked the same from the outside: the # machine feels slow and the CPU is idle. Telling that apart from a real CPU # problem means watching IO pressure and per-process disk rates while it # happens, which `top` does not show and btm does. # # It is not in Fedora's repositories and the COPR that used to carry it is no # longer kept current, so it comes from the project's own GitHub release -- # the same way the machine this was modelled on got it. # # Entirely best-effort. A monitoring tool failing to install is not a reason # for a media server install to fail, so every path here ends in a warning at # worst. install_system_monitor() { command -v btm >/dev/null 2>&1 && return 0 local api=https://api.github.com/repos/ClementTsang/bottom/releases/latest local tag asset url tmp tag=$(curl -fsSL -m 20 "$api" 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) [ -n "$tag" ] || { warn "Could not reach GitHub for btm; skipping it."; return 0; } case "$PKG_FAMILY" in rpm) asset="bottom-${tag}-1.x86_64.rpm" ;; deb) asset="bottom_${tag}-1_amd64.deb" ;; *) return 0 ;; esac url="https://github.com/ClementTsang/bottom/releases/download/${tag}/${asset}" log "Installing btm ${tag} (system monitor)..." tmp=$(mktemp -d) if curl -fsSL -m 120 -o "$tmp/$asset" "$url" 2>/dev/null; then case "$PKG_FAMILY" in # Its own signing is not something this repository knows about, and the # download came over TLS from the project's release page. rpm) dnf install -y --nogpgcheck "$tmp/$asset" >/dev/null 2>&1 || warn "btm did not install; skipping." ;; deb) DEBIAN_FRONTEND=noninteractive apt-get install -y "$tmp/$asset" >/dev/null 2>&1 || warn "btm did not install; skipping." ;; esac else warn "Could not download btm; skipping it." fi rm -rf "$tmp" } # Give this host a disk swapfile behind zram. # # zram cannot be bigger than the RAM it lives in, so a host with only zram has # no answer to a burst that exceeds it except the OOM killer -- and ffmpeg, a # Jellyfin scan, a Discover build and a torrent verify can overlap on a machine # sized for none of them at once. ensure_swapfile() { [ -x /opt/qar/qar-swapctl ] || return 0 /opt/qar/qar-swapctl ensure || true } # Jellyfin runs as its own user and has to read the library Qar writes. grant_jellyfin_media_access() { getent passwd jellyfin >/dev/null 2>&1 || return 0 getent group qar >/dev/null 2>&1 || return 0 usermod -aG qar jellyfin 2>/dev/null || true # Group traversal down to the library. chmod 755 /qar /qar/content 2>/dev/null || true if systemctl is-active jellyfin &>/dev/null; then systemctl restart jellyfin 2>/dev/null || true fi } # --- Debian/Ubuntu --- install_deb() { log "Updating package lists..." apt-get update -qq # Install prerequisites log "Installing prerequisites..." apt-get install -y -qq curl gnupg apt-transport-https > /dev/null # Add Jellyfin repository if ! [ -f /usr/share/keyrings/jellyfin.gpg ]; then log "Adding Jellyfin repository..." local repo_distro case "$OS_ID" in ubuntu|linuxmint|pop) repo_distro="ubuntu" ;; *) repo_distro="debian" ;; esac local codename codename=$(jellyfin_suite "$repo_distro") if [ -z "$codename" ]; then # Adding a source that 404s does not degrade gracefully: apt-get update # fails outright, and every other repository on the machine -- including # Qar's -- stops working with it. Better to install without Jellyfin and # say so than to leave a box where apt itself is broken. warn "Jellyfin publishes nothing for $repo_distro/$(. /etc/os-release && echo "$VERSION_CODENAME")." warn "Skipping the Jellyfin repository; install Jellyfin separately." else curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/$repo_distro $codename main" \ > /etc/apt/sources.list.d/jellyfin.list fi else log "Jellyfin repository already configured" fi # Add Qar repository # # The source list is checked as well as the key, not just the key. Qar's # packages moved hosts, and a machine installed before the move has a key on # disk but a source pointing somewhere that no longer publishes -- so keying # this on the key alone would leave exactly the machines that need migrating # as the ones that never get it. Re-running the installer moves them over. if ! grep -qsF "$QAR_REPO_URL/deb" /etc/apt/sources.list.d/qar.list; then log "Adding Qar repository ($QAR_REPO_URL)..." curl -fsSL "$QAR_REPO_URL/KEY.gpg" | gpg --dearmor --yes -o /usr/share/keyrings/qar.gpg echo "deb [signed-by=/usr/share/keyrings/qar.gpg] $QAR_REPO_URL/deb stable main" \ > /etc/apt/sources.list.d/qar.list else log "Qar repository already configured" fi # Install Qar (pulls in all deps except Jellyfin) log "Updating package lists..." apt-get update -qq log "Installing Qar and dependencies..." DEBIAN_FRONTEND=noninteractive apt-get install -y qar # Install Jellyfin from its official repo (added above) if ! dpkg -l jellyfin-server 2>/dev/null | grep -q '^ii'; then log "Installing Jellyfin..." DEBIAN_FRONTEND=noninteractive apt-get install -y jellyfin else log "Jellyfin already installed" fi prepare_jellyfin_storage grant_jellyfin_media_access ensure_swapfile install_system_monitor } # Replace Fedora's cut-down ffmpeg with the full one from RPM Fusion. # # Fedora ships `ffmpeg-free`, the build with the patent-encumbered parts taken # out, and a Workstation install already has it because the desktop pulls it # in. The problem is not that it lacks codecs Qar wants: it *conflicts* with # the full `ffmpeg` that both Qar and Jellyfin depend on, so on any desktop # Fedora the transaction fails to resolve before a single package is fetched. # # What that failure looks like is worth knowing, because it does not read as an # ffmpeg problem. dnf reports a dozen lines about jellyfin-server requiring # `ffmpeg >= 7.1`, quietly says "Skipping packages with broken dependencies: # qar", and exits having installed nothing -- with `ffmpeg-free` named only in # the middle of the list, as one conflict among many. # # `dnf swap` is the step RPM Fusion documents for exactly this. It has to run # before anything that requires ffmpeg is asked for, which is why it is here # rather than left to the package's own dependencies to sort out. # # Containers do not show this. A minimal Fedora image has no ffmpeg-free, so # the plain `ffmpeg` installs cleanly and the install passes -- which is how # this reached real hardware without being caught. ensure_full_ffmpeg() { rpm -q ffmpeg-free &>/dev/null || return 0 rpm -q ffmpeg &>/dev/null && return 0 log "Replacing Fedora's ffmpeg-free with the full ffmpeg from RPM Fusion..." log " ffmpeg-free conflicts with it, and Jellyfin will not install without it." # --allowerasing because the swap takes the whole libav*-free family with it; # they are replaced by the equivalents from RPM Fusion in the same # transaction, so nothing is left without a provider. if ! dnf swap -y --allowerasing ffmpeg-free ffmpeg; then warn "dnf swap did not work; trying a direct install instead." dnf install -y --allowerasing ffmpeg fi } # Install Qar, or move an existing install up to the published version. # # `dnf install qar` is not enough on its own: on a machine that already has Qar # it prints "Package qar-1.0.8-1.x86_64 is already installed. Nothing to do." # and exits 0, whatever newer version the repository is offering. So re-running # the installer to pick up a fix -- which is the thing anyone with a broken # install tries first, and which this script tells them to do -- quietly left # them on the version they started with, having apparently succeeded. # # apt does not behave this way, which is why only the rpm side needs it. install_or_upgrade_qar() { if rpm -q qar &>/dev/null; then log "Qar is already installed; upgrading it to the published version..." dnf upgrade -y --refresh qar else log "Installing Qar and dependencies..." dnf install -y --refresh qar fi } # --- Fedora --- install_rpm_fedora() { local fedora_version fedora_version=$(rpm -E %fedora) if [ "$fedora_version" -lt "$MIN_FEDORA" ] 2>/dev/null; then err "Fedora $fedora_version is not supported. Qar requires Fedora $MIN_FEDORA or newer." err "Older releases do not carry a Jellyfin build compatible with Qar." exit 1 fi # RPM Fusion supplies both a full ffmpeg and Jellyfin itself on Fedora. if ! rpm -q rpmfusion-free-release &>/dev/null; then log "Adding RPM Fusion repository..." dnf install -y "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-${fedora_version}.noarch.rpm" else log "RPM Fusion already configured" fi ensure_full_ffmpeg setup_qar_rpm_repo install_or_upgrade_qar # Jellyfin from RPM Fusion. jellyfin-firewalld ships the service definition # so a TV on the LAN can reach the server. if rpm -q jellyfin-server &>/dev/null; then log "Jellyfin already installed" else log "Installing Jellyfin from RPM Fusion..." dnf install -y jellyfin jellyfin-firewalld fi systemctl enable --now jellyfin 2>/dev/null || true configure_firewall prepare_jellyfin_storage grant_jellyfin_media_access ensure_swapfile install_system_monitor } # --- RHEL / Rocky / AlmaLinux (EL9+) --- install_rpm_el() { local el_version el_version=$(. /etc/os-release && echo "${VERSION_ID%%.*}") # Enable Node.js 20 module stream (EL9 defaults to Node 16 which is too old) log "Enabling Node.js 20 module stream..." dnf module reset nodejs -y 2>/dev/null || true dnf module enable nodejs:20 -y # EPEL (needed for qbittorrent-nox, tor) if ! rpm -q epel-release &>/dev/null; then log "Installing EPEL repository..." dnf install -y epel-release else log "EPEL already configured" fi # Enable CRB/PowerTools (needed for RPM Fusion deps) log "Enabling CRB repository..." /usr/bin/crb enable 2>/dev/null || dnf config-manager --set-enabled crb 2>/dev/null || true # RPM Fusion (needed for ffmpeg) if ! rpm -q rpmfusion-free-release &>/dev/null; then log "Adding RPM Fusion repository..." dnf install -y --nogpgcheck "https://mirrors.rpmfusion.org/free/el/rpmfusion-free-release-${el_version}.noarch.rpm" else log "RPM Fusion already configured" fi # EL does not ship ffmpeg-free as a rule, but a machine that has picked it up # from EPEL hits the same conflict Fedora does, and the check costs one rpm # query on the machines that have not. ensure_full_ffmpeg # Add Qar repository setup_qar_rpm_repo # Install Qar install_or_upgrade_qar # Install Jellyfin via portable tarball (RPM Fusion jellyfin package # requires ffmpeg >= 7.1 which is not available on EL9) install_jellyfin_portable configure_firewall prepare_jellyfin_storage grant_jellyfin_media_access ensure_swapfile install_system_monitor } # Add Qar DNF/YUM repository setup_qar_rpm_repo() { # Rewritten every time rather than skipped when present. # # This file is generated, not edited, and skipping it meant a setting added # here only ever reached machines installing for the first time -- so the # machines that had been running longest, and had most to gain from a fix, # were the ones that could never be told about it. That is how every existing # install kept dnf's 48-hour metadata expiry after it was corrected here. # # Re-importing the key is a no-op when it is already known. if [ -f /etc/yum.repos.d/qar.repo ]; then log "Refreshing the Qar repository configuration..." else log "Adding Qar repository ($QAR_REPO_URL)..." fi rpm --import "$QAR_REPO_URL/KEY.gpg" # gpgcheck covers each .rpm, repo_gpgcheck covers the metadata listing them. # Both matter: the first stops an unsigned package being installed, the # second stops a tampered index pointing at a different one. # Written through a quoted heredoc and then substituted, rather than letting # the shell expand the body directly. # # An unquoted heredoc expands backticks, and this file is mostly prose. The # previous version of this comment said "and `dnf update` telling them", and # that backtick pair ran dnf update as root during the install: on a machine # with nothing pending it printed "Nothing to do." into the middle of the # comment, and on a machine with updates waiting it would have blocked on a # confirmation prompt with no terminal to answer it, then written the whole # transaction table into a file dnf has to parse. Both machines here had the # damaged line, which is how it was found. # # Substituting afterwards means nothing in the body is ever interpreted, so a # comment can say whatever it needs to without being a hazard. cat > /etc/yum.repos.d/qar.repo << 'REPOFILE' [qar] name=Qar - Self-hosted media management baseurl=@QAR_REPO_URL@/rpm enabled=1 gpgcheck=1 repo_gpgcheck=1 gpgkey=@QAR_REPO_URL@/KEY.gpg # How long dnf may trust its cached copy of this repository's index. # # dnf's default is 48 hours, which for a distribution archive is sensible and # for this one is not: a fix published here is a fix somebody is waiting for, # and "dnf update" telling them they are current for two days afterwards is the # whole difference between shipping a fix and shipping it eventually. # # An hour costs one small signed index fetch, and only when something asks. metadata_expire=1h REPOFILE sed -i "s|@QAR_REPO_URL@|$QAR_REPO_URL|g" /etc/yum.repos.d/qar.repo } # Install Jellyfin from portable tarball (for RPM systems) install_jellyfin_portable() { # Skip if Jellyfin is already installed if systemctl is-active jellyfin &>/dev/null; then log "Jellyfin is already running" return fi if [ -f /opt/qar/install-jellyfin.sh ]; then log "Installing Jellyfin via portable installer..." bash /opt/qar/install-jellyfin.sh else warn "Jellyfin installer not found at /opt/qar/install-jellyfin.sh" warn "Install Jellyfin manually: https://jellyfin.org/downloads/" fi } # Detect and install detect_os prevent_sleep case "$PKG_FAMILY" in deb) install_deb ;; rpm) case "${RPM_VARIANT:-fedora}" in fedora) install_rpm_fedora ;; el) install_rpm_el ;; esac ;; esac # Check that what was installed actually runs, before saying it worked. # # A package manager returns as soon as it has finished writing files, which it # has, whether or not what it wrote can start. Everything above this point can # succeed on a machine where the backend dies on its first database query, and # the installer would still print a success banner and a URL that answers with # a connection refused. That is a bad five minutes for somebody who has just # run a script off the internet as root and has no idea which part to blame. # # So wait for it to answer. The backend reconciles its schema before it # listens, which on a fresh install is a few seconds and on a slow disk is # rather more, hence the generous deadline. verify_install() { local backend_ok=false local deadline=$((SECONDS + 300)) log "Waiting for Qar to start..." while [ $SECONDS -lt $deadline ]; do if curl -fsS -m 5 -o /dev/null http://127.0.0.1:3001/health 2>/dev/null; then backend_ok=true break fi sleep 3 done if [ "$backend_ok" = true ]; then log "Backend is answering." else warn "The backend did not answer on port 3001 within five minutes." warn " sudo systemctl status qar-backend.service" warn " sudo journalctl -u qar-backend.service -n 50" fi if curl -fsS -m 10 -o /dev/null http://127.0.0.1:3000/ 2>/dev/null; then log "Web interface is answering." else warn "The web interface did not answer on port 3000." warn " sudo journalctl -u qar-frontend.service -n 50" backend_ok=false fi # Say out loud whether this machine will receive fixes. An install that # works today and never updates again is the failure mode this whole # arrangement exists to prevent, and it is completely silent. if systemctl is-enabled qar-update.timer >/dev/null 2>&1; then log "Automatic updates are on (daily, around 4am)." else warn "Automatic updates are not enabled on this machine." warn " sudo systemctl enable --now qar-update.timer" fi [ "$backend_ok" = true ] } if ! verify_install; then echo "" echo -e "${YELLOW}Qar was installed, but it is not answering yet.${NC}" echo "" echo " The commands above will say why. Once it starts, finish setup at:" echo "" echo -e " ${CYAN}http://localhost:3000/setup${NC}" echo "" exit 1 fi # Re-running this script is the documented way to upgrade, so it lands on # machines that have been running for months as often as on new ones. Sending # somebody who has a working library off to the setup wizard is the wrong # instruction, and it makes an upgrade look like it reset something. SETUP_DONE=false if curl -fsS -m 5 http://127.0.0.1:3001/api/stats/system 2>/dev/null | grep -q '"setupCompleted":true'; then SETUP_DONE=true fi echo "" if [ "$SETUP_DONE" = true ]; then echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ Qar $(printf '%-7s' "$(cat /opt/qar/VERSION 2>/dev/null || echo '')") is up to date ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}" echo "" echo -e " ${CYAN}http://localhost:3000${NC}" echo "" echo " Updates install themselves nightly from now on; this" echo " script only has to be run again if that stops working." echo "" else echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ Qar installed successfully! ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}" echo "" echo -e " Finish setup in your browser:" echo "" echo -e " ${CYAN}http://localhost:3000/setup${NC}" echo "" echo " It walks you through choosing a VPN (or not), connecting" echo " Jellyfin, and adding your first movie. Nothing downloads" echo " until that is done." echo "" fi