#!/usr/bin/env bash
# Installs the Kinonode media server on Linux, as a service that starts at boot.
#
#   curl -fsSL https://kinonode.com/install.sh | sudo bash
#
# Options go after `bash -s --`:
#
#   curl -fsSL https://kinonode.com/install.sh | sudo bash -s -- --media /srv/films --yes
#
#   --media <dir>   Let the server read this folder (repeat for more).
#   --yes           Don't ask anything; accept the defaults.
#   --uninstall     Remove Kinonode. Your libraries and settings are kept.
#   --purge         With --uninstall, also remove them and the kinonode user.
#
# What it does:
#   - installs ffmpeg and the other tools the server needs
#   - downloads the latest server from Kinonode and checks its SHA-256
#   - creates a `kinonode` system user, which the server runs as
#   - puts the server in /usr/lib/kinonode, its data in /var/lib/kinonode and
#     its settings in /etc/kinonode/server.env
#   - adds the kinonode-server systemd service and the `kinonode` command
#   - gives the kinonode user read access to your media folders
#
# Running it again updates the server and keeps your data and settings.
#
# KINONODE_CONNECT_URL points it at another Connect, for testing.

set -euo pipefail

CONNECT_URL="${KINONODE_CONNECT_URL:-}"
INSTALLER_URL="${KINONODE_INSTALLER_URL:-https://kinonode.com/install.sh}"

SERVICE=kinonode-server
SVC_USER=kinonode
LIB_DIR=/usr/lib/kinonode
DATA_DIR=/var/lib/kinonode
CONF_DIR=/etc/kinonode
ENV_FILE=/etc/kinonode/server.env
FOLDERS_FILE=/etc/kinonode/folders
UNIT_FILE=/etc/systemd/system/kinonode-server.service
HELPER=/usr/bin/kinonode
DEFAULT_PORT=32600
MIN_GLIBC=2.34

ASSUME_YES=0
UNINSTALL=0
PURGE=0
MEDIA=()

# ---------------------------------------------------------------------------
# Output and prompts. Everything from here to `main` is also copied into the
# `kinonode` helper, so the installer and the helper share one copy of it.
# ---------------------------------------------------------------------------

if [ -t 1 ]; then BOLD=$'\e[1m' YELLOW=$'\e[33m' RED=$'\e[31m' RESET=$'\e[0m'
else BOLD='' YELLOW='' RED='' RESET=''; fi

say() { printf '%s\n' "$*"; }
step() { printf '\n%s==> %s%s\n' "$BOLD" "$*" "$RESET"; }
warn() { printf '%sWarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
die() { printf '%sError:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }

# The terminal to ask questions on. When the script is piped into bash its
# stdin is the script itself, so questions are read from /dev/tty instead.
# Empty with --yes, or when there's no terminal (then the defaults are used).
prompt_tty() {
  if [ "$ASSUME_YES" = 1 ]; then return 1; fi
  ( : </dev/tty ) 2>/dev/null
}

# ask "Question" → the answer, on stdout.
ask() {
  local answer=''
  read -r -p "$1" answer </dev/tty || true
  printf '%s' "$answer"
}

# confirm "Question" → true for yes. Without a terminal, the answer is $2 (no).
confirm() {
  if [ "$ASSUME_YES" = 1 ]; then return 0; fi
  if ! prompt_tty; then [ "${2:-no}" = yes ]; return; fi
  local answer
  answer="$(ask "$1 [y/N] ")"
  case "$answer" in [Yy] | [Yy][Ee][Ss]) return 0 ;; *) return 1 ;; esac
}

# need_root <command> [args]: stops with the sudo command to run instead.
need_root() {
  [ "$(id -u)" -ne 0 ] || return 0
  local cmd="sudo kinonode $1" arg
  shift
  for arg in "$@"; do
    case "$arg" in
      *[!A-Za-z0-9_./:=+-]*) cmd+=" \"$arg\"" ;;
      *) cmd+=" $arg" ;;
    esac
  done
  die "This needs root. Run it with sudo: $cmd"
}

# Runs a command as the kinonode user, to check what it can read.
# shellcheck disable=SC2016 # su's '"$0" "$@"' is expanded by the kinonode user's shell
as_kinonode() {
  if command -v runuser >/dev/null 2>&1; then runuser -u "$SVC_USER" -- "$@"
  elif command -v sudo >/dev/null 2>&1; then sudo -u "$SVC_USER" -- "$@"
  else su -s /bin/sh "$SVC_USER" -c '"$0" "$@"' "$@"
  fi
}

# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------

# A setting from server.env, if it's set there (and not commented out).
env_setting() {
  [ -r "$ENV_FILE" ] || return 0
  sed -n "s/^[[:space:]]*$1=//p" "$ENV_FILE" | tail -n 1 | sed -e 's/^["'\'']//' -e 's/["'\'']$//'
}

# The port the server listens on, from KINONODE_BIND (e.g. 0.0.0.0:32600).
server_port() {
  local bind
  bind="$(env_setting KINONODE_BIND)"
  bind="${bind##*:}"
  case "$bind" in '' | *[!0-9]*) printf '%s' "$DEFAULT_PORT" ;; *) printf '%s' "$bind" ;; esac
}

# Where to reach the server from this machine.
local_host() {
  local host
  host="$(env_setting KINONODE_BIND)"
  host="${host%:*}"
  host="${host#[}" host="${host%]}"
  case "$host" in '' | 0.0.0.0 | :: | localhost) printf '127.0.0.1' ;; *) printf '%s' "$host" ;; esac
}

# The server's health answer, e.g. {"status":"ok","version":"1.0.0"}, or nothing.
health() {
  local host
  host="$(local_host)"
  case "$host" in *:*) host="[$host]" ;; esac
  curl -fsS -m 3 "http://$host:$(server_port)/api/v1/health" 2>/dev/null || true
}

json_string() { # json_string <key>, reading JSON on stdin
  sed -n -E "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p" | head -n 1
}

# This machine's addresses on the local network, one per line.
lan_addresses() {
  local bind_host
  bind_host="$(local_host)"
  if [ "$bind_host" != 127.0.0.1 ]; then
    printf '%s\n' "$bind_host"
    return
  fi
  if command -v ip >/dev/null 2>&1; then
    ip -4 -o addr show scope global 2>/dev/null |
      awk '$2 !~ /^(docker|br-|veth|virbr|lxc|cni|podman|flannel)/ { split($4, a, "/"); print a[1] }'
  elif hostname -I >/dev/null 2>&1; then
    hostname -I | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true
  fi
}

print_urls() {
  local port addr found=0
  port="$(server_port)"
  while read -r addr; do
    if [ -n "$addr" ]; then say "    http://$addr:$port"; found=1; fi
  done < <(lan_addresses)
  [ "$found" = 1 ] || say "    http://<this machine's address>:$port"
  [ "$(local_host)" = 127.0.0.1 ] && say "    http://localhost:$port (on this machine)"
  return 0
}

# ---------------------------------------------------------------------------
# Media folders
#
# The server runs as the kinonode user, so it reads only what that user may.
# Granting a folder adds an ACL entry giving kinonode read access to it and
# everything in it, and a default entry so files added later are readable too.
# Each folder above it gets just enough (execute) for kinonode to get through.
# ---------------------------------------------------------------------------

# Folders the server must never be given, even by mistake.
refuse_reason() {
  local dir="$1" home
  case "$dir" in
    / | /bin | /boot | /dev | /etc | /home | /lib | /lib32 | /lib64 | /libx32 | /media | /mnt | /opt | /proc | \
      /root | /run | /run/media | /sbin | /srv | /sys | /tmp | /usr | /var | /var/lib | /var/tmp)
      printf 'it is a system folder'; return ;;
    /bin/* | /boot/* | /dev/* | /etc/* | /lib/* | /lib32/* | /lib64/* | /libx32/* | /proc/* | /root/* | \
      /sbin/* | /sys/* | /usr/* | /var/lib/kinonode | /var/lib/kinonode/* | /var/log/* | /var/cache/* | /var/spool/*)
      printf 'it is a system folder'; return ;;
    /run/media/*) ;;
    /run/*) printf 'it is a system folder'; return ;;
  esac
  # A whole home folder would include SSH keys and passwords.
  while IFS= read -r home; do
    if [ "$dir" = "$home" ]; then
      printf "it is someone's whole home folder; give it the media folder inside instead"
      return
    fi
  done < <(getent passwd | cut -d: -f6 | grep -v '^/$' || true)
}

# Advice for folders on a filesystem that can't take ACLs.
mount_advice() {
  local dir="$1" fstype target uid gid
  fstype="$(findmnt -n -o FSTYPE -T "$dir" 2>/dev/null || true)"
  target="$(findmnt -n -o TARGET -T "$dir" 2>/dev/null || true)"
  uid="$(id -u "$SVC_USER")" gid="$(id -g "$SVC_USER")"
  say "  $dir is on a ${fstype:-filesystem} mount ($target) that doesn't support"
  say "  per-user permissions. Mount it so the kinonode user can read it, and"
  say "  add it to /etc/fstab so it's there at boot. For example:"
  case "$fstype" in
    cifs | smb3)
      say ""
      say "    //nas/films  $target  cifs  credentials=/etc/kinonode/nas-login,uid=$uid,gid=$gid,file_mode=0644,dir_mode=0755,_netdev,nofail  0 0"
      say ""
      say "  (or keep your own uid= and use file_mode=0644,dir_mode=0755, which lets"
      say "  every user on this machine read it)." ;;
    nfs | nfs4)
      say ""
      say "    nas:/volume1/films  $target  nfs  ro,_netdev,nofail  0 0"
      say ""
      say "  NFS keeps the NAS's own permissions: on the NAS, make the files readable"
      say "  by everyone (folders 755, files 644), or map this machine to a user that"
      say "  can read them." ;;
    *)
      say ""
      say "    UUID=XXXX-XXXX  $target  ${fstype:-exfat}  uid=$uid,gid=$gid,umask=022,nofail  0 0"
      say ""
      say "  (find the UUID with: lsblk -f). umask=022 alone, with your own uid=, also"
      say "  works: it lets every user on this machine read the drive." ;;
  esac
  say "  Then remount it: sudo umount \"$target\" && sudo mount -a"
}

remember_folder() {
  mkdir -p "$CONF_DIR"
  touch "$FOLDERS_FILE"
  grep -qxF -- "$1" "$FOLDERS_FILE" || printf '%s\n' "$1" >>"$FOLDERS_FILE"
}

forget_folder() {
  [ -f "$FOLDERS_FILE" ] || return 0
  grep -vxF -- "$1" "$FOLDERS_FILE" >"$FOLDERS_FILE.new" || true
  mv -f "$FOLDERS_FILE.new" "$FOLDERS_FILE"
}

# grant_folder <dir>: lets the kinonode user read <dir>. Returns 1 if it can't.
grant_folder() {
  local dir reason parent acl_ok=1 errors
  if [ ! -d "$1" ]; then
    warn "$1 isn't a folder on this machine. Skipped."
    return 1
  fi
  dir="$(realpath -- "$1")"
  reason="$(refuse_reason "$dir")"
  if [ -n "$reason" ]; then
    warn "Not giving Kinonode $dir: $reason."
    return 1
  fi

  errors="$(mktemp)"
  if setfacl -m "u:$SVC_USER:rX" -- "$dir" 2>"$errors"; then
    # Everything inside, then default entries on folders for files added later.
    setfacl -R -m "u:$SVC_USER:rX" -- "$dir" 2>>"$errors" || acl_ok=0
    find "$dir" -type d -exec setfacl -d -m "u:$SVC_USER:rX" -- {} + 2>>"$errors" || acl_ok=0
    # Let kinonode through each folder above it, without reading them.
    parent="$(dirname -- "$dir")"
    while [ "$parent" != / ]; do
      if ! as_kinonode test -x "$parent"; then
        setfacl -m "u:$SVC_USER:x" -- "$parent" 2>>"$errors" || acl_ok=0
      fi
      parent="$(dirname -- "$parent")"
    done
    if [ "$acl_ok" = 0 ]; then
      warn "Some files in $dir couldn't be changed:"
      head -n 5 "$errors" | sed 's/^/  /' >&2
    fi
  else
    acl_ok=0
  fi
  rm -f "$errors"

  if as_kinonode test -r "$dir" -a -x "$dir"; then
    remember_folder "$dir"
    if [ "$acl_ok" = 1 ]; then say "  Kinonode can read $dir"
    else say "  Kinonode can read $dir (check the files inside are readable too)"; fi
    return 0
  fi
  warn "Kinonode still can't read $dir."
  mount_advice "$dir"
  return 1
}

# revoke_folder <dir>: removes what grant_folder added. Folders above it keep
# their execute-only entry, since other granted folders may need it.
revoke_folder() {
  local dir
  if [ ! -d "$1" ]; then
    forget_folder "$1"
    warn "$1 isn't a folder on this machine."
    return 1
  fi
  dir="$(realpath -- "$1")"
  setfacl -R -x "u:$SVC_USER" -- "$dir" 2>/dev/null || true
  find "$dir" -type d -exec setfacl -d -x "u:$SVC_USER" -- {} + 2>/dev/null || true
  forget_folder "$dir"
  say "  Kinonode can no longer read $dir (unless it's readable by everyone)."
}

# ---------------------------------------------------------------------------
# Uninstalling
# ---------------------------------------------------------------------------

uninstall() {
  local purge="$1" dir
  if [ "$purge" = 1 ] && ! confirm "Delete all Kinonode data and settings in $DATA_DIR and $CONF_DIR, and the kinonode user?"; then
    if prompt_tty; then die "Nothing was removed."; fi
    die "--purge deletes your Kinonode data. Run it again with --yes to confirm."
  fi

  step "Removing Kinonode"
  if [ -f "$UNIT_FILE" ]; then
    systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true
    rm -f "$UNIT_FILE"
    systemctl daemon-reload
    systemctl reset-failed "$SERVICE" >/dev/null 2>&1 || true
  fi
  rm -rf "$LIB_DIR"
  rm -f "$HELPER"
  say "  Removed the service, $LIB_DIR and $HELPER."

  if [ "$purge" = 1 ]; then
    if [ -f "$FOLDERS_FILE" ] && id "$SVC_USER" >/dev/null 2>&1; then
      while IFS= read -r dir; do
        [ -d "$dir" ] || continue
        revoke_folder "$dir" >/dev/null
        # And the pass-through entries on the folders above it.
        dir="$(dirname -- "$dir")"
        while [ "$dir" != / ]; do
          setfacl -x "u:$SVC_USER" -- "$dir" 2>/dev/null || true
          dir="$(dirname -- "$dir")"
        done
      done <"$FOLDERS_FILE"
      say "  Removed the kinonode user's access to your media folders."
    fi
    rm -rf "$DATA_DIR" "$CONF_DIR"
    if id "$SVC_USER" >/dev/null 2>&1; then userdel "$SVC_USER" 2>/dev/null || true; fi
    if getent group "$SVC_USER" >/dev/null 2>&1; then groupdel "$SVC_USER" 2>/dev/null || true; fi
    say "  Removed $DATA_DIR, $CONF_DIR and the kinonode user."
    say ""
    say "Kinonode is gone. Your media files weren't touched."
  else
    say ""
    say "Kinonode is uninstalled. Your libraries and settings are kept in $DATA_DIR"
    say "and $CONF_DIR, so installing again picks up where you left off."
    say "To remove those too:"
    say "  curl -fsSL https://kinonode.com/install.sh | sudo bash -s -- --uninstall --purge"
  fi
}

# ---------------------------------------------------------------------------
# Everything below is only for the installer.
# ---------------------------------------------------------------------------

usage() {
  local src="${BASH_SOURCE[0]:-}"
  if [ -n "$src" ] && [ -f "$src" ]; then
    sed -n '2,25p' "$src" | sed 's/^# \{0,1\}//'
  else
    say "Usage: curl -fsSL https://kinonode.com/install.sh | sudo bash -s -- [--media <dir>] [--yes] [--uninstall [--purge]]"
  fi
}

parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --media) [ $# -ge 2 ] || die "--media needs a folder."; MEDIA+=("$2"); shift ;;
      --media=*) MEDIA+=("${1#*=}") ;;
      -y | --yes) ASSUME_YES=1 ;;
      --uninstall) UNINSTALL=1 ;;
      --purge) PURGE=1 ;;
      -h | --help) usage; exit 0 ;;
      *) die "Unknown option: $1. Options: --media <dir>, --yes, --uninstall, --purge." ;;
    esac
    shift
  done
  if [ "$PURGE" = 1 ] && [ "$UNINSTALL" = 0 ]; then die "--purge only goes with --uninstall."; fi
}

require_root() {
  if [ "$(id -u)" -eq 0 ]; then return; fi
  local args=''
  if [ $# -gt 0 ]; then args=" -s --$(printf ' %q' "$@")"; fi
  say "The installer needs root to add a service and a user. Run it with sudo:"
  say ""
  say "  curl -fsSL https://kinonode.com/install.sh | sudo bash$args"
  exit 1
}

check_platform() {
  [ "$(uname -s)" = Linux ] || die "Kinonode's installer is for Linux. Windows and Mac apps are coming."
  [ -d /run/systemd/system ] || die "This machine doesn't use systemd, which the installer needs to run Kinonode as a service. Use Docker instead: https://kinonode.com/download"

  case "$(uname -m)" in
    x86_64 | amd64) ARCH=x86_64 ;;
    aarch64 | arm64) ARCH=aarch64 ;;
    *) die "Kinonode doesn't have a build for $(uname -m) yet. It runs on 64-bit Intel, AMD and ARM machines." ;;
  esac

  local glibc
  glibc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}' || true)"
  if [ -z "$glibc" ]; then
    glibc="$(ldd --version 2>&1 | head -n 1 | grep -oE '[0-9]+\.[0-9]+$' || true)"
  fi
  if [ -z "$glibc" ]; then
    die "This system doesn't use glibc, which the Kinonode server needs. Use Docker instead: https://kinonode.com/download"
  fi
  if [ "$(printf '%s\n%s\n' "$MIN_GLIBC" "$glibc" | sort -V | head -n 1)" != "$MIN_GLIBC" ]; then
    die "This system has glibc $glibc; the Kinonode server needs $MIN_GLIBC or newer (Ubuntu 22.04+, Debian 12+, Fedora 35+, RHEL 9+ or similar). Upgrade, or use Docker instead: https://kinonode.com/download"
  fi
}

# Installs the tools the server and this script use, with whichever package
# manager is here. ffmpeg is installed on its own so a failure there (some
# distributions need an extra repository for it) doesn't stop the rest.
install_dependencies() {
  local need=() pm=''
  command -v curl >/dev/null 2>&1 || need+=(curl)
  command -v tar >/dev/null 2>&1 || need+=(tar)
  command -v gzip >/dev/null 2>&1 || need+=(gzip)
  command -v setfacl >/dev/null 2>&1 || need+=(acl)
  if [ ! -e /etc/ssl/certs/ca-certificates.crt ] && [ ! -e /etc/pki/tls/certs/ca-bundle.crt ] &&
    [ ! -e /etc/ssl/ca-bundle.pem ] && [ ! -e /etc/ssl/cert.pem ]; then
    need+=(ca-certificates)
  fi
  local want_ffmpeg=0
  command -v ffprobe >/dev/null 2>&1 || want_ffmpeg=1
  if [ ${#need[@]} -eq 0 ] && [ "$want_ffmpeg" = 0 ]; then return; fi

  for pm in apt-get dnf yum pacman zypper ''; do
    if [ -n "$pm" ] && command -v "$pm" >/dev/null 2>&1; then break; fi
  done
  if [ -z "$pm" ]; then
    [ ${#need[@]} -eq 0 ] || die "Please install these first: ${need[*]}"
    warn "Couldn't find a package manager to install ffmpeg. Install it yourself; the server needs it to read your files."
    return
  fi

  local what="${need[*]}"
  if [ "$want_ffmpeg" = 1 ]; then what="${what:+$what }ffmpeg"; fi
  step "Installing $what"
  if [ "$pm" = apt-get ]; then
    DEBIAN_FRONTEND=noninteractive apt-get update -qq </dev/null >>"$WORK/packages.log" 2>&1 ||
      warn "apt-get update failed; trying anyway."
  fi
  if [ ${#need[@]} -gt 0 ] && ! pkg_install "$pm" "${need[@]}"; then
    tail -n 20 "$WORK/packages.log" >&2
    die "Couldn't install ${need[*]}. Install them, then run this again."
  fi
  if [ "$want_ffmpeg" = 1 ]; then
    if ! pkg_install "$pm" ffmpeg; then
      # Fedora's own repositories have ffmpeg-free; full ffmpeg is in RPM Fusion.
      case "$pm" in dnf | yum) pkg_install "$pm" ffmpeg-free || true ;; esac
    fi
    if ! command -v ffprobe >/dev/null 2>&1; then
      tail -n 10 "$WORK/packages.log" >&2
      warn "Couldn't install ffmpeg. The server needs it to read and play your files. Install it yourself (on Fedora and RHEL it comes from RPM Fusion), then run: sudo kinonode restart"
    fi
  fi
  return 0
}

# pkg_install <package manager> <package>...: output goes to packages.log.
pkg_install() {
  local pm="$1"
  shift
  say "  $*"
  case "$pm" in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$@" </dev/null ;;
    dnf | yum) "$pm" install -y -q "$@" </dev/null ;;
    pacman) pacman -S --needed --noconfirm "$@" </dev/null ;;
    zypper) zypper --non-interactive --quiet install "$@" </dev/null ;;
  esac >>"$WORK/packages.log" 2>&1
}

# Finds the download for this machine in Connect's latest release and puts the
# verified server binary at $WORK/kinonode-server. Sets NEW_VERSION.
download_release() {
  local json files file name sha url
  step "Checking the latest version"
  json="$(curl -fsSL --retry 3 --max-time 30 "$CONNECT_URL/api/v1/releases/latest" | tr -d '\r\n')" ||
    die "Couldn't reach Kinonode at $CONNECT_URL. Check this machine is online and try again."

  # The release is one JSON object with a "files" list of flat objects. Take the
  # version from outside that list, then pick this machine's file from it.
  NEW_VERSION="$(sed -E 's/"files"[[:space:]]*:[[:space:]]*\[[^]]*\]//' <<<"$json" | json_string version)"
  [[ "$NEW_VERSION" =~ ^[0-9A-Za-z.+-]+$ ]] || die "Kinonode hasn't published a server release yet."
  files="$(grep -oE '\{[^{}]*\}' <<<"$json" || true)"
  file="$(grep -E '"os"[[:space:]]*:[[:space:]]*"linux"' <<<"$files" |
    grep -E "\"arch\"[[:space:]]*:[[:space:]]*\"$ARCH\"" | head -n 1 || true)"
  [ -n "$file" ] || die "Kinonode $NEW_VERSION doesn't have a download for $ARCH Linux yet."

  name="$(json_string name <<<"$file")"
  sha="$(json_string sha256 <<<"$file" | tr 'A-F' 'a-f')"
  url="$(json_string url <<<"$file")"
  [[ "$sha" =~ ^[0-9a-f]{64}$ ]] || die "The release for $ARCH has no valid checksum."
  [[ "$url" =~ ^https?:// ]] || url="$CONNECT_URL/downloads/$name"
  [[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || name=kinonode-server.tar.gz

  if [ "$INSTALLED_VERSION" = "$NEW_VERSION" ] && [ -x "$LIB_DIR/kinonode-server" ]; then
    return
  fi

  say "Downloading Kinonode $NEW_VERSION ($ARCH)"
  local progress=(-sS)
  if [ -t 2 ]; then progress=(-#); fi
  curl -fL --retry 3 "${progress[@]}" -o "$WORK/$name" "$url" || die "The download failed. Try again in a minute."
  [ "$(sha256sum "$WORK/$name" | cut -d ' ' -f 1)" = "$sha" ] ||
    die "The download didn't match its SHA-256 checksum, so it wasn't installed. Try again."
  mkdir -p "$WORK/unpacked"
  tar -xzf "$WORK/$name" -C "$WORK/unpacked" --no-same-owner || die "Couldn't unpack the download."
  [ -f "$WORK/unpacked/kinonode-server" ] || die "The download doesn't contain kinonode-server."
}

create_user() {
  local nologin='' shell group
  for shell in /usr/sbin/nologin /sbin/nologin /usr/bin/nologin /bin/false; do
    if [ -x "$shell" ]; then nologin="$shell"; break; fi
  done
  if ! id "$SVC_USER" >/dev/null 2>&1; then
    step "Creating the kinonode user"
    if getent group "$SVC_USER" >/dev/null 2>&1; then
      useradd --system --gid "$SVC_USER" --home-dir "$DATA_DIR" --no-create-home --shell "$nologin" --comment "Kinonode media server" "$SVC_USER"
    else
      useradd --system --user-group --home-dir "$DATA_DIR" --no-create-home --shell "$nologin" --comment "Kinonode media server" "$SVC_USER"
    fi
  fi

  # video and render let it use the GPU for transcoding. It isn't added to
  # anyone's own group: that would let it read most of their home folder.
  # Media folders are granted one by one instead (grant_folder).
  local groups=() existing
  for group in video render; do
    getent group "$group" >/dev/null 2>&1 && groups+=("$group")
  done
  existing=" $(id -nG "$SVC_USER") "
  for group in "${groups[@]}"; do
    if [[ "$existing" != *" $group "* ]]; then
      usermod -aG "$group" "$SVC_USER"
      GROUPS_CHANGED=1
    fi
  done
}

write_env_file() {
  [ -f "$ENV_FILE" ] && return
  local tmp
  tmp="$(mktemp)"
  cat >"$tmp" <<'EOF'
# Settings for the Kinonode media server. Uncomment a line to change it, then
# run: sudo kinonode restart

# The address and port to listen on. Change the port if something else
# already uses 32600.
#KINONODE_BIND=0.0.0.0:32600

# The server's name, as people see it. Empty uses the name picked on the
# setup page.
#KINONODE_NAME=

# The relay lets apps reach this server from anywhere without opening ports
# on your router. Set to off to turn it off.
#KINONODE_RELAY=on
EOF
  if [ -n "${KINONODE_CONNECT_URL:-}" ]; then
    printf '\n# Where Kinonode Connect runs. Only for testing.\nKINONODE_CONNECT_URL=%s\n' "$CONNECT_URL" >>"$tmp"
  fi
  install -m 640 -o root -g "$SVC_USER" "$tmp" "$ENV_FILE"
  rm -f "$tmp"
}

# Writes $1 to $2 if it differs. Returns 0 when it changed.
write_if_changed() {
  if [ -f "$2" ] && cmp -s "$1" "$2"; then rm -f "$1"; return 1; fi
  mv -f "$1" "$2"
}

write_unit() {
  local tmp
  tmp="$(mktemp)"
  cat >"$tmp" <<'EOF'
[Unit]
Description=Kinonode media server
Documentation=https://kinonode.com/setup
After=network-online.target remote-fs.target local-fs.target
Wants=network-online.target

[Service]
Type=simple
User=kinonode
Group=kinonode
Environment=KINONODE_DATA_DIR=/var/lib/kinonode
Environment=KINONODE_INSTALL=native
Environment=KINONODE_OPEN_BROWSER=off
EnvironmentFile=-/etc/kinonode/server.env
ExecStart=/usr/lib/kinonode/kinonode-server
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes

[Install]
WantedBy=multi-user.target
EOF
  chmod 644 "$tmp"
  if write_if_changed "$tmp" "$UNIT_FILE"; then UNIT_CHANGED=1; fi
}

# The `kinonode` command: this script's shared functions, plus its own commands.
write_helper() {
  local tmp
  tmp="$(mktemp)"
  {
    printf '#!/usr/bin/env bash\n'
    printf '# The kinonode command, written by the Kinonode installer. Run: kinonode help\n'
    printf '# It is replaced on every update, so changes here are lost.\n'
    printf '# shellcheck disable=SC2016\n\n'
    printf 'set -euo pipefail\n\n'
    declare -p SERVICE SVC_USER LIB_DIR DATA_DIR CONF_DIR ENV_FILE FOLDERS_FILE UNIT_FILE HELPER DEFAULT_PORT INSTALLER_URL
    cat <<'EOF'
ASSUME_YES=0

if [ -t 1 ]; then BOLD=$'\e[1m' YELLOW=$'\e[33m' RED=$'\e[31m' RESET=$'\e[0m'
else BOLD='' YELLOW='' RED='' RESET=''; fi

EOF
    declare -f say step warn die prompt_tty ask confirm need_root as_kinonode env_setting server_port \
      local_host health json_string lan_addresses print_urls refuse_reason mount_advice remember_folder \
      forget_folder grant_folder revoke_folder uninstall
    cat <<'EOF'

helper_usage() {
  cat <<USAGE
Kinonode media server

  kinonode status                 Is it running, and where to open it
  kinonode logs                   Follow the server's log
  sudo kinonode restart           Restart the server
  sudo kinonode grant <folder>    Let the server read a media folder
  sudo kinonode revoke <folder>   Take that away again
  sudo kinonode update            Install the latest version
  sudo kinonode uninstall         Remove Kinonode, keeping your data
  sudo kinonode uninstall --purge Remove Kinonode and all its data
  kinonode version                Show the installed version

Server:   $LIB_DIR/kinonode-server
Data:     $DATA_DIR
Settings: $ENV_FILE
Guide:    https://kinonode.com/setup
USAGE
}

cmd_status() {
  systemctl status --no-pager --lines=5 "$SERVICE" || true
  local answer version
  answer="$(health)"
  echo
  if [ -n "$answer" ]; then
    version="$(json_string version <<<"$answer")"
    say "Kinonode ${version:-} is answering. Open it at:"
    print_urls
  else
    say "Kinonode isn't answering on port $(server_port). See: kinonode logs"
  fi
  if [ -s "$FOLDERS_FILE" ]; then
    echo
    say "Media folders it can read (from sudo kinonode grant):"
    sed 's/^/    /' "$FOLDERS_FILE"
  fi
}

cmd_grant() {
  need_root grant "$@"
  [ $# -gt 0 ] || die 'Which folder? For example: sudo kinonode grant "/srv/films"'
  local dir failed=0
  for dir in "$@"; do grant_folder "$dir" || failed=1; done
  if [ "$failed" = 0 ]; then
    say "Add it as a library on your server's page; it shows up in the folder list."
  fi
  return "$failed"
}

cmd_revoke() {
  need_root revoke "$@"
  [ $# -gt 0 ] || die 'Which folder? For example: sudo kinonode revoke "/srv/films"'
  local dir failed=0
  for dir in "$@"; do revoke_folder "$dir" || failed=1; done
  return "$failed"
}

cmd_update() {
  need_root update
  local tmp status=0
  tmp="$(mktemp)"
  if curl -fsSL --max-time 60 -o "$tmp" "$INSTALLER_URL" 2>/dev/null &&
    [ "$(head -c 2 "$tmp")" = '#!' ] && grep -q kinonode-server "$tmp"; then
    bash "$tmp" --yes || status=$?
  elif [ -f "$LIB_DIR/install.sh" ]; then
    warn "Couldn't get the latest installer from $INSTALLER_URL; using the one from your last install."
    bash "$LIB_DIR/install.sh" --yes || status=$?
  else
    rm -f "$tmp"
    die "Couldn't get the installer from $INSTALLER_URL. Check this machine is online."
  fi
  rm -f "$tmp"
  return "$status"
}

cmd_version() {
  local installed running
  installed="$(cat "$LIB_DIR/VERSION" 2>/dev/null || true)"
  running="$(health | json_string version)"
  say "Kinonode ${installed:-(unknown version)}"
  if [ -n "$running" ] && [ "$running" != "$installed" ]; then
    say "Running $running; restart to use the installed version: sudo kinonode restart"
  fi
}

main() {
  local cmd="${1:-help}"
  [ $# -gt 0 ] && shift
  case "$cmd" in
    status) cmd_status ;;
    logs | log) exec journalctl -u "$SERVICE" -f -n 100 ;;
    restart)
      need_root restart
      systemctl restart "$SERVICE"
      say "Restarted. Check it with: kinonode status" ;;
    start | stop)
      need_root "$cmd"
      systemctl "$cmd" "$SERVICE" ;;
    grant) cmd_grant "$@" ;;
    revoke) cmd_revoke "$@" ;;
    update | upgrade) cmd_update ;;
    uninstall)
      need_root uninstall "$@"
      local purge=0 arg
      for arg in "$@"; do
        case "$arg" in
          --purge) purge=1 ;;
          --yes | -y) ASSUME_YES=1 ;;
          *) die "Unknown option: $arg" ;;
        esac
      done
      uninstall "$purge" ;;
    version | --version | -v) cmd_version ;;
    help | --help | -h) helper_usage ;;
    *) helper_usage >&2; exit 1 ;;
  esac
}

# Exit straight away: an update replaces this file while it runs.
main "$@"
exit
EOF
  } >"$tmp"
  bash -n "$tmp" || die "The kinonode command didn't come out right. Please report this."
  # A new file renamed into place, since `kinonode update` runs from the old one.
  install -m 755 "$tmp" "$HELPER.new"
  mv -f "$HELPER.new" "$HELPER"
  rm -f "$tmp"
}

# Keeps a copy of this installer, for `kinonode update` when kinonode.com
# can't be reached. Piped into bash there's no file to copy, so fetch it.
save_installer() {
  local src="${BASH_SOURCE[0]:-}" tmp
  if [ -n "$src" ] && [ -f "$src" ]; then
    if [ "$(realpath -- "$src")" != "$LIB_DIR/install.sh" ]; then
      install -m 755 "$src" "$LIB_DIR/install.sh"
    fi
    return
  fi
  tmp="$(mktemp)"
  if curl -fsSL --max-time 30 -o "$tmp" "$INSTALLER_URL" 2>/dev/null && [ "$(head -c 2 "$tmp")" = '#!' ]; then
    install -m 755 "$tmp" "$LIB_DIR/install.sh"
  fi
  rm -f "$tmp"
}

# What's listening on a port, if anything: the program's name, or "something".
port_user() {
  local port="$1" line
  if command -v ss >/dev/null 2>&1; then
    line="$(ss -Hltnp "sport = :$port" 2>/dev/null | head -n 1)"
    [ -n "$line" ] || return 0
    sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line" | grep . || printf 'something'
  elif (: </dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
    printf 'something'
  fi
}

check_port() {
  local port who next
  port="$(server_port)"
  who="$(port_user "$port")"
  [ -n "$who" ] || return 0
  warn "Port $port is already in use by $who, so Kinonode can't listen there."
  next=$((port + 1))
  while [ "$next" -lt $((port + 100)) ] && [ -n "$(port_user "$next")" ]; do next=$((next + 1)); done
  if prompt_tty && confirm "Use port $next for Kinonode instead?" no; then
    printf '\n# Set by the installer: port %s was taken.\nKINONODE_BIND=0.0.0.0:%s\n' "$port" "$next" >>"$ENV_FILE"
    say "  Kinonode will use port $next. Change it in $ENV_FILE."
  else
    say "  To use another port, add this line to $ENV_FILE and run sudo kinonode restart:"
    say "    KINONODE_BIND=0.0.0.0:$next"
  fi
}

# Folders that look like they hold films and shows: drives mounted under
# /media, /mnt, /srv and /run/media, and folders with names like Movies or TV
# in the home folder of whoever ran sudo. A drive with media folders on it is
# listed as those folders, so the rest of the drive stays private.
detect_media() {
  local mounts=() homes=() m found
  local names=(-iname movies -o -iname movie -o -iname films -o -iname film -o -iname tv -o -iname 'tv shows'
    -o -iname tvshows -o -iname 'tv-shows' -o -iname shows -o -iname series -o -iname videos -o -iname media)

  # Mount points, with escapes like \x20 (findmnt) or \040 (/proc/mounts) undone.
  if command -v findmnt >/dev/null 2>&1; then
    while IFS= read -r m; do mounts+=("$(printf '%b' "$m")"); done < <(findmnt -rn -o TARGET 2>/dev/null || true)
  else
    while IFS= read -r m; do mounts+=("$(printf '%b' "${m//\\0/\\00}")"); done < <(awk '{print $2}' /proc/mounts)
  fi
  if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
    homes=("$(getent passwd "$SUDO_USER" | cut -d: -f6)")
  else
    for m in /home/*/; do [ -d "$m" ] && homes+=("${m%/}"); done
  fi

  {
    for m in ${mounts[@]+"${mounts[@]}"}; do
      case "$m" in /mnt/wsl*) continue ;; /media/?* | /mnt/?* | /srv/?* | /run/media/?*/?*) ;; *) continue ;; esac
      [ -d "$m" ] || continue
      found="$(find "$m" -mindepth 1 -maxdepth 3 -type d \( "${names[@]}" \) -not -path '*/.*' 2>/dev/null | head -n 20 || true)"
      if [ -n "$found" ]; then printf '%s\n' "$found"; else printf '%s\n' "$m"; fi
    done
    # Folders under /srv are a common place for media without a separate drive.
    find /srv -mindepth 1 -maxdepth 2 -type d \( "${names[@]}" \) -not -path '*/.*' 2>/dev/null | head -n 20 || true
    for m in ${homes[@]+"${homes[@]}"}; do
      [ -n "$m" ] && [ -d "$m" ] || continue
      find "$m" -mindepth 1 -maxdepth 2 -type d \( "${names[@]}" \) -not -path '*/.*' 2>/dev/null | head -n 20 || true
    done
  } | while IFS= read -r m; do
    # Leave out folders we'd refuse anyway.
    if [ -z "$(refuse_reason "$m")" ]; then printf '%s\n' "$m"; fi
  done | LC_ALL=C sort -u |
    # And folders inside one already listed (a parent sorts before its children).
    awk '{ for (i = 1; i <= n; i++) if (index($0, kept[i] "/") == 1) next; kept[++n] = $0; print }'
}

# Asks which folders the server may read, and adds them to MEDIA.
choose_media() {
  local found=() picked=() line i home nums=()
  while IFS= read -r line; do [ -n "$line" ] && found+=("$line"); done < <(detect_media)
  home="$(getent passwd "${SUDO_USER:-root}" | cut -d: -f6)"

  step "Media folders"
  say "Kinonode runs as its own user, so it can only read the folders you give it."
  if [ ${#found[@]} -gt 0 ]; then
    say "These look like media folders:"
    for i in "${!found[@]}"; do say "  $((i + 1))) ${found[$i]}"; done
    say ""
    line="$(ask "Press Enter to use all of them, type numbers (like 1 3) or a folder's path, or n for none: ")"
    case "$line" in
      '') picked=("${found[@]}") ;;
      [Nn] | [Nn][Oo]) line=n ;;
      /* | '~'*) picked+=("${line/#\~/$home}") ;;
      *)
        read -r -a nums <<<"$line"
        for i in "${nums[@]}"; do
          if [[ "$i" =~ ^[0-9]+$ ]] && [ "$i" -ge 1 ] && [ "$i" -le ${#found[@]} ]; then
            picked+=("${found[$((i - 1))]}")
          else
            warn "Skipped \"$i\": it isn't a number from the list."
          fi
        done ;;
    esac
  else
    line="$(ask "Type the path of a folder with your films or shows, or press Enter to skip: ")"
    case "$line" in
      '') line=n ;;
      /* | '~'*) picked+=("${line/#\~/$home}") ;;
      *) warn "Type the full path, like /srv/films." ;;
    esac
  fi

  # Then any more, one per line.
  if [ "$line" != n ]; then
    while :; do
      line="$(ask "Another folder? Type its path, or press Enter to finish: ")"
      case "$line" in
        '') break ;;
        /* | '~'*) picked+=("${line/#\~/$home}") ;;
        *) warn "Type the full path, like /srv/films." ;;
      esac
    done
  fi

  if [ ${#picked[@]} -gt 0 ]; then MEDIA+=("${picked[@]}"); fi
  say "You can give it more folders any time with: sudo kinonode grant \"/path/to/folder\""
}

grant_media() {
  [ ${#MEDIA[@]} -gt 0 ] || return 0
  step "Giving Kinonode access to your media"
  local dir
  for dir in "${MEDIA[@]}"; do grant_folder "$dir" || true; done
}

wait_for_health() {
  local i answer
  for i in $(seq 1 20); do
    answer="$(health)"
    if [ -n "$answer" ]; then printf '%s' "$answer"; return 0; fi
    sleep 1
  done
  return 1
}

firewall_note() {
  local port="$1" out
  if command -v ufw >/dev/null 2>&1; then
    out="$(ufw status 2>/dev/null || true)"
    if grep -q '^Status: active' <<<"$out" && ! grep -qE "^$port(/tcp)?[[:space:]]" <<<"$out"; then
      say ""
      say "The ufw firewall is on and may block other devices from reaching Kinonode."
      say "To allow them: sudo ufw allow $port/tcp"
    fi
  elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
    out="$(firewall-cmd --list-ports 2>/dev/null || true)"
    if ! grep -qw "$port/tcp" <<<"$out"; then
      say ""
      say "firewalld is on and may block other devices from reaching Kinonode. To allow them:"
      say "  sudo firewall-cmd --permanent --add-port=$port/tcp && sudo firewall-cmd --reload"
    fi
  fi
}

main() {
  parse_args "$@"
  require_root "$@"
  [ -d /run/systemd/system ] || [ "$UNINSTALL" = 0 ] || die "This machine doesn't use systemd, so there's nothing to uninstall."

  if [ "$UNINSTALL" = 1 ]; then
    uninstall "$PURGE"
    return
  fi

  check_platform

  # A Connect set at install time (for testing) sticks for updates.
  if [ -z "$CONNECT_URL" ]; then CONNECT_URL="$(env_setting KINONODE_CONNECT_URL)"; fi
  CONNECT_URL="${CONNECT_URL:-https://connect.kinonode.com}"
  CONNECT_URL="${CONNECT_URL%/}"

  INSTALLED_VERSION=''
  local updating=0
  if [ -f "$UNIT_FILE" ] && [ -x "$LIB_DIR/kinonode-server" ]; then
    updating=1
    INSTALLED_VERSION="$(cat "$LIB_DIR/VERSION" 2>/dev/null || true)"
  fi

  WORK="$(mktemp -d)"
  trap 'rm -rf "$WORK"' EXIT
  install_dependencies
  download_release

  GROUPS_CHANGED=0
  UNIT_CHANGED=0
  create_user

  install -d -m 755 "$LIB_DIR" "$CONF_DIR"
  install -d -m 750 -o "$SVC_USER" -g "$SVC_USER" "$DATA_DIR"
  write_env_file
  write_unit
  write_helper
  save_installer

  if [ "$updating" = 0 ] && prompt_tty && [ ${#MEDIA[@]} -eq 0 ]; then choose_media; fi
  grant_media

  local new_binary=0
  if [ -f "$WORK/unpacked/kinonode-server" ]; then new_binary=1; fi

  if [ "$updating" = 1 ]; then
    if [ "$new_binary" = 1 ]; then
      step "Updating Kinonode"
      systemctl stop "$SERVICE"
    fi
  else
    step "Starting Kinonode"
    check_port
  fi

  if [ "$new_binary" = 1 ]; then
    # Copy next to the old binary, then rename over it, so it's never half-written.
    install -m 755 "$WORK/unpacked/kinonode-server" "$LIB_DIR/.kinonode-server.new"
    mv -f "$LIB_DIR/.kinonode-server.new" "$LIB_DIR/kinonode-server"
    printf '%s\n' "$NEW_VERSION" >"$LIB_DIR/VERSION"
  fi
  systemctl daemon-reload

  if [ "$updating" = 0 ]; then
    systemctl enable --now "$SERVICE" >/dev/null 2>&1 || systemctl enable --now "$SERVICE"
  elif [ "$new_binary" = 1 ]; then
    systemctl start "$SERVICE"
  elif [ "$UNIT_CHANGED" = 1 ] || [ "$GROUPS_CHANGED" = 1 ] || ! systemctl is-active --quiet "$SERVICE"; then
    systemctl restart "$SERVICE"
  fi

  local answer
  if ! answer="$(wait_for_health)"; then
    say ""
    warn "Kinonode didn't answer on port $(server_port) within 20 seconds. Its last log lines:"
    journalctl -u "$SERVICE" -n 20 --no-pager 2>/dev/null >&2 || true
    say ""
    say "Check on it with: kinonode status, and follow its log with: kinonode logs"
    exit 1
  fi

  say ""
  if [ "$updating" = 1 ]; then
    if [ "$new_binary" = 1 ]; then
      say "${BOLD}Updated Kinonode ${INSTALLED_VERSION:-} → $NEW_VERSION.${RESET}"
    else
      say "${BOLD}Kinonode $NEW_VERSION is already the latest version.${RESET}"
    fi
    say "It's running. Open it at:"
    print_urls
    return
  fi

  say "${BOLD}Kinonode $NEW_VERSION is installed and running.${RESET}"
  say ""
  say "Open its setup page from a browser on the same network, sign in to link it"
  say "to your Kinonode account, and add your libraries:"
  print_urls
  say ""
  say "It starts on its own at boot. Useful commands:"
  say "  kinonode status                  Is it running, and where to open it"
  say "  kinonode logs                    Follow its log"
  say "  sudo kinonode grant \"/path/to/folder\"   Let it read another media folder"
  say "  sudo kinonode update             Install the latest version"
  say "  kinonode help                    Everything else"
  say ""
  say "Settings are in $ENV_FILE and its data is in $DATA_DIR."
  firewall_note "$(server_port)"
}

# Everything above only defines functions, so nothing runs until the whole
# script has downloaded, and commands run by main can't read the rest of it
# from stdin.
main "$@"
