commit c6e098bad6dfea4bc1addff41fed7692f0794dda
Author: Chris Roberts <chris.roberts@learningunix.net>
Date: Tue, 7 Jul 2026 04:30:39 -0500
created initial playbooks
Diffstat:
13 files changed, 584 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,15 @@
+# Ansible
+*.retry
+.ansible/
+.vault_pass
+.vault_pass.txt
+group_vars/all/vault.yml
+
+# Python
+__pycache__/
+*.pyc
+
+# Editor/OS
+*.swp
+*.swo
+.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -0,0 +1,19 @@
+# Project: Create ansible role
+This project is to create ansible roles that provision a debian based system to rip and encode dvd and Blueray movies.
+
+## Architecture
+Use a site.yml and a roles based directory structure. Use pushover to send notifications.
+
+## Important notes
+Use flatpaks for makemkv and ffmpeg if available
+Explain everything that claude code does
+When more that one option exists explain them all
+Don't guess. If claude code doesn't know something inform the user and let them make the decision.
+The target machine is 192.168.0.240. It is a proxmox server. The target directory is /mnt/Movies.
+The provided scripts use gpu encoding. Esnure the user in the proper groups for doing so.
+This project will become a git repo
+
+
+## Assets
+There is a scripts directory in the project folder which contains the needed rip, encode, and notification scripts.
+There is an ansible vault file in the project directory. It contains the pushover user and app keys we will use.
diff --git a/README.md b/README.md
@@ -0,0 +1,89 @@
+# makemkv-headless
+
+Ansible playbook that provisions a bare Proxmox VE host as a headless Blu-ray/DVD archiving box: MakeMKV (via Flatpak) and ffmpeg, GPU group access for hardware encoding, and a `rip`/`encode`/`notify` script set for turning a disc into a Jellyfin-ready x265 file — no desktop or VNC session required, since ripping happens directly on the host that already owns the movie library.
+
+## Target
+
+| | |
+|---|---|
+| Host | `192.168.0.240` (`proxmox_host` in `inventory/hosts.yml`) — bare Proxmox VE host, not a container/VM guest |
+| User | `cjr` |
+| Optical drive | `/dev/sr0` |
+| Library | Local `/mnt/Movies` (this host is the storage server; a separate desktop role mounts it over NFS elsewhere) |
+
+## Running the playbook
+
+```bash
+# Full provision
+ansible-playbook -i inventory/hosts.yml site.yml --ask-vault-pass
+
+# Individual roles
+ansible-playbook -i inventory/hosts.yml site.yml --tags flatpaks --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags gpu --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags scripts --ask-vault-pass
+
+# Dry run
+ansible-playbook -i inventory/hosts.yml site.yml --check --ask-vault-pass
+```
+
+Lint with `ansible-lint site.yml`.
+
+## Secrets
+
+`group_vars/all/vault.yml` is Ansible Vault-encrypted and must hold `pushover_app_token`, `pushover_user_key`, and `jellyfin_api_key`. Always pass `--ask-vault-pass`.
+
+This repo is public, so `vault.yml` is gitignored and never committed — not even encrypted. `group_vars/all/vault.yml.example` documents the required keys with placeholder values. On a new checkout:
+
+```bash
+cp group_vars/all/vault.yml.example group_vars/all/vault.yml
+ansible-vault edit group_vars/all/vault.yml # fill in real values
+```
+
+## Roles
+
+| Role | Purpose |
+|------|---------|
+| `flatpaks` | Flathub remote, MakeMKV flatpak (granted access to `home` + `/mnt/Movies`), DVD libs (`libdvdcss` via `libdvd-pkg`), `ffmpeg` (apt — no standalone ffmpeg CLI Flatpak exists on Flathub) |
+| `gpu` | Adds `cjr` to `video`/`render` groups for VA-API hardware encoding access |
+| `scripts` | Creates a local `jellyfin` group (see below), deploys `rip`, `encode`, and `notify` to `~/.scripts/` and adds it to `PATH` |
+
+Key variables live in `group_vars/all/vars.yml`.
+
+### Jellyfin group / GID note
+
+The `rip` script `chgrp`s each new movie folder so Jellyfin (a separate host, `192.168.0.241`) can read it. On `192.168.0.240`, GID `110` is already taken by the system `postfix` group, so this project uses a dedicated local group `jellyfin` with **GID 1100** instead (`jellyfin_gid` in `group_vars/all/vars.yml`).
+
+This GID does **not** numerically match Jellyfin's real group on `192.168.0.241` (which is GID 110, named `jellyfin` there). Verified this doesn't matter in practice: the NFSv4 mount between these hosts has `nfs4_disable_idmapping=Y` on both ends (confirmed via `/sys/module/nfs{,d}/parameters/nfs4_disable_idmapping`), so permission checks use raw numeric IDs, not names — but the existing movie library is already world-readable (`644`/`777` files, and `rip`'s `chmod 2775` on new folders includes the "other" `r-x` bit), so Jellyfin can already read everything regardless of group membership. The `chgrp` step is effectively cosmetic today, not load-bearing.
+
+If file/directory permissions are ever tightened to remove world-read access, this mismatch would start to matter — at that point, either add a matching GID 1100 group to `192.168.0.241` and put the `jellyfin` service user in it, or enable proper NFSv4 idmapping (`nfs4_disable_idmapping=N` + running, matching-domain `nfs-idmapd` on both hosts) so group *names* rather than numbers are what's compared.
+
+## The rip → encode pipeline
+
+`rip`, `encode`, and `notify` are deployed to `~/.scripts/` on the target and used interactively over SSH.
+
+```
+rip [disc] [title] [crf] [--software] [--name "Movie Title"]
+```
+
+1. Verifies `/dev/sr0` exists and a disc is actually readable in it.
+2. Queries the disc title via `makemkvcon info` and proposes an auto-detected movie name.
+3. Prompts for a name override — auto-accepts the detected name after 60 seconds of no input.
+4. Checks `/mnt/Movies/.rip.db` (SQLite) for a prior rip of the same disc and warns before re-ripping.
+5. Extracts the main feature to `/mnt/Movies` with `makemkvcon`, then calls `encode` on the result.
+6. Records the rip in the database, sends a Pushover notification, triggers a Jellyfin library scan, and ejects the disc.
+
+```
+encode <input.mkv> [output.mkv] [crf] [--software]
+```
+
+Transcodes to x265 — `hevc_vaapi` hardware encoding on `/dev/dri/renderD128` by default, or `libx265` software with `--software` (also auto-selected for SD/DVD sources, since VA-API can't handle their BT.601 color space) — and strips non-English audio/subtitle tracks.
+
+Query the rip history:
+
+```bash
+sqlite3 /mnt/Movies/.rip.db "SELECT title, output_file, ripped_at FROM rips ORDER BY ripped_at DESC;"
+```
+
+## Naming conventions
+
+Output files must be Jellyfin/TMDB-scrapable: `Movie Title (Year)/Movie Title (Year).mkv`, spaces not dots.
diff --git a/group_vars/all/vars.yml b/group_vars/all/vars.yml
@@ -0,0 +1,5 @@
+---
+rip_user: cjr
+
+jellyfin_url: http://192.168.0.241:8096
+jellyfin_gid: 1100
diff --git a/group_vars/all/vault.yml.example b/group_vars/all/vault.yml.example
@@ -0,0 +1,10 @@
+---
+# Copy this file to vault.yml, fill in real values, then encrypt it:
+# cp group_vars/all/vault.yml.example group_vars/all/vault.yml
+# ansible-vault encrypt group_vars/all/vault.yml
+#
+# vault.yml is gitignored — it never gets committed, even encrypted.
+
+pushover_app_token: CHANGEME
+pushover_user_key: CHANGEME
+jellyfin_api_key: CHANGEME
diff --git a/inventory/hosts.yml b/inventory/hosts.yml
@@ -0,0 +1,6 @@
+---
+all:
+ hosts:
+ proxmox_host:
+ ansible_host: 192.168.0.240
+ ansible_user: cjr
diff --git a/roles/flatpaks/tasks/main.yml b/roles/flatpaks/tasks/main.yml
@@ -0,0 +1,58 @@
+---
+- name: Install flatpak
+ ansible.builtin.apt:
+ name: flatpak
+ state: present
+ update_cache: true
+ tags: [flatpaks, install]
+
+- name: Add Flathub remote (system-wide)
+ community.general.flatpak_remote:
+ name: flathub
+ state: present
+ flatpakrepo_url: https://flathub.org/repo/flathub.flatpakrepo
+ method: system
+ tags: [flatpaks, configure]
+
+- name: Grant MakeMKV access to home and movie library
+ ansible.builtin.command:
+ cmd: flatpak override --user --filesystem=home --filesystem=/mnt/Movies com.makemkv.MakeMKV
+ become: true
+ become_user: "{{ rip_user }}"
+ changed_when: true
+ tags: [flatpaks, makemkv]
+
+- name: Install MakeMKV from Flathub
+ community.general.flatpak:
+ name: com.makemkv.MakeMKV
+ state: present
+ remote: flathub
+ method: system
+ tags: [flatpaks, makemkv]
+
+- name: Enable contrib repository
+ ansible.builtin.apt_repository:
+ repo: "deb http://deb.debian.org/debian {{ ansible_facts['distribution_release'] }} main contrib non-free"
+ state: present
+ update_cache: true
+ tags: [flatpaks, dvd]
+
+- name: Install DVD support packages
+ ansible.builtin.apt:
+ name:
+ - libdvd-pkg
+ - libdvdread8
+ - libdvdnav4
+ state: present
+ tags: [flatpaks, dvd]
+
+- name: Build and install libdvdcss
+ ansible.builtin.command: dpkg-reconfigure -f noninteractive libdvd-pkg
+ changed_when: true
+ tags: [flatpaks, dvd]
+
+- name: Install ffmpeg
+ ansible.builtin.apt:
+ name: ffmpeg
+ state: present
+ tags: [flatpaks, ffmpeg]
diff --git a/roles/gpu/tasks/main.yml b/roles/gpu/tasks/main.yml
@@ -0,0 +1,7 @@
+---
+- name: Add user to video and render groups for GPU access
+ ansible.builtin.user:
+ name: "{{ rip_user }}"
+ groups: video,render
+ append: true
+ tags: [gpu]
diff --git a/roles/scripts/files/encode.sh b/roles/scripts/files/encode.sh
@@ -0,0 +1,99 @@
+#!/bin/bash
+set -euo pipefail
+
+SOFTWARE=0
+
+usage() {
+ echo "Usage: encode <input.mkv> [output.mkv] [crf] [--software]"
+ echo ""
+ echo " input.mkv source file (required)"
+ echo " output.mkv destination file (default: input_x265.mkv)"
+ echo " crf quality level 18-28 (default: 22, lower = better quality)"
+ echo " --software use software x265 encoder (slower, higher quality)"
+ echo ""
+ echo "Examples:"
+ echo " encode movie.mkv"
+ echo " encode movie.mkv movie_encoded.mkv"
+ echo " encode movie.mkv movie_encoded.mkv 18"
+ echo " encode movie.mkv movie_encoded.mkv 18 --software"
+ exit 1
+}
+
+[[ $# -lt 1 ]] && usage
+
+INPUT="$1"
+OUTPUT="${2:-${INPUT%.*}_x265.mkv}"
+CRF="${3:-22}"
+
+for arg in "$@"; do
+ [[ "$arg" == "--software" ]] && SOFTWARE=1
+done
+
+if [[ ! -f "$INPUT" ]]; then
+ echo "Error: input file not found: $INPUT"
+ exit 1
+fi
+
+# Build stream maps — probe for English audio and subtitle tracks
+MAPS="-map 0:v"
+
+if ffprobe -v quiet -select_streams "a:m:language:eng" -show_streams "$INPUT" 2>/dev/null | grep -q "^index="; then
+ MAPS="$MAPS -map 0:a:m:language:eng"
+else
+ MAPS="$MAPS -map 0:a"
+fi
+
+if ffprobe -v quiet -select_streams "s:m:language:eng" -show_streams "$INPUT" 2>/dev/null | grep -q "^index="; then
+ MAPS="$MAPS -map 0:s:m:language:eng"
+fi
+
+# Auto-detect SD content — hevc_vaapi can't handle BT.601/TV-range color space
+# (standard DVD format). SD is small enough that software is faster and better quality.
+if [[ $SOFTWARE -eq 0 ]]; then
+ HEIGHT=$(ffprobe -v quiet -select_streams v:0 \
+ -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$INPUT" 2>/dev/null | head -1)
+ if [[ -n "$HEIGHT" && "$HEIGHT" -le 576 ]]; then
+ echo "SD content detected (${HEIGHT}p) — switching to software encoder"
+ SOFTWARE=1
+ fi
+fi
+
+echo "Input: $INPUT"
+echo "Output: $OUTPUT"
+echo "CRF/QP: $CRF"
+
+if [[ $SOFTWARE -eq 1 ]]; then
+ echo "Encoder: libx265 (software)"
+ echo ""
+ ffmpeg -probesize 100M -analyzeduration 100M \
+ -i "$INPUT" \
+ $MAPS \
+ -c:v libx265 \
+ -crf "$CRF" \
+ -preset medium \
+ -c:a copy \
+ -c:s copy \
+ "$OUTPUT"
+else
+ echo "Encoder: hevc_vaapi (hardware)"
+ echo ""
+ ffmpeg -probesize 100M -analyzeduration 100M \
+ -vaapi_device /dev/dri/renderD128 \
+ -i "$INPUT" \
+ $MAPS \
+ -vf 'format=nv12,hwupload,scale_vaapi=format=nv12' \
+ -c:v hevc_vaapi \
+ -qp "$CRF" \
+ -c:a copy \
+ -c:s copy \
+ "$OUTPUT"
+fi
+
+INSIZE=$(du -sh "$INPUT" | cut -f1)
+OUTSIZE=$(du -sh "$OUTPUT" | cut -f1)
+echo ""
+echo "Done. $INPUT ($INSIZE) -> $OUTPUT ($OUTSIZE)"
+
+if command -v notify &>/dev/null && [[ "${MAKEMKV_RIP:-0}" != "1" ]]; then
+ notify "Encode complete" "$(basename "$OUTPUT") — ${INSIZE} → ${OUTSIZE}"
+fi
diff --git a/roles/scripts/tasks/main.yml b/roles/scripts/tasks/main.yml
@@ -0,0 +1,70 @@
+---
+- name: Install script dependencies
+ ansible.builtin.apt:
+ name:
+ - sqlite3
+ - curl
+ state: present
+ update_cache: true
+ tags: [scripts]
+
+- name: Create jellyfin group for movie library permissions
+ ansible.builtin.group:
+ name: jellyfin
+ gid: "{{ jellyfin_gid }}"
+ state: present
+ tags: [scripts]
+
+- name: Add user to jellyfin group
+ ansible.builtin.user:
+ name: "{{ rip_user }}"
+ groups: jellyfin
+ append: true
+ tags: [scripts]
+
+- name: Create ~/.scripts directory
+ ansible.builtin.file:
+ path: /home/{{ rip_user }}/.scripts
+ state: directory
+ owner: "{{ rip_user }}"
+ group: "{{ rip_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Copy encode script
+ ansible.builtin.copy:
+ src: encode.sh
+ dest: /home/{{ rip_user }}/.scripts/encode
+ owner: "{{ rip_user }}"
+ group: "{{ rip_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Deploy rip script
+ ansible.builtin.template:
+ src: rip.sh.j2
+ dest: /home/{{ rip_user }}/.scripts/rip
+ owner: "{{ rip_user }}"
+ group: "{{ rip_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Deploy notify script
+ ansible.builtin.template:
+ src: notify.sh.j2
+ dest: /home/{{ rip_user }}/.scripts/notify
+ owner: "{{ rip_user }}"
+ group: "{{ rip_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Add ~/.scripts to PATH in .bashrc
+ ansible.builtin.lineinfile:
+ path: /home/{{ rip_user }}/.bashrc
+ line: 'export PATH="$HOME/.scripts:$PATH"'
+ state: present
+ create: true
+ owner: "{{ rip_user }}"
+ group: "{{ rip_user }}"
+ mode: '0644'
+ tags: [scripts]
diff --git a/roles/scripts/templates/notify.sh.j2 b/roles/scripts/templates/notify.sh.j2
@@ -0,0 +1,21 @@
+#!/bin/bash
+# Usage: notify "title" "message"
+# notify "message" (uses default title "MakeMKV")
+
+PUSHOVER_TOKEN="{{ pushover_app_token }}"
+PUSHOVER_USER="{{ pushover_user_key }}"
+PUSHOVER_URL="https://api.pushover.net/1/messages.json"
+
+case $# in
+ 1) TITLE="MakeMKV" ; MESSAGE="$1" ;;
+ 2) TITLE="$1" ; MESSAGE="$2" ;;
+ *) echo "Usage: notify [title] message" >&2; exit 1 ;;
+esac
+
+curl -s --max-time 10 \
+ -F "token=$PUSHOVER_TOKEN" \
+ -F "user=$PUSHOVER_USER" \
+ -F "title=$TITLE" \
+ -F "message=$MESSAGE" \
+ "$PUSHOVER_URL" > /dev/null \
+ || echo "Warning: Pushover notification failed" >&2
diff --git a/roles/scripts/templates/rip.sh.j2 b/roles/scripts/templates/rip.sh.j2
@@ -0,0 +1,171 @@
+#!/bin/bash
+set -euo pipefail
+
+OUTPUT_DIR="/mnt/Movies"
+DB="/mnt/Movies/.rip.db"
+SOFTWARE=""
+NAME_OVERRIDE=""
+
+usage() {
+ echo "Usage: rip [disc] [title] [crf] [--software] [--name \"Movie Title\"]"
+ echo ""
+ echo " disc disc number (default: 0)"
+ echo " title title number (default: 0, usually the main feature)"
+ echo " crf encode quality 18-28 (default: 22, lower = better quality)"
+ echo " --software use software x265 encoder instead of hardware"
+ echo " --name output filename base (default: auto-detected from disc)"
+ echo ""
+ echo "Examples:"
+ echo " rip"
+ echo " rip 0 1"
+ echo " rip 0 0 18"
+ echo " rip 0 0 18 --software"
+ echo " rip --name \"Raiders of the Lost Ark (1981)\""
+ echo " rip 0 0 22 --name \"Raiders of the Lost Ark (1981)\""
+ exit 1
+}
+
+[[ "${1:-}" == "-h" ]] && usage
+
+positional=()
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --software) SOFTWARE="--software"; shift ;;
+ --name) NAME_OVERRIDE="$2"; shift 2 ;;
+ *) positional+=("$1"); shift ;;
+ esac
+done
+
+DISC="${positional[0]:-0}"
+TITLE_NUM="${positional[1]:-0}"
+CRF="${positional[2]:-22}"
+
+DRIVE="/dev/sr0"
+
+if [[ ! -b "$DRIVE" ]]; then
+ echo "Error: no optical drive found at $DRIVE"
+ exit 1
+fi
+
+if ! dd if="$DRIVE" of=/dev/null bs=2048 count=1 status=none 2>/dev/null; then
+ echo "Error: no disc detected in $DRIVE"
+ exit 1
+fi
+
+sql_str() { printf '%s' "${1//\'/\'\'}"; }
+
+init_db() {
+ sqlite3 "$DB" <<'SQL'
+CREATE TABLE IF NOT EXISTS rips (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ disc_id TEXT NOT NULL,
+ title TEXT,
+ output_file TEXT,
+ output_bytes INTEGER,
+ ripped_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+SQL
+}
+
+# Always query disc info — needed for duplicate detection regardless of --name
+echo "Querying disc info..."
+RAW=$(flatpak run --command=makemkvcon com.makemkv.MakeMKV \
+ -r info "disc:$DISC" 2>/dev/null \
+ | awk -F'"' '/^CINFO:2,/ { print $2; exit }' || echo "")
+
+# Determine output movie name
+if [[ -n "$NAME_OVERRIDE" ]]; then
+ MOVIE_NAME="${NAME_OVERRIDE//\//-}"
+else
+ if [[ -n "$RAW" ]]; then
+ MOVIE_NAME=$(python3 -c "
+import sys, re
+JUNK = {'ws', 'fs', 'bd', 'uhd', 'hd', '3d', 'disc1', 'disc2', 'disc3', 'disc4'}
+parts = sys.argv[1].replace('_', ' ').split()
+clean = [p for p in parts if p and p.lower() not in JUNK and not re.match(r'^\d+x\d+$', p.lower())]
+print(' '.join(p.capitalize() for p in clean))
+" "$RAW")
+ else
+ MOVIE_NAME=""
+ fi
+ echo ""
+ if ! read -r -t 60 -p "Movie name [${MOVIE_NAME:-enter name}] (60s timeout): " input; then
+ echo ""
+ echo "No input received in 60s, using default: ${MOVIE_NAME:-<none>}"
+ fi
+ [[ -n "${input:-}" ]] && MOVIE_NAME="$input"
+ if [[ -z "$MOVIE_NAME" ]]; then
+ echo "Error: movie name is required"
+ exit 1
+ fi
+fi
+
+# Duplicate check
+if [[ -n "$RAW" ]] && command -v sqlite3 &>/dev/null; then
+ init_db
+ EXISTING=$(sqlite3 "$DB" \
+ "SELECT title, output_file, ripped_at FROM rips WHERE disc_id='$(sql_str "$RAW")' LIMIT 1;")
+ if [[ -n "$EXISTING" ]]; then
+ echo ""
+ echo "Warning: this disc has already been ripped:"
+ echo " $EXISTING"
+ echo ""
+ read -r -p "Rip again anyway? [y/N] " confirm
+ [[ "${confirm,,}" != "y" ]] && { echo "Aborted."; exit 0; }
+ fi
+fi
+
+echo "Ripping title $TITLE_NUM from disc $DISC to $OUTPUT_DIR..."
+MARKER=$(mktemp)
+flatpak run --command=makemkvcon com.makemkv.MakeMKV \
+ mkv "disc:$DISC" "$TITLE_NUM" "$OUTPUT_DIR"
+
+RIPPED=$(find "$OUTPUT_DIR" -maxdepth 2 -name "*.mkv" -not -name "*_x265.mkv" \
+ -newer "$MARKER" -type f | head -1)
+rm -f "$MARKER"
+
+if [[ -z "$RIPPED" ]]; then
+ echo "Error: could not find ripped MKV in $OUTPUT_DIR"
+ exit 1
+fi
+echo "Ripped: $RIPPED"
+
+if [[ -n "$MOVIE_NAME" ]]; then
+ mkdir -p "$OUTPUT_DIR/$MOVIE_NAME"
+ chgrp {{ jellyfin_gid }} "$OUTPUT_DIR/$MOVIE_NAME" 2>/dev/null || true
+ chmod 2775 "$OUTPUT_DIR/$MOVIE_NAME"
+ DEST="$OUTPUT_DIR/$MOVIE_NAME/$MOVIE_NAME.mkv"
+ mv "$RIPPED" "$DEST"
+ RIPPED="$DEST"
+ echo "Moved to: $RIPPED"
+fi
+
+ENCODED="${RIPPED%.*}_x265.mkv"
+echo "Encoding with English audio and subtitles only..."
+MAKEMKV_RIP=1 encode "$RIPPED" "$ENCODED" "$CRF" $SOFTWARE
+
+INSIZE=$(du -sh "$RIPPED" | cut -f1)
+OUTSIZE=$(du -sh "$ENCODED" | cut -f1)
+rm "$RIPPED"
+
+# Record in DB
+if [[ -n "$RAW" ]] && command -v sqlite3 &>/dev/null; then
+ OUTPUT_BYTES=$(stat -c%s "$ENCODED")
+ sqlite3 "$DB" "INSERT INTO rips (disc_id, title, output_file, output_bytes) \
+ VALUES ('$(sql_str "$RAW")', '$(sql_str "$MOVIE_NAME")', '$(sql_str "$ENCODED")', $OUTPUT_BYTES);"
+fi
+
+echo ""
+echo "Done. Kept $ENCODED ($OUTSIZE), removed source ($INSIZE)"
+
+command -v notify &>/dev/null && notify "Rip complete" "$MOVIE_NAME — ${INSIZE} → ${OUTSIZE}"
+
+echo "Triggering Jellyfin library scan..."
+curl -s -X POST "{{ jellyfin_url }}/ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed" \
+ -H "X-Emby-Token: {{ jellyfin_api_key }}" \
+ -o /dev/null \
+ && echo "Scan triggered." \
+ || echo "Warning: could not reach Jellyfin at {{ jellyfin_url }}"
+
+echo "Ejecting disc..."
+eject "$DRIVE" 2>/dev/null || echo "Warning: could not eject $DRIVE"
diff --git a/site.yml b/site.yml
@@ -0,0 +1,14 @@
+---
+- name: Configure Proxmox headless ripping host
+ hosts: proxmox_host
+ become: true
+
+ roles:
+ - role: flatpaks
+ tags: [flatpaks]
+
+ - role: gpu
+ tags: [gpu]
+
+ - role: scripts
+ tags: [scripts]