commit 11b4e5637d4309646cce7181cb9f4f95df8ed8fc
Author: Chris Roberts <chris.roberts@learningunix.net>
Date: Wed, 12 Aug 2026 12:15:34 -0500
Initial commit
Diffstat:
18 files changed, 828 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+group_vars/all/vault.yml
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -0,0 +1,77 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this project is
+
+An Ansible playbook that provisions a Debian desktop machine for Blu-ray/DVD archiving. It installs MakeMKV (via Flatpak), HandBrake, ffmpeg, a TigerVNC server with XFCE4, NFS client storage, and two ripping/encoding scripts.
+
+Target host: `192.168.0.118` (user `cjr`), defined in `inventory/hosts.yml`. Passwordless SSH access to this host is available (`ssh cjr@192.168.0.118`) — use it directly to inspect logs, run `rip`/`encode` diagnostics, query the disc via `makemkvcon`, etc.
+
+## Running the playbook
+
+```bash
+# Full provision
+ansible-playbook -i inventory/hosts.yml site.yml --ask-vault-pass
+
+# Run a specific role only
+ansible-playbook -i inventory/hosts.yml site.yml --tags vnc --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags flatpaks --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags nfs --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags scripts --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags update --ask-vault-pass
+
+# Dry run
+ansible-playbook -i inventory/hosts.yml site.yml --check --ask-vault-pass
+
+# Lint
+ansible-lint site.yml
+```
+
+## Secrets / vault
+
+`group_vars/all/vault.yml` is Ansible Vault-encrypted (AES256). It holds `vnc_password`. Always pass `--ask-vault-pass` when running the playbook. Never commit plaintext secrets.
+
+To edit vault contents:
+```bash
+ansible-vault edit group_vars/all/vault.yml
+```
+
+## Architecture
+
+### Role overview
+
+| Role | Purpose |
+|------|---------|
+| `system_update` | `apt dist-upgrade` + autoremove/autoclean |
+| `vnc` | TigerVNC on display `:5`, XFCE4 session, systemd service `vncserver@5` |
+| `flatpaks` | Flathub remote, MakeMKV + HandBrake flatpaks, DVD libs (`libdvdcss` via `libdvd-pkg`), `ffmpeg` |
+| `nfs` | Mounts NFS share `192.168.0.240:/mnt/Movies` → `/mnt/Movies` (persisted in fstab) |
+| `scripts` | Deploys `rip` and `encode` to `~/.scripts/` and adds it to `PATH` |
+
+### Variables
+
+All non-secret variables live in `group_vars/all/vars.yml`. Role defaults are in `roles/vnc/defaults/main.yml` and are overridden by `group_vars`. The vault provides `vnc_password`.
+
+Key variables:
+- `vnc_user` / `vnc_uid` / `vnc_display` / `vnc_geometry` / `vnc_depth` — TigerVNC config (`vnc_uid` must match the actual UID of `vnc_user` on the target; default 1000)
+- `nfs_server` / `nfs_export` / `nfs_mountpoint` / `nfs_opts` — NFS mount config
+
+### Rip database
+
+`/mnt/Movies/.rip.db` is a SQLite database that tracks every completed rip. Before ripping, `rip` checks the disc's label against this database and prompts if a duplicate is detected. To query it:
+
+```bash
+sqlite3 /mnt/Movies/.rip.db "SELECT title, output_file, ripped_at FROM rips ORDER BY ripped_at DESC;"
+```
+
+### The rip/encode pipeline
+
+`rip.sh` and `encode.sh` are deployed to `~/.scripts/` on the target. They form a two-step pipeline:
+
+1. **`rip [disc] [title] [crf] [--software] [--name "Movie Title"]`** — verifies `/dev/sr0` exists and a disc is actually readable in it, queries the disc title via `makemkvcon info` (auto-names the output), prompts for a name override (falls back to the auto-detected name after a 60s timeout so it never hangs unattended), extracts an MKV to `/mnt/Movies`, calls `encode` on the result, then ejects the disc. `--name` overrides the auto-detected title.
+2. **`encode <input.mkv> [output.mkv] [crf] [--software]`** — transcodes to x265 using VA-API hardware encoding (`hevc_vaapi` on `/dev/dri/renderD128`) by default, or `libx265` software with `--software`. Strips non-English audio and subtitle tracks via `ffprobe` stream inspection.
+
+The user (`cjr`) is added to the `video` and `render` groups by the VNC role to enable GPU access for VA-API. `/dev/sr0` access is a static `cdrom` group grant (`cjr` is already a member) — not tied to any login session, so `rip` works the same whether run over SSH, VNC, or a background service.
+
+MakeMKV Flatpak is granted access to `home` and `/mnt/Movies` via `flatpak override`.
diff --git a/README.md b/README.md
@@ -0,0 +1,82 @@
+# makemkv
+
+Ansible playbook that provisions a Debian desktop as a Blu-ray/DVD archiving box: MakeMKV and HandBrake (via Flatpak), ffmpeg, a headless TigerVNC + XFCE4 desktop, an NFS-mounted movie library, and a `rip`/`encode` script pair for turning a disc into a Jellyfin-ready x265 file.
+
+## Target
+
+| | |
+|---|---|
+| Host | `192.168.0.118` (`debian_desktop` in `inventory/hosts.yml`) |
+| User | `cjr` |
+| Optical drive | `/dev/sr0` (USB Blu-ray writer) |
+| Library | NFS share `192.168.0.240:/mnt/Movies` → `/mnt/Movies` |
+
+## 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 vnc --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags flatpaks --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags nfs --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags scripts --ask-vault-pass
+ansible-playbook -i inventory/hosts.yml site.yml --tags update --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 holds `vnc_password`. Always pass `--ask-vault-pass`. Never commit plaintext secrets.
+
+```bash
+ansible-vault edit group_vars/all/vault.yml
+```
+
+## Roles
+
+| Role | Purpose |
+|------|---------|
+| `system_update` | `apt dist-upgrade` + autoremove/autoclean |
+| `vnc` | TigerVNC on display `:5`, XFCE4 session, systemd service `vncserver@5`; adds `cjr` to `video`/`render` for VA-API GPU access |
+| `flatpaks` | Flathub remote, MakeMKV + HandBrake flatpaks, DVD libs (`libdvdcss` via `libdvd-pkg`), `ffmpeg` |
+| `nfs` | Mounts `192.168.0.240:/mnt/Movies` → `/mnt/Movies`, persisted in fstab |
+| `scripts` | Deploys `rip`, `encode`, and `notify` to `~/.scripts/` and adds it to `PATH` |
+
+Key variables live in `group_vars/all/vars.yml`; role defaults live under `roles/*/defaults/`.
+
+## The rip → encode pipeline
+
+`rip` and `encode` are deployed to `~/.scripts/` on the target and used interactively over SSH or the VNC desktop.
+
+```
+rip [disc] [title] [crf] [--software] [--name "Movie Title"]
+```
+
+1. Verifies `/dev/sr0` exists and a disc is actually readable in it before doing anything else.
+2. Queries the disc title via `makemkvcon info` and proposes an auto-detected movie name (stripping junk tokens like `WS`/`FS`/`4X3`).
+3. Prompts for a name override — auto-accepts the detected name after 60 seconds of no input, so the pipeline never hangs unattended.
+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, 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` — 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,14 @@
+---
+vnc_user: cjr
+vnc_uid: 1000
+vnc_display: 5
+vnc_geometry: "1920x1080"
+vnc_depth: 24
+
+nfs_server: 192.168.0.240
+nfs_export: /mnt/Movies
+nfs_mountpoint: /mnt/Movies
+nfs_opts: "defaults,_netdev,auto,rw"
+
+jellyfin_url: http://192.168.0.241:8096
+jellyfin_gid: 110
diff --git a/inventory/hosts.yml b/inventory/hosts.yml
@@ -0,0 +1,6 @@
+---
+all:
+ hosts:
+ debian_desktop:
+ ansible_host: 192.168.0.118
+ ansible_user: cjr
diff --git a/roles/flatpaks/tasks/main.yml b/roles/flatpaks/tasks/main.yml
@@ -0,0 +1,66 @@
+---
+- 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 NFS mount
+ ansible.builtin.command:
+ cmd: flatpak override --user --filesystem=home --filesystem=/mnt/Movies com.makemkv.MakeMKV
+ become: true
+ become_user: "{{ vnc_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]
+
+- name: Install HandBrake from Flathub
+ community.general.flatpak:
+ name: fr.handbrake.ghb
+ state: present
+ remote: flathub
+ method: system
+ tags: [flatpaks, handbrake]
diff --git a/roles/nfs/tasks/main.yml b/roles/nfs/tasks/main.yml
@@ -0,0 +1,44 @@
+---
+- name: Install NFS client
+ ansible.builtin.apt:
+ name: nfs-common
+ state: present
+ update_cache: true
+ tags: [nfs, install]
+
+- name: Check if NFS already mounted
+ ansible.builtin.command: mountpoint -q {{ nfs_mountpoint }}
+ register: nfs_already_mounted
+ changed_when: false
+ failed_when: false
+ tags: [nfs, configure]
+
+- name: Create NFS mount point
+ ansible.builtin.file:
+ path: "{{ nfs_mountpoint }}"
+ state: directory
+ mode: '0755'
+ when: nfs_already_mounted.rc != 0
+ tags: [nfs, configure]
+
+- name: Look up group name for Jellyfin GID
+ ansible.builtin.command: getent group {{ jellyfin_gid }}
+ register: jellyfin_group_entry
+ changed_when: false
+ tags: [nfs, configure]
+
+- name: Add user to GID {{ jellyfin_gid }} group for NFS write access
+ ansible.builtin.user:
+ name: "{{ vnc_user }}"
+ groups: "{{ jellyfin_group_entry.stdout.split(':')[0] }}"
+ append: true
+ tags: [nfs, configure]
+
+- name: Mount NFS share and persist in fstab
+ ansible.posix.mount:
+ path: "{{ nfs_mountpoint }}"
+ src: "{{ nfs_server }}:{{ nfs_export }}"
+ fstype: nfs
+ opts: "{{ nfs_opts }}"
+ state: mounted
+ tags: [nfs, mount]
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,56 @@
+---
+- name: Install script dependencies
+ ansible.builtin.apt:
+ name:
+ - sqlite3
+ - curl
+ state: present
+ update_cache: true
+ tags: [scripts]
+
+- name: Create ~/.scripts directory
+ ansible.builtin.file:
+ path: /home/{{ vnc_user }}/.scripts
+ state: directory
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Copy encode script
+ ansible.builtin.copy:
+ src: encode.sh
+ dest: /home/{{ vnc_user }}/.scripts/encode
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Deploy rip script
+ ansible.builtin.template:
+ src: rip.sh.j2
+ dest: /home/{{ vnc_user }}/.scripts/rip
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Deploy notify script
+ ansible.builtin.template:
+ src: notify.sh.j2
+ dest: /home/{{ vnc_user }}/.scripts/notify
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0755'
+ tags: [scripts]
+
+- name: Add ~/.scripts to PATH in .bashrc
+ ansible.builtin.lineinfile:
+ path: /home/{{ vnc_user }}/.bashrc
+ line: 'export PATH="$HOME/.scripts:$PATH"'
+ state: present
+ create: true
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_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,188 @@
+#!/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: auto-detected as the longest title — 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_ARG="${positional[1]:-}"
+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 and title auto-detection regardless of --name
+echo "Querying disc info..."
+INFO_RAW=$(flatpak run --command=makemkvcon com.makemkv.MakeMKV \
+ -r info "disc:$DISC" 2>/dev/null || echo "")
+RAW=$(printf '%s\n' "$INFO_RAW" | awk -F'"' '/^CINFO:2,/ { print $2; exit }')
+
+# 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
+
+# MakeMKV numbers titles by discovery order, not duration, so title 0 is not
+# reliably the main feature. Default to the longest title unless one is given.
+if [[ -n "$TITLE_ARG" ]]; then
+ TITLE_NUM="$TITLE_ARG"
+else
+ TITLE_NUM=$(printf '%s\n' "$INFO_RAW" \
+ | grep '^TINFO:[0-9]*,9,0,' \
+ | sed -E 's/^TINFO:([0-9]+),9,0,"([0-9]+):([0-9]+):([0-9]+)"/\1 \2 \3 \4/' \
+ | awk '{secs=$2*3600+$3*60+$4; if (secs>max) {max=secs; best=$1}} END{print best}')
+ if [[ -z "$TITLE_NUM" ]]; then
+ echo "Warning: could not auto-detect main feature title, defaulting to title 0"
+ TITLE_NUM=0
+ else
+ echo "Auto-detected main feature: title $TITLE_NUM"
+ 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/roles/system_update/tasks/main.yml b/roles/system_update/tasks/main.yml
@@ -0,0 +1,22 @@
+---
+- name: Update apt cache
+ ansible.builtin.apt:
+ update_cache: true
+ cache_valid_time: 3600
+ tags: [update]
+
+- name: Upgrade all packages to latest
+ ansible.builtin.apt:
+ upgrade: dist
+ tags: [update]
+
+- name: Remove orphaned packages
+ ansible.builtin.apt:
+ autoremove: true
+ purge: true
+ tags: [update]
+
+- name: Clean apt cache
+ ansible.builtin.apt:
+ autoclean: true
+ tags: [update]
diff --git a/roles/vnc/defaults/main.yml b/roles/vnc/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+vnc_user: cjr
+vnc_display: 5
+vnc_geometry: "1920x1080"
+vnc_depth: 24
+vnc_password: "changeme"
diff --git a/roles/vnc/handlers/main.yml b/roles/vnc/handlers/main.yml
@@ -0,0 +1,9 @@
+---
+- name: Reload systemd
+ ansible.builtin.systemd:
+ daemon_reload: true
+
+- name: Restart VNC service
+ ansible.builtin.systemd:
+ name: "vncserver@{{ vnc_display }}"
+ state: restarted
diff --git a/roles/vnc/tasks/main.yml b/roles/vnc/tasks/main.yml
@@ -0,0 +1,91 @@
+---
+- name: Install TigerVNC and dependencies
+ ansible.builtin.apt:
+ name:
+ - tigervnc-standalone-server
+ - tigervnc-common
+ - dbus-x11
+ - xfce4
+ - xfce4-goodies
+ state: present
+ update_cache: true
+ tags: [vnc, install]
+
+- name: Create .vnc directory for user
+ ansible.builtin.file:
+ path: /home/{{ vnc_user }}/.vnc
+ state: directory
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0700'
+ tags: [vnc, configure]
+
+- name: Create .config/tigervnc directory for user
+ ansible.builtin.file:
+ path: /home/{{ vnc_user }}/.config/tigervnc
+ state: directory
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0700'
+ tags: [vnc, configure]
+
+- name: Set VNC password
+ ansible.builtin.shell:
+ cmd: "printf '%s' '{{ vnc_password }}' | vncpasswd -f > /home/{{ vnc_user }}/.config/tigervnc/passwd"
+ become: true
+ become_user: "{{ vnc_user }}"
+ no_log: true
+ changed_when: true
+ tags: [vnc, configure]
+
+- name: Lock down VNC passwd file
+ ansible.builtin.file:
+ path: /home/{{ vnc_user }}/.config/tigervnc/passwd
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0600'
+ tags: [vnc, configure]
+
+- name: Add user to video and render groups for GPU access
+ ansible.builtin.user:
+ name: "{{ vnc_user }}"
+ groups: video,render
+ append: true
+ tags: [vnc, configure, gpu]
+
+- name: Allow user to poweroff without password
+ ansible.builtin.copy:
+ dest: /etc/sudoers.d/{{ vnc_user }}-poweroff
+ content: "{{ vnc_user }} ALL=(ALL) NOPASSWD: /sbin/poweroff\n"
+ mode: '0440'
+ tags: [vnc, configure]
+
+- name: Deploy xstartup script
+ ansible.builtin.template:
+ src: xstartup.j2
+ dest: /home/{{ vnc_user }}/.vnc/xstartup
+ owner: "{{ vnc_user }}"
+ group: "{{ vnc_user }}"
+ mode: '0755'
+ notify: Restart VNC service
+ tags: [vnc, configure]
+
+- name: Deploy VNC systemd service unit
+ ansible.builtin.template:
+ src: vncserver@.service.j2
+ dest: /etc/systemd/system/vncserver@.service
+ owner: root
+ group: root
+ mode: '0644'
+ notify:
+ - Reload systemd
+ - Restart VNC service
+ tags: [vnc, service]
+
+- name: Enable and start VNC service
+ ansible.builtin.systemd:
+ name: "vncserver@{{ vnc_display }}"
+ enabled: true
+ state: started
+ daemon_reload: true
+ tags: [vnc, service]
diff --git a/roles/vnc/templates/vncserver@.service.j2 b/roles/vnc/templates/vncserver@.service.j2
@@ -0,0 +1,21 @@
+[Unit]
+Description=Remote desktop service (VNC) for display :%i
+After=syslog.target network.target
+
+[Service]
+Type=simple
+User={{ vnc_user }}
+Group={{ vnc_user }}
+WorkingDirectory=/home/{{ vnc_user }}
+
+ExecStartPre=-/bin/sh -c '/usr/bin/vncserver -kill :%i > /dev/null 2>&1'
+ExecStartPre=-/bin/rm -f /tmp/.X%i-lock /tmp/.X11-unix/X%i
+ExecStart=/usr/bin/vncserver :%i \
+ -geometry {{ vnc_geometry }} \
+ -depth {{ vnc_depth }} \
+ -localhost no \
+ -fg
+ExecStop=/usr/bin/vncserver -kill :%i
+
+[Install]
+WantedBy=multi-user.target
diff --git a/roles/vnc/templates/xstartup.j2 b/roles/vnc/templates/xstartup.j2
@@ -0,0 +1,4 @@
+#!/bin/sh
+unset SESSION_MANAGER
+unset DBUS_SESSION_BUS_ADDRESS
+exec dbus-launch --exit-with-session startxfce4
diff --git a/site.yml b/site.yml
@@ -0,0 +1,21 @@
+---
+- name: Configure Debian Desktop
+ hosts: debian_desktop
+ become: true
+
+ roles:
+ - role: system_update
+ tags: [update]
+
+ - role: vnc
+ tags: [vnc]
+
+ - role: flatpaks
+ tags: [flatpaks]
+
+ - role: nfs
+ tags: [nfs]
+
+ - role: scripts
+ tags: [scripts]
+