1#!/bin/sh
2set -eu
3
4instance=${1:?usage: tools/import-jellyfin.sh jellyfin|jellyfin-preview-XXXXXXXX}
5case $instance in
6 jellyfin)
7 test -n "${STUDIO_DEPLOY_HOST:-}" || { echo 'Set STUDIO_DEPLOY_HOST to the production target' >&2; exit 1; }
8 production=true
9 ;;
10 jellyfin-preview-*)
11 printf '%s\n' "$instance" | grep -Eq '^jellyfin-preview-[0-9a-f]{8}$' || {
12 echo 'Expected a Jellyfin preview ID' >&2; exit 1;
13 }
14 production=false
15 ;;
16 *) echo 'Expected jellyfin or its preview ID' >&2; exit 1 ;;
17esac
18
19source_host=${STUDIO_MIGRATION_SOURCE:-zenith}
20target_host=${STUDIO_DEPLOY_HOST:-root@127.0.0.1}
21target_port=${STUDIO_DEPLOY_PORT:-2222}
22offline=false
23if [ -n "${STUDIO_LEGACY_HANDOFF:-}" ]; then
24 test "$production" = true || { echo 'Offline handoff requires production Jellyfin' >&2; exit 1; }
25 sh "$(dirname "$0")/check-legacy-handoff.sh" "$STUDIO_LEGACY_HANDOFF" "$target_host" "$target_port"
26 offline=true
27fi
28if [ "$production" = true ]; then
29 root=/srv/prod/jellyfin/config
30 mount=/srv/prod/jellyfin
31 source_root=/mnt/storage1/apps/jellyfin
32 if [ "$offline" = false ]; then
33 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' jellyfin")" = false || {
34 echo 'Stop Zenith jellyfin before importing production data' >&2; exit 1;
35 }
36 fi
37 response=$(ssh -p "$target_port" "$target_host" 'curl -s -w "\n%{http_code}" -H "X-Nomad-Token: $(cat /var/lib/studio/nomad.token)" http://127.0.0.1:4646/v1/job/jellyfin')
38 status=$(printf '%s\n' "$response" | tail -n 1)
39 test "$status" = 200 || { echo "Could not verify jellyfin on the home server: $status" >&2; exit 1; }
40 stopped=$(printf '%s\n' "$response" | sed '$d' | python3 -c 'import json,sys; print(str(json.load(sys.stdin)["Stop"]).lower())')
41 test "$stopped" = true || { echo 'Stop jellyfin on the home server before importing production data' >&2; exit 1; }
42 media_fs=$(ssh -p "$target_port" "$target_host" 'findmnt -n -o FSTYPE --mountpoint /srv/clover/Media')
43 test "$media_fs" = zfs || { echo 'Mount the real Media ZFS dataset before importing production data' >&2; exit 1; }
44 source_label=stopped-live-source
45 if [ "$offline" = true ]; then source_label=mounted-old-apps; fi
46else
47 root="/srv/staging/$instance/config"
48 mount="/srv/staging/$instance"
49 source_id=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/stages/$instance.json\"))[\"sourceId\"])'")
50 test "$source_id" = jellyfin || { echo 'Preview belongs to another service' >&2; exit 1; }
51 source_snapshot=$(ssh "$source_host" '/usr/sbin/zfs list -H -t snapshot -o name -s creation -r storage1/apps | tail -n 1')
52 case $source_snapshot in storage1/apps@*) ;; *) echo 'No apps ZFS snapshot is available' >&2; exit 1 ;; esac
53 source_label=${source_snapshot#*@}
54 source_root="/mnt/storage1/apps/.zfs/snapshot/$source_label/jellyfin"
55fi
56
57dataset=$(ssh -p "$target_port" "$target_host" "findmnt -n -o SOURCE --mountpoint $mount")
58if [ "$production" = true ]; then
59 case $dataset in */prod/jellyfin) ;; *) echo 'Production dataset is not mounted' >&2; exit 1 ;; esac
60else
61 case $dataset in */staging/"$instance") ;; *) echo 'Preview dataset is not mounted' >&2; exit 1 ;; esac
62fi
63
64if [ "$offline" = false ]; then
65 scratch=$(mktemp -d)
66 trap 'rm -rf "$scratch"' EXIT
67 rsync -a --exclude='/cache/' --exclude='/log/' --exclude='/transcodes/' --exclude='*-shm' \
68 "$source_host:$source_root/" "$scratch/"
69fi
70
71if [ "$production" = false ]; then
72 ssh -p "$target_port" "$target_host" "NOMAD_TOKEN=\$(cat /var/lib/studio/nomad.token) nomad job stop -yes $instance"
73fi
74snapshot="$dataset@before-jellyfin-import-$(date +%s)-$$"
75ssh -p "$target_port" "$target_host" "set -eu; for i in \$(seq 1 30); do running=\$(curl -fsS -H \"X-Nomad-Token: \$(cat /var/lib/studio/nomad.token)\" http://127.0.0.1:4646/v1/job/$instance/allocations | python3 -c 'import json,sys; print(sum(a[\"ClientStatus\"] == \"running\" for a in json.load(sys.stdin)))'); test \"\$running\" = 0 && break; sleep 2; done; test \"\$running\" = 0; zfs snapshot $snapshot"
76
77if [ "$offline" = true ]; then
78 ssh -p "$target_port" "$target_host" "rsync -a --delete --delete-excluded --exclude='/cache/' --exclude='/log/' --exclude='/transcodes/' --exclude='*-shm' '$source_root/' '$root/'"
79else
80 rsync -a --delete -e "ssh -p $target_port" "$scratch/" "$target_host:$root/"
81fi
82owner=$(ssh -p "$target_port" "$target_host" "python3 -c 'import json; print(json.load(open(\"/var/lib/studio/identities.json\"))[\"jellyfin\"])'")
83ssh -p "$target_port" "$target_host" "chown -R $owner:$owner $root"
84if [ "$offline" = true ]; then
85 drift=$(ssh -p "$target_port" "$target_host" "rsync -rlnc --delete --delete-excluded --exclude='/cache/' --exclude='/log/' --exclude='/transcodes/' --exclude='*-shm' --out-format='%n' '$source_root/' '$root/'")
86else
87 drift=$(rsync -rlnc --delete --out-format='%n' -e "ssh -p $target_port" "$scratch/" "$target_host:$root/")
88fi
89test -z "$drift" || { echo "Jellyfin copy differs from source; restore $snapshot" >&2; exit 1; }
90ssh -p "$target_port" "$target_host" "python3 -c 'import sqlite3; from pathlib import Path; root=Path(\"$root/data\"); assert all((root / name).is_file() and sqlite3.connect(\"file:\" + str(root / name) + \"?mode=ro\", uri=True).execute(\"PRAGMA integrity_check\").fetchone()[0] == \"ok\" for name in (\"jellyfin.db\", \"introskipper/introskipper.db\"))'"
91
92if [ "$production" = true ]; then
93 if [ "$offline" = false ]; then
94 test "$(ssh "$source_host" "sudo -n docker inspect -f '{{.State.Running}}' jellyfin")" = false || {
95 echo 'Zenith jellyfin restarted during import; leave the home server stopped' >&2; exit 1;
96 }
97 fi
98 echo "Imported production Jellyfin from $source_label. Previous dataset state: $snapshot"
99else
100 python3 "$(dirname "$0")/deploy.py" stage jellyfin
101 ssh -p "$target_port" "$target_host" "zfs destroy $snapshot"
102 echo "Imported and tested $instance from $source_snapshot"
103fi