#!/bin/sh # Hearth installer — https://hearth-7hb.pages.dev # Writes compose.yaml, .env and (when needed) compose.override.yaml into one folder, then starts Hearth with Docker # Compose. Run it again in the same folder to update. Every question can be answered ahead of time: # HEARTH_DIR, HEARTH_MEDIA_DIR, HEARTH_MEDIA_WRITABLE (yes/no), HEARTH_PORT, HEARTH_GPU (yes/no), # HEARTH_VERSION, HEARTH_YES=1 (take defaults), HEARTH_NO_START=1 (write the files, start nothing). # The whole script is a function called on the last line, so a truncated download runs nothing. set -eu hearth_install() { SITE='https://hearth-7hb.pages.dev' RELEASE='0.1.0' IMAGE='madpelsm/hearth-media' if [ -t 1 ]; then BOLD=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m'); GREEN=$(printf '\033[32m'); RESET=$(printf '\033[0m') else BOLD=''; DIM=''; RED=''; GREEN=''; RESET=''; fi say() { printf '%s\n' "$*"; } ok() { printf '%sāœ“%s %s\n' "$GREEN" "$RESET" "$*"; } warn() { printf '%s!%s %s\n' "$RED" "$RESET" "$*" >&2; } fail() { printf '%sError:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; } is_yes() { case $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') in y|yes|true|1) return 0;; *) return 1;; esac; } # `curl | sh` leaves stdin on the script itself, so questions go to the terminal directly. TTY=0 if [ "${HEARTH_YES:-0}" != 1 ] && (: /dev/null; then TTY=1; fi ask() { # ask VARIABLE "question" default eval "ask_current=\${$1:-}" if [ -n "$ask_current" ]; then return 0; fi if [ "$TTY" = 0 ]; then if [ -z "$3" ]; then fail "$2 Set $1 to answer it without a terminal."; fi eval "$1=\$3"; return 0 fi if [ -n "$3" ]; then printf '%s%s%s %s[%s]%s ' "$BOLD" "$2" "$RESET" "$DIM" "$3" "$RESET" >/dev/tty else printf '%s%s%s ' "$BOLD" "$2" "$RESET" >/dev/tty; fi IFS= read -r ask_answer /dev/null 2>&1 || fail "Docker is not installed. Install Docker Engine first: https://docs.docker.com/engine/install/" if ! docker info >/dev/null 2>&1; then if docker info 2>&1 | grep -qi 'permission denied'; then fail "This user may not use Docker. Run the installer with sudo, or add the user to the docker group." fi fail "Docker is installed but not running. Start it and run the installer again." fi docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is missing. Install the docker-compose-plugin package." ok "Docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo '') with Compose $(docker compose version --short 2>/dev/null || echo '')" say '' # Where Hearth lives. An existing installation is updated in place. if [ "$(id -u)" = 0 ]; then default_dir=/opt/hearth; else default_dir="$HOME/hearth"; fi ask HEARTH_DIR 'Install Hearth in' "$default_dir" case $HEARTH_DIR in /*) ;; *) HEARTH_DIR="$(pwd)/$HEARTH_DIR";; esac mkdir -p "$HEARTH_DIR" || fail "Cannot create $HEARTH_DIR." cd "$HEARTH_DIR" VERSION=${HEARTH_VERSION:-$RELEASE} if [ -f .env ] && [ -f compose.yaml ]; then previous=$(sed -n 's/^HEARTH_VERSION=//p' .env | tr -d "'\"") say "Found an installation in $HEARTH_DIR (version ${previous:-unknown})." if [ "$previous" != "$VERSION" ]; then update='' ask update "Update it to $VERSION?" 'yes' is_yes "$update" || { say 'Nothing changed.'; exit 0; } sed "s/^HEARTH_VERSION=.*/HEARTH_VERSION='$VERSION'/" .env > .env.next && mv .env.next .env fi write_compose ok "Kept your .env and compose.override.yaml" else configure fi if [ "${HEARTH_NO_START:-0}" = 1 ]; then say ''; ok "Files written to $HEARTH_DIR. Start Hearth there with: docker compose up -d"; return 0 fi start } write_compose() { if [ -f compose.yaml ] && ! grep -q '^# Managed by the Hearth installer' compose.yaml; then cp compose.yaml compose.yaml.backup warn "Your edited compose.yaml was saved as compose.yaml.backup. Put local changes in compose.override.yaml." fi { say '# Managed by the Hearth installer, which replaces this file on update. Put your own changes in' say '# compose.override.yaml: Docker Compose merges it on top of this file.' cat <<'HEARTH_COMPOSE' services: hearth: image: madpelsm/hearth-media:${HEARTH_VERSION:?Set HEARTH_VERSION in .env to a published version} restart: unless-stopped ports: - "${HEARTH_PORT:-3000}:3000" environment: DATA_DIR: /data PORT: 3000 # Empty for the home network. Behind a reverse proxy: the exact address people open, such as # https://media.example.com. Hearth then accepts sign-ins only at that address. PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-} volumes: - hearth-data:/data - type: bind source: ${MEDIA_DIR:?Set MEDIA_DIR in .env to an existing absolute host folder} target: /media read_only: true bind: create_host_path: false stop_grace_period: 20s volumes: hearth-data: HEARTH_COMPOSE } > compose.yaml ok "Wrote compose.yaml" } configure() { ask HEARTH_MEDIA_DIR 'Where is your media? (an existing folder on this machine)' '' [ -d "$HEARTH_MEDIA_DIR" ] || fail "$HEARTH_MEDIA_DIR is not an existing folder." MEDIA=$(realpath "$HEARTH_MEDIA_DIR" 2>/dev/null || (cd "$HEARTH_MEDIA_DIR" 2>/dev/null && pwd -P)) || fail "Cannot resolve $HEARTH_MEDIA_DIR." case $MEDIA in *"'"*|*'$'*) fail 'Choose a media path without quotes or dollar signs.';; esac ask HEARTH_MEDIA_WRITABLE 'Let Hearth save downloads into that folder?' 'no' ask HEARTH_PORT 'Port for the web interface' '3000' case $HEARTH_PORT in ''|*[!0-9]*) fail 'The port must be a number.';; esac GPU_NODE='' if [ "$OS" = Linux ]; then for node in /dev/dri/renderD*; do if [ -c "$node" ]; then GPU_NODE=$node; break; fi; done fi if [ -n "$GPU_NODE" ]; then ask HEARTH_GPU "Found a GPU ($GPU_NODE). Let Hearth try it for video conversion?" 'no' else HEARTH_GPU=no; fi # Hearth runs as UID/GID 1000. Grant the media folder's group when only the group has the access it needs. GROUPS_NEEDED='' if [ "$OS" = Linux ]; then need=5; if is_yes "$HEARTH_MEDIA_WRITABLE"; then need=7; fi set -- $(stat -c '%u %g %a' "$MEDIA") owner=$1 group=$2 mode=$3 bits() { printf '%s' "$mode" | awk -v p="$1" '{ m = substr($0, length($0) - 2); print substr(m, p, 1) }'; } has() { [ $(( $1 & need )) -eq "$need" ]; } if [ "$owner" = 1000 ] && has "$(bits 1)"; then : elif has "$(bits 2)"; then [ "$group" = 1000 ] || GROUPS_NEEDED=$group elif has "$(bits 3)"; then : else warn "Hearth (user 1000) may not be able to $( [ "$need" = 7 ] && echo 'write to' || echo 'read' ) $MEDIA. See $SITE/#permissions" fi fi printf "# Hearth settings. Change a value, then run: docker compose up -d\nHEARTH_VERSION='%s'\n# The media folder on this machine. Hearth sees it as /media.\nMEDIA_DIR='%s'\nHEARTH_PORT='%s'\n# Leave empty on a home network. Behind a reverse proxy, the exact address people open, such as\n# https://media.example.com. Hearth then accepts sign-ins only at that address.\nPUBLIC_BASE_URL=''\n" \ "$VERSION" "$MEDIA" "$HEARTH_PORT" > .env ok "Wrote .env" write_compose writable=0; if is_yes "$HEARTH_MEDIA_WRITABLE"; then writable=1; fi gpu=0; gpu_group='' if is_yes "$HEARTH_GPU"; then gpu=1; gpu_group=$(stat -c '%g' "$GPU_NODE"); fi if [ "$writable" = 1 ] || [ "$gpu" = 1 ] || [ -n "$GROUPS_NEEDED" ]; then { say '# Your changes on top of compose.yaml. The installer writes this file once and never replaces it.' say 'services:' say ' hearth:' if [ "$writable" = 1 ]; then say ' volumes:' say ' - type: bind' say ' source: ${MEDIA_DIR}' say ' target: /media' say ' read_only: false' say ' bind:' say ' create_host_path: false' fi if [ "$gpu" = 1 ]; then say ' devices:'; say " - $GPU_NODE:$GPU_NODE"; fi if [ -n "$GROUPS_NEEDED$gpu_group" ]; then say ' group_add:' if [ -n "$GROUPS_NEEDED" ]; then say " - \"$GROUPS_NEEDED\" # the media folder's group"; fi if [ -n "$gpu_group" ] && [ "$gpu_group" != "$GROUPS_NEEDED" ]; then say " - \"$gpu_group\" # the GPU's group"; fi fi } > compose.override.yaml ok "Wrote compose.override.yaml" fi } start() { say '' docker compose config --quiet || fail 'Docker Compose rejected the configuration above.' docker compose pull --quiet || fail "Could not download $IMAGE:$VERSION. Check the version and your connection." docker compose up -d --remove-orphans printf 'Waiting for Hearth to start' container=$(docker compose ps -q hearth) state=starting tries=0 while [ "$tries" -lt 60 ]; do state=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || echo gone) if [ "$state" = healthy ]; then break; fi case $state in exited|dead|gone) break;; esac printf '.'; sleep 2; tries=$((tries + 1)) done say '' [ "$state" = healthy ] || fail "Hearth did not become healthy ($state). See: cd $HEARTH_DIR && docker compose logs" ok 'Hearth is running' # Check access as Hearth's own user, with its own groups, inside the container. docker compose exec -T hearth node -e ' const fs = require("node:fs"), w = process.argv[1] === "1"; for (const [p, m] of [["/data", 7], ["/media", w ? 7 : 5]]) { try { fs.accessSync(p, m); } catch { console.error("Hearth cannot " + (m === 7 ? "write to " : "read ") + p); process.exitCode = 1; } }' "$(grep -q 'read_only: false' compose.override.yaml 2>/dev/null && echo 1 || echo 0)" \ || warn "Fix the folder permissions, then run: cd $HEARTH_DIR && docker compose restart. See $SITE/#permissions" port=$(sed -n "s/^HEARTH_PORT=//p" .env | tr -d "'\"") address='' if [ "$OS" = Linux ]; then address=$(hostname -I 2>/dev/null | awk '{print $1}'); fi if [ -z "$address" ]; then address=localhost; fi say '' say "${BOLD}Open http://$address:${port:-3000}${RESET}" say ' 1. Create the administrator account.' say ' 2. In Server → Storage, add /media and scan it.' say '' say "${DIM}Settings live in $HEARTH_DIR/.env. Run this installer again to update.${RESET}" say "${DIM}More folders or your own web address: $SITE/#folders and $SITE/#address${RESET}" } hearth_install "$@"