makemkv-workstation

Log | Files | Refs | README

commit 11b4e5637d4309646cce7181cb9f4f95df8ed8fc
Author: Chris Roberts <chris.roberts@learningunix.net>
Date:   Wed, 12 Aug 2026 12:15:34 -0500

Initial commit

Diffstat:
A.gitignore | 1+
ACLAUDE.md | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agroup_vars/all/vars.yml | 14++++++++++++++
Ainventory/hosts.yml | 6++++++
Aroles/flatpaks/tasks/main.yml | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroles/nfs/tasks/main.yml | 44++++++++++++++++++++++++++++++++++++++++++++
Aroles/scripts/files/encode.sh | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroles/scripts/tasks/main.yml | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroles/scripts/templates/notify.sh.j2 | 21+++++++++++++++++++++
Aroles/scripts/templates/rip.sh.j2 | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroles/system_update/tasks/main.yml | 22++++++++++++++++++++++
Aroles/vnc/defaults/main.yml | 6++++++
Aroles/vnc/handlers/main.yml | 9+++++++++
Aroles/vnc/tasks/main.yml | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroles/vnc/templates/vncserver@.service.j2 | 21+++++++++++++++++++++
Aroles/vnc/templates/xstartup.j2 | 4++++
Asite.yml | 21+++++++++++++++++++++
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] +