Files
ytstream/deploy/deploy.sh
T
Tom FluxandClaude Opus 5 d3bf8d6f19 Deploy it, and fix the six things installation found
Both units are installed and running, 10 of 119 channels approved, 251 episodes
live in Jellyfin with verified DirectPlay. 345 tests. Six problems surfaced that no
test could have, and two of them were mine in the deploy scripts.

deploy.sh had a circular dependency with bootstrap.sh: deploy started the units and
told the operator to run bootstrap, but bootstrap refused to run until the state
directory existed, which only deploy creates. The units started against a
non-existent venv, failed 203/EXEC and restart-looped 17 and 21 times. deploy.sh now
creates the directory, calls bootstrap itself through runuser so the venv is not left
root-owned, and refuses to start units when the venv is still missing.

Deno was absent, and `doctor` is the only reason we know. It is mandatory rather than
nice-to-have — without a JS runtime yt-dlp cannot solve the n challenge, which
youtube-automate measured on this machine as 22 formats instead of 29 plus
throttling. Nothing else would have complained; playback would just have quietly
degraded. bootstrap.sh now installs it and asserts yt-dlp reports it.

Episodes had no synopsis at all, because materialise passed plot=None while both
sources hand us descriptions for free. Now plumbed through from RSS
(media:group/media:description) and from videos.list, which carries
snippet.description in the call already being made for durations — so the ~40% of
episodes older than RSS reaches get one too. That needed a schema v2 migration; v1
was left exactly as shipped so a fresh install and a migrated one are identical, and
a test asserts it.

`materialise --all` — the documented recovery from a Jellyfin metadata wipe — was
itself creating duplicates. Episode numbers were re-derived each run, and
next_episode() excludes the row being numbered, so re-materialising a day's videos in
a different order renumbered them and orphaned the old files. One run left 102
orphaned NFOs against 251 episodes. An episode number is now permanent once assigned,
and a video whose rel_path changes has its old files removed first. Running it twice
is now a no-op.

Two Jellyfin behaviours worth having in writing. It ignores <runtime> and
<durationinseconds> for episodes while reading the rest of the NFO happily, so a
.strm shows no duration until first played — not fixable without probing, which is
the one thing this design exists to avoid. And a plain /Library/Refresh does not
reliably re-read a rewritten NFO: after rewriting all 251, fifty kept their old empty
metadata. The fix is metadataRefreshMode=Default with replaceAllMetadata=false, which
took plots from 201 to 251 while the proxy served zero requests. §5's prohibition on
replaceAllMetadata=true still stands — that one probes. Exposed as
`ytstream refresh-metadata` and run automatically after `materialise --all`.

The measurement §5 has been waiting for: a full Jellyfin scan of 251 .strm files
took ~119 s, about 8 minutes per 1,000 episodes, and made zero media probes. That
last number is the fact the whole design rests on, now confirmed at scale on the real
library rather than on seven PoC files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:21:35 +01:00

113 lines
4.4 KiB
Bash
Executable File

#!/bin/bash
# Root-requiring installation steps for ytstream.
#
# susan has no passwordless sudo, so everything needing root is collected here for
# the operator to run in one go:
#
# sudo /opt/ytstream/deploy/deploy.sh
#
# This is the only command needed. It creates the state directory, builds the venv
# by calling bootstrap.sh as the service user, and only then installs and starts
# the units.
#
# That ordering is not cosmetic. An earlier version installed the units first and
# told the operator to run bootstrap.sh separately — but bootstrap.sh needs the
# state directory, which only this script can create, so neither could go first.
# The units started, failed 203/EXEC against a venv that did not exist yet, and
# restart-looped until the venv appeared.
set -euo pipefail
REPO=/opt/ytstream
STATE=/var/lib/ytstream
VENV=$STATE/venv
SERVICE_USER=susan
HOSTNAME_=tube.jihakuz.xyz
if [[ $EUID -ne 0 ]]; then
echo "This script needs root. Run: sudo $0" >&2
exit 1
fi
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
say "Creating $STATE"
# root-owned directory, group-writable by `automation` so susan's cron job and the
# admin server can both write the database.
install -d -o "$SERVICE_USER" -g automation -m 0770 "$STATE"
say "Building the virtualenv"
if [[ -x $VENV/bin/ytstream ]]; then
echo " already present at $VENV"
else
# As the service user, so the venv is not left root-owned. Absolute path:
# runuser lives in /sbin, which is not always on the invoking PATH.
/sbin/runuser -u "$SERVICE_USER" -- bash "$REPO/deploy/bootstrap.sh"
fi
# Refuse to start units that cannot possibly work. Restart=always would otherwise
# turn a missing venv into a restart loop in the journal.
if [[ ! -x $VENV/bin/ytstream ]]; then
echo "The virtualenv was not built. Fix that, then re-run $0." >&2
exit 1
fi
say "Installing the /usr/local/bin shim"
cat > /usr/local/bin/ytstream <<EOF
#!/bin/sh
# Thin shim onto the venv entry point.
exec $VENV/bin/ytstream "\$@"
EOF
chmod 0755 /usr/local/bin/ytstream
chown root:automation /usr/local/bin/ytstream
echo " /usr/local/bin/ytstream"
say "Preparing the media root"
# setgid so new directories inherit `mediaserver` — without it Jellyfin loses
# access to anything created after the fact. See plan.md §9.
install -d -o susan -g mediaserver -m 2770 /disks/Plex/_ytstream
say "Installing systemd units"
for unit in ytstream-proxy ytstream-admin; do
install -m 0644 "$REPO/deploy/$unit.service" "/etc/systemd/system/$unit.service"
echo " $unit.service"
done
systemctl daemon-reload
systemctl enable --now ytstream-proxy.service ytstream-admin.service
for unit in ytstream-proxy ytstream-admin; do
systemctl --no-pager --lines=5 status "$unit.service" || true
done
say "nginx vhost for $HOSTNAME_"
# tube.jihakuz.xyz is served by a leftover TubeArchivist server block inside
# sites-available/jihakuz.xyz, which owns the Let's Encrypt certificate and wins
# because nginx uses the FIRST server block matching a name. Installing a second
# vhost for the same name silently does nothing. youtube-automate repointed that
# block at 8085; ytstream needs it on 8086.
if grep -rql "server_name $HOSTNAME_" /etc/nginx/sites-enabled/ 2>/dev/null; then
echo " $HOSTNAME_ is already served by an existing vhost."
echo " Repoint its proxy_pass to http://127.0.0.1:8086 by hand, then:"
echo " nginx -t && systemctl reload nginx"
echo " (Deliberately not edited automatically — that block also serves"
echo " other names and owns the TLS certificate.)"
else
echo " No existing vhost found. Install one proxying to 127.0.0.1:8086"
echo " and run: certbot --nginx -d $HOSTNAME_"
fi
say "Done"
cat <<'EOF'
Remaining steps, all as susan and none needing root:
ytstream set-password # admin UI login
ytstream set-jellyfin-key # verified against the live server
ytstream set-youtube-key # verified against the live API
ytstream add-source @cflux1030 # the mirrored account
ytstream sync # queues the subscriptions
ytstream pending # review them
ytstream approve --all # or approve a subset by id
ytstream run # first real cycle
ytstream doctor # confirm everything is wired up
Then add the cron entries from deploy/crontab.fragment to susan's crontab.
EOF