Build Fix Learn Guide

Complete ARM Setup Guide

This is the complete Build Fix Learn procedure for recreating the Automated Ripping Machine and Jellyfin server. It covers the hardware baseline, Ubuntu, RAID1 storage, permissions, Jellyfin, GPU transcoding, MakeMKV, HandBrakeCLI, movie mapping, TV episode mapping, disc fingerprints, the ARM pipeline, automatic Jellyfin library movement, testing, troubleshooting, recovery, and the full sanitized production source.

Reading this guide

You can read the entire build procedure directly on this page, including the current sanitized production scripts, configuration example, systemd service, database initialization code, and arm-status dashboard. The downloadable .md file contains the same material for offline use.

Offline copy

Download the complete guide

The Markdown download contains the same complete procedure and source shown below. The source ZIP is also available if you want the sanitized production files already separated into their installable directory structure.

Guide Contents

This online guide is the complete V12 build guide, not a shortened article companion. It includes the full installation procedure and the sanitized production source needed to recreate the ARM software.

Build and installation: hardware baseline, Ubuntu, RAID1, permissions, Jellyfin, NVIDIA/NVENC, MakeMKV, HandBrakeCLI, ARM account, directories, configuration, SQLite database, and systemd.

Automation logic: disc inspection, movie classification and main-feature mapping, TV classification and episode mapping, independent parallel ripping, the single transcode queue, automatic Jellyfin library movement, generic-disc fingerprinting, cleanup, restart recovery, correction tools, and the terminal dashboard.

Full source appendix: arm.conf.example, arm.service, arm-status, arm_config.py, arm_logger.py, init_db.py, arm_orchestrator.py, disc_inspector.py, title_selector.py, ripper.py, transcoder.py, library_mover.py, cleanup_rips.py, recovery_check.py, and job_corrector.py.

The public source is sanitized. Personal information, usernames, private network addresses, API credentials, machine-specific identifiers, production databases, old backup revisions, and compiled cache files are not published.

ARM / Jellyfin Server Build Procedure

Known-Good Two-Drive Parallel Pipeline

Version: 1.2 (Build Fix Learn V12)
Original known-good baseline: 2026-08-16
V12 source audit: 2026-09-07

Purpose: Recreate the current Automated Ripping Machine (ARM) and Jellyfin server from a fresh Ubuntu installation. This is a living document and should be updated whenever hardware, storage, Blu-ray support, pipeline logic, transcoding, or recovery procedures change.


1. Final Architecture

The server is designed to:

  1. Detect inserted DVD discs automatically.
  2. Inspect multiple optical drives independently.
  3. Select the appropriate movie or TV titles.
  4. Rip independently from each optical drive.
  5. Allow multiple optical drives to rip simultaneously.
  6. Queue completed rips for transcoding.
  7. Run only one HandBrake transcode worker at a time.
  8. Move completed media into the Jellyfin library.
  9. Clean temporary working files only when safe.
  10. Recover orphaned RIPPING or TRANSCODING jobs after interrupted ARM restarts.

Current optical drives:

  • /dev/sr0 — hp DVD A DH16AAL
  • /dev/sr1 — ATAPI iHAS124 C

The former slow external /dev/sr2 drive was removed from production.

Future plan: add a Blu-ray optical drive and extend the pipeline as needed.


2. Hardware Baseline

The build began with a reused gaming desktop. The Intel Core i7-4790K, 16 GB of RAM, case/motherboard, and DVD drives were already available. The main hardware added for the ARM/Jellyfin conversion was the 1 TB SSD, two 2 TB hard drives, NVIDIA Quadro P400, and a Wi-Fi card. The finished server is intended to run headless during normal use, so it does not need a dedicated monitor, keyboard, or mouse.

  • CPU: Intel Core i7-4790K, reused
  • RAM: 16 GB, reused
  • NVIDIA Quadro P400: purchased for NVENC transcoding
  • OS/rip SSD: 1 TB SSD, purchased
  • Media RAID: 2 × 2 TB hard drives in RAID1, purchased
  • Wi-Fi card: network connection for headless operation where Ethernet is inconvenient
  • Optical drives: two internal SATA DVD drives, reused

Current disk layout:

/dev/sda1 ┐
          ├── /dev/md0 RAID1 ext4 label=JELLYFIN -> /mnt/jellyfin
/dev/sdb1 ┘

/dev/sdc1 FAT32 EFI -> /boot/efi
/dev/sdc2 ext4 root -> /

/rips is a directory on the root SSD.

Always verify device identities before destructive work:

lsblk -o NAME,SIZE,FSTYPE,LABEL,MODEL,TRAN,MOUNTPOINTS

3. Install Ubuntu

Install Ubuntu Server 26.04 in UEFI mode on the SSD.

Recommended layout:

  • EFI: about 1 GB FAT32
  • Root: remaining SSD space, ext4

After installation:

sudo apt update
sudo apt full-upgrade

Verify:

lsblk -f
df -hT / /boot/efi

4. Build the Jellyfin RAID1

WARNING: Verify the correct media disks first. These operations can destroy data.

lsblk -o NAME,SIZE,MODEL,SERIAL

Create RAID1 using the two media partitions:

sudo mdadm --create /dev/md0 \
  --level=1 \
  --raid-devices=2 \
  /dev/sda1 /dev/sdb1

Format:

sudo mkfs.ext4 -L JELLYFIN /dev/md0

Create mount point:

sudo mkdir -p /mnt/jellyfin

Find UUID:

sudo blkid /dev/md0

Add the filesystem UUID to /etc/fstab:

UUID=<JELLYFIN-FILESYSTEM-UUID> /mnt/jellyfin ext4 defaults 0 2

Test:

sudo mount -a
findmnt /mnt/jellyfin
cat /proc/mdstat
sudo mdadm --detail /dev/md0

5. Create Media Directories and Permissions

sudo mkdir -p \
  /mnt/jellyfin/Movies \
  "/mnt/jellyfin/TV Shows"

ARM needs write access; Jellyfin needs read access.

Example ACLs:

sudo setfacl -m u:arm:rwx /mnt/jellyfin
sudo setfacl -m u:jellyfin:rx /mnt/jellyfin
sudo setfacl -d -m u:arm:rwx /mnt/jellyfin
sudo setfacl -d -m u:jellyfin:rx /mnt/jellyfin

Test ARM writes:

sudo -u arm touch /mnt/jellyfin/.arm-write-test
sudo rm /mnt/jellyfin/.arm-write-test

6. Install Jellyfin

Install Jellyfin from the official Jellyfin repository.

Then:

sudo systemctl enable jellyfin
sudo systemctl start jellyfin
sudo systemctl status jellyfin --no-pager

Verify user/group access:

id jellyfin

Configure Jellyfin libraries:

Movies:   /mnt/jellyfin/Movies
TV Shows: /mnt/jellyfin/TV Shows

7. Configure NVIDIA Transcoding

Install the appropriate NVIDIA driver for the Quadro P400.

Verify:

nvidia-smi
ls -l /dev/nvidia*
ls -l /dev/dri/

The known-good HandBrake configuration uses NVENC H.264 with quality 15, automatic frame rate, no crop, preserved display aspect, all audio, all subtitles, optimized MP4 output.


8. Install MakeMKV and HandBrakeCLI

Required commands:

makemkvcon
HandBrakeCLI

Verify:

command -v makemkvcon
command -v HandBrakeCLI

Test optical drives:

makemkvcon -r info disc:0
makemkvcon -r info disc:1

9. Create the ARM Service Account

Create a dedicated arm account with a nologin shell.

It must have access to optical drives through the cdrom group.

Verify:

id arm

If needed:

sudo usermod -aG cdrom arm

10. ARM Directory Layout

Create:

sudo mkdir -p \
  /opt/arm/backups \
  /opt/arm/config \
  /opt/arm/docs \
  /opt/arm/logs \
  /opt/arm/pipelines \
  /opt/arm/scripts \
  /opt/arm/state

Important paths:

/opt/arm/config/arm.conf
/opt/arm/state/arm.db
/opt/arm/scripts/arm_orchestrator.py

Core scripts:

arm_config.py
arm_orchestrator.py
cleanup_rips.py
disc_inspector.py
library_mover.py
ripper.py
title_selector.py
transcoder.py

11. Create the Rip Workspace

/rips is on the OS SSD.

sudo mkdir -p \
  /rips/incoming \
  /rips/ripping \
  /rips/transcoding/output \
  /rips/encoding \
  /rips/completed \
  /rips/failed \
  /rips/logs

Set ownership:

sudo chown -R arm:arm /rips

Test:

sudo -u arm touch /rips/.arm-test
sudo rm /rips/.arm-test

12. ARM Configuration

Current production optical section:

[optical]
drive_0 = /dev/sr0
drive_1 = /dev/sr1

The removed external drive must not be present.

Important production setting:

auto_library_move = true

Jellyfin media root:

[jellyfin]
media_root = /mnt/jellyfin

Automation should enable ripping, transcoding, library movement, and safe cleanup.


13. ARM Database

SQLite database:

/opt/arm/state/arm.db

Important tables:

jobs
job_files
job_titles
events
system_state

Integrity and recent jobs:

sudo python3 - <<'PY'
import sqlite3
conn = sqlite3.connect("/opt/arm/state/arm.db")
print(conn.execute("PRAGMA integrity_check").fetchone())
for row in conn.execute("SELECT id, drive, disc_label, status FROM jobs ORDER BY id DESC LIMIT 10"):
    print(row)
PY

Expected integrity result:

('ok',)

14. Disc Inspection Behavior

Each configured optical drive may have one active disc_inspector.py process.

While an inspector owns a drive, the orchestrator must not run another MakeMKV detection probe against that same drive. This prevents competing MakeMKV information scans.

DVD inspection currently uses a 600-second timeout.


15. Movie and TV Classification

disc_inspector.py classifies discs as movie or TV content.

TV recognition uses season-style labels such as:

SEASON
S01
S1

Known successful TV example:

GAME_OF_THRONES_S1_DISC1

Movie discs use feature-title scoring.


16. Title Selection

Jobs progress:

INSPECTED -> TITLE_SELECTED

Movie flow normally selects one primary feature. TV discs may select multiple episode titles.

Selections are stored in job_titles.selected.


16A. Movie Mapping and Main-Feature Selection

Movie and TV title selection are separate paths in the production selector. A movie job selects exactly one main feature.

For every inspected title, title_selector.py calculates a feature score from:

  • runtime
  • chapter count
  • file size

The current duration bonuses are strongest at 90 minutes or longer, then 60 minutes or longer, then 30 minutes or longer. Runtime also contributes directly, chapter count adds weight, and file size adds a smaller capped weight. Candidates are sorted by score, then duration and size. The highest-scoring title is marked selected = 1, every other title for that movie job is cleared, and the job advances to TITLE_SELECTED.

Conceptually:

MOVIE DISC
   -> inspect titles
   -> score runtime + chapters + size
   -> select one main feature
   -> MakeMKV rip
   -> HandBrake NVENC transcode
   -> library mover
   -> /mnt/jellyfin/Movies/<MOVIE>/<MOVIE>.mp4

16B. TV Mapping and Episode Selection

TV jobs may select multiple titles. The production selector first filters episode candidates by duration, file size, and chapter structure, then applies grouping logic so it does not blindly accept every title on the disc. Selected episodes are kept in disc/title order and assigned season and episode metadata that follows the file through ripping, transcoding, correction, and library movement.

TV classification can use season markers and configured overrides. Ambiguous publisher volume labels can be held for metadata confirmation instead of treating a volume number as a season number.

Conceptually:

TV DISC
   -> classify / confirm show and season
   -> inspect titles
   -> choose coherent episode candidates
   -> assign SxxExx mapping
   -> rip each selected title
   -> transcode each episode
   -> /mnt/jellyfin/TV Shows/<SHOW>/Season XX/

17. Independent Parallel Ripping

The orchestrator tracks active ripper workers per drive.

Conceptually:

ACTIVE_RIPPERS = {
    "/dev/sr0": process,
    "/dev/sr1": process
}

Only one ripper worker is allowed per drive.

The orchestrator does not wait for all rippers to finish before continuing its main loop.

Valid concurrent behavior:

SR0: RIPPING
SR1: INSPECTING / TITLE_SELECTED / RIPPING

At peak, both may run:

makemkvcon -r mkv disc:0 ...
makemkvcon -r mkv disc:1 ...

18. Single Transcode Queue

Completed jobs become:

RIPPED

The orchestrator permits exactly one active transcoder.py worker at a time. The oldest eligible ripped job enters the transcode worker.

Correct overlap:

Job A: TRANSCODING
Job B: RIPPING

Verify:

sudo ps -eo pid,ppid,etime,args | \
grep -E '[m]akemkvcon|[H]andBrakeCLI|[t]ranscoder.py'

19. Library Movement

Movies:

/mnt/jellyfin/Movies/<MOVIE_NAME>/<MOVIE_NAME>.mp4

TV:

/mnt/jellyfin/TV Shows/<SHOW>/Season 01/<episode>.mp4

Jobs are marked LIBRARY_MOVED only after destination verification.

Existing destinations are protected. ARM should fail with LIBRARY_DESTINATION_EXISTS rather than overwrite existing media.


19A. Generic Disc Fingerprints

Some DVDs identify themselves with generic labels such as DVD_VIDEO. The production disc_inspector.py therefore builds a stable SHA-256 fingerprint from normalized MakeMKV title metadata instead of relying only on the physical disc label.

For each title, the fingerprint includes:

  • MakeMKV title ID, falling back to ARM title number
  • duration in seconds
  • size rounded to MiB
  • chapter count

It intentionally excludes drive path, drive model, timestamp, and physical disc label so the same disc should produce the same fingerprint in either optical drive. For a generic label, ARM can derive a temporary human-facing title using the first 12 characters of the fingerprint and can supersede a repeat inspection when the same fingerprint has already reached LIBRARY_MOVED.

20. Cleanup

cleanup_rips.py deletes working data only when safe.

During active processing, BLOCKED is expected.

A clean system should eventually show:

Eligible jobs:        0
Safe to remove:       0.00 GB
Blocked working data: 0.00 GB

Failed jobs may remain in database history after useless partial files are removed.


21. Restart Recovery

ARM includes recovery for orphaned jobs.

On startup, orphaned states may be returned to retryable states:

RIPPING      -> TITLE_SELECTED
TRANSCODING  -> RIPPED

This protects against interrupted service restarts or reboots.


22. systemd Service

ARM service:

arm.service

Useful commands:

sudo systemctl enable arm
sudo systemctl start arm
sudo systemctl status arm --no-pager
sudo journalctl -u arm -f

23. Known-Good Functional Test

Test A — One DVD

Expected event sequence:

DISC_INSERTED
DISC_INSPECTED
TITLE_SELECTED
RIP_STARTED
RIP_COMPLETED
TRANSCODE_COMPLETED
LIBRARY_MOVE_COMPLETED

Test B — Two DVDs

Insert one disc in each optical drive and verify both can rip simultaneously.

Test C — Pipeline overlap

Allow one rip to finish while the other continues.

Verify simultaneous:

Job A = TRANSCODING
Job B = RIPPING

This is the defining known-good scheduler test.


24. Dashboard

The ARM dashboard displays:

  • ARM service status
  • current inspection
  • ripping
  • transcoding
  • drive states
  • recent jobs
  • library information
  • active MakeMKV / HandBrake processes
  • rip output growth
  • recent ARM activity
  • load, RAM, and /rips usage

Historical job state may occasionally appear for an idle drive; database/events are authoritative during troubleshooting.


25. Useful Troubleshooting Commands

Recent jobs:

sudo python3 - <<'PY'
import sqlite3
conn = sqlite3.connect("/opt/arm/state/arm.db")
for row in conn.execute("SELECT id, drive, disc_label, status, error_code, error_message FROM jobs ORDER BY id DESC LIMIT 20"):
    print(row)
PY

Workers:

sudo ps -eo pid,ppid,etime,args | \
grep -E '[d]isc_inspector|[m]akemkvcon|[H]andBrakeCLI|[t]ranscoder.py'

ARM journal:

sudo journalctl -u arm -n 100 --no-pager
sudo journalctl -u arm -f

RAID:

cat /proc/mdstat
sudo mdadm --detail /dev/md0

Space:

df -hT / /rips /mnt/jellyfin

25A. Install the V12 Production Source

The public V12 package contains the current production scripts rather than reconstructed snippets. Copy the files only after reviewing the paths and configuration for your own machine.

Create the application directories if they do not already exist:

sudo mkdir -p /opt/arm/scripts /opt/arm/config /opt/arm/logs /opt/arm/state /opt/arm/backups

Copy the Python files into /opt/arm/scripts/, install the sanitized configuration template as /opt/arm/config/arm.conf, and replace REPLACE_WITH_YOUR_JELLYFIN_API_KEY if you want the correction utility to request a Jellyfin library refresh.

Install the systemd unit as:

/etc/systemd/system/arm.service

Install the terminal dashboard as:

/usr/local/bin/arm-status

Then set executable permissions and initialize a fresh database:

sudo chmod 755 /opt/arm/scripts/*.py
sudo chmod 755 /usr/local/bin/arm-status
sudo python3 /opt/arm/scripts/init_db.py
sudo systemctl daemon-reload
sudo systemctl enable arm
sudo systemctl start arm

Do not copy another person’s production arm.db. init_db.py creates the schema for a fresh installation.

26. ARM Application Snapshot

Known-good ARM application snapshot:

/opt/arm/backups/known-good-2026-08-16-parallel-pipeline

Contents:

arm.db
arm.service
config/
scripts/

27. Whole-System Disaster Recovery

The dedicated external recovery HDD is ext4 and labeled:

ARM_RECOVERY

It contains:

ARM-Recovery/
├── README-RESTORE.txt
├── arm/
├── metadata/
├── scripts/
└── system/

The recovery drive contains the known-good root filesystem snapshot, EFI contents, ARM application snapshot, partition layout, UUID information, RAID configuration, package inventory, enabled services, EFI boot information, complete SSD replacement instructions, and a reusable backup script.

Normally keep this drive unplugged and stored safely.


28. Future System Backups

Mount the recovery drive at:

/mnt/arm-recovery

Run:

sudo /mnt/arm-recovery/ARM-Recovery/scripts/backup-system.sh

The script stops ARM and Jellyfin, verifies workers are gone, captures metadata, snapshots the root filesystem and EFI partition, excludes /rips and /mnt/jellyfin, verifies critical files, then restores prior service state.


29. Future Work

Planned additions:

  • Blu-ray drive installation
  • Blu-ray MakeMKV workflow
  • Blu-ray title-selection rules
  • Blu-ray transcoding policy
  • snapshot retention policy
  • dashboard live-state improvements
  • per-job recovery-process matching
  • storage expansion
  • future Jellyfin hardware-acceleration changes

30. Known-Good Baseline Summary

As of 2026-08-16:

Ubuntu Server
      |
      +-- Jellyfin
      |      |
      |      +-- /mnt/jellyfin RAID1
      |
      +-- ARM Orchestrator
             |
             +-- /dev/sr0 -> independent inspection/rip worker
             |
             +-- /dev/sr1 -> independent inspection/rip worker
             |
             +-- RIPPED queue
                     |
                     +-- one HandBrake NVENC worker
                             |
                             +-- library mover
                                     |
                                     +-- cleanup

Validated:

  • two-drive automatic disc detection
  • simultaneous disc inspection
  • simultaneous DVD ripping
  • ripping while another job transcodes
  • one transcode worker
  • automatic Jellyfin library movement
  • duplicate-destination protection
  • automated cleanup
  • interrupted-job recovery
  • ARM-level known-good backup
  • whole-system external-HDD disaster-recovery snapshot

Update this procedure whenever production architecture changes.


Appendix: Full Sanitized V12 Production Source

The following source is included so this Markdown file is usable even without the separate ZIP package. The production API key and personal or host-specific information are not included.

config/arm.conf.example

[system]
name = ARM
version = 0.1

[user]
run_as = arm

[optical]
drive_0 = /dev/sr0
drive_1 = /dev/sr1

[ripping]
tool = makemkvcon
rip_root = /rips
rip_work = /rips/ripping

[transcoding]
tool = HandBrakeCLI

[jellyfin]
media_root = /mnt/jellyfin
url = http://127.0.0.1:8096
api_key = REPLACE_WITH_YOUR_JELLYFIN_API_KEY

[state]
root = /opt/arm/state
logs = /opt/arm/logs
backups = /opt/arm/backups

[automation]
auto_rip = true
auto_transcode = true
auto_library_move = true
auto_cleanup_rips = true
wait_for_next_disc = true
poll_seconds = 15
auto_eject = true

[tv_overrides]
# Optional format:
# DISC_LABEL_PREFIX = Jellyfin Show Name|SeasonNumber
# EXAMPLE_SHOW_S1 = Example Show|1

systemd/arm.service

[Unit]
Description=ARM Automated Ripping Machine
After=local-fs.target
Wants=local-fs.target

[Service]
Type=simple

User=arm
Group=arm

ExecStart=/usr/bin/python3 /opt/arm/scripts/arm_orchestrator.py

Restart=always
RestartSec=10

WorkingDirectory=/opt/arm

StandardOutput=journal
StandardError=journal

NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

dashboard/arm-status

#!/usr/bin/env python3

import configparser
import os
import select
import sqlite3
import subprocess
from pathlib import Path
import sys
import termios
import time
import tty
from datetime import datetime, timezone


DB = "/opt/arm/state/arm.db"
CONFIG = "/opt/arm/config/arm.conf"
RIP_ROOT = "/rips/ripping"

RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"


# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------

def run(cmd, timeout=3):
    try:
        return subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            timeout=timeout,
            check=False,
        ).stdout.strip()
    except Exception:
        return ""


def clear():
    print("\033[2J\033[H", end="")


def status_text(status):
    if status in ("RIPPING", "TRANSCODING", "TITLE_SELECTED"):
        return f"{CYAN}{status}{RESET}"

    if status in (
        "RIP_FAILED",
        "TRANSCODE_FAILED",
        "SELECTION_FAILED",
    ):
        return f"{RED}{status}{RESET}"

    if status == "LIBRARY_MOVED":
        return f"{GREEN}{status}{RESET}"

    return f"{YELLOW}{status}{RESET}"


def parse_time(value):
    if not value:
        return None

    try:
        return datetime.fromisoformat(value)
    except Exception:
        return None


def elapsed(value):
    dt = parse_time(value)

    if not dt:
        return ""

    seconds = max(
        0,
        int(
            (
                datetime.now(timezone.utc) - dt
            ).total_seconds()
        ),
    )

    hours, remainder = divmod(seconds, 3600)
    minutes, seconds = divmod(remainder, 60)

    return f"{hours:02}:{minutes:02}:{seconds:02}"


def bytes_text(value):
    value = float(value)

    for unit in ("B", "KB", "MB", "GB", "TB"):
        if value < 1024:
            return f"{value:.1f} {unit}"

        value /= 1024

    return f"{value:.1f} PB"


def directory_size(path):
    total = 0

    if not os.path.exists(path):
        return 0

    for root, dirs, files in os.walk(path):
        for filename in files:
            try:
                total += os.path.getsize(
                    os.path.join(root, filename)
                )
            except OSError:
                pass

    return total


# ---------------------------------------------------------------------------
# Library helpers
# ---------------------------------------------------------------------------

def library_status(conn):
    """
    Return compact TV and movie library information for the dashboard.

    TV is grouped by show/season and only displays episodes that have
    actually reached the Jellyfin library.

    Movies are intentionally limited to the most recent relevant movie
    job so the normal dashboard stays within one terminal screen.
    """

    tv = {}

    rows = conn.execute("""
        SELECT
            jt.name,
            jt.season_number,
            jt.episode_number,
            jf.status AS file_status,
            jf.library_file,
            j.destination
        FROM job_titles jt
        JOIN jobs j
            ON j.id = jt.job_id
        LEFT JOIN job_files jf
            ON jf.job_title_id = jt.id
        WHERE j.media_type = 'TV'
          AND jt.season_number IS NOT NULL
          AND jt.episode_number IS NOT NULL
        ORDER BY
            jt.season_number,
            jt.episode_number
    """).fetchall()

    for row in rows:
        show = row["name"]

        # Existing ARM TV title rows may not contain the show name.
        # The library path is authoritative once the file has reached
        # Jellyfin, for example:
        #   /mnt/jellyfin/TV Shows/Game of Thrones/Season 01/...
        if not show and row["library_file"]:
            parts = Path(row["library_file"]).parts

            try:
                shows_index = parts.index("TV Shows")
                if shows_index + 1 < len(parts):
                    show = parts[shows_index + 1]
            except ValueError:
                pass

        if not show and row["destination"]:
            parts = Path(row["destination"]).parts

            try:
                shows_index = parts.index("TV Shows")
                if shows_index + 1 < len(parts):
                    show = parts[shows_index + 1]
            except ValueError:
                pass

        if not show:
            continue

        season = int(row["season_number"])
        episode = int(row["episode_number"])

        key = (show, season)

        if key not in tv:
            tv[key] = {}

        status = row["file_status"]

        if status == "LIBRARY_MOVED":
            tv[key][episode] = "✓"
        elif status in (
            "TRANSCODING",
            "RIPPING",
            "MOVING",
        ):
            tv[key][episode] = "●"
        elif status in (
            "RIPPED",
            "TRANSCODED",
        ):
            # Completed the current stage and waiting for the next
            # pipeline stage.
            tv[key][episode] = "○"
        elif status:
            tv[key][episode] = "✗"
        else:
            # The title exists and is expected, but no job_file has
            # reached an active/failed/library state yet.
            tv[key][episode] = "○"

    # Prefer the newest movie that is actively moving through the
    # pipeline. This keeps the compact dashboard focused on live work.
    movie = conn.execute("""
        SELECT
            id,
            title,
            disc_label,
            status,
            destination,
            updated_at
        FROM jobs
        WHERE COALESCE(media_type, '') != 'TV'
          AND status IN (
              'RIPPING',
              'TRANSCODING',
              'MOVING',
              'TITLE_SELECTED'
          )
        ORDER BY id DESC
        LIMIT 1
    """).fetchone()

    # If nothing is actively processing, show the newest movie waiting
    # between completed pipeline stages.
    if movie is None:
        movie = conn.execute("""
            SELECT
                id,
                title,
                disc_label,
                status,
                destination,
                updated_at
            FROM jobs
            WHERE COALESCE(media_type, '') != 'TV'
              AND status IN (
                  'RIPPED',
                  'TRANSCODED'
              )
            ORDER BY id DESC
            LIMIT 1
        """).fetchone()

    # Finally, fall back to the most recently completed library move.
    if movie is None:
        movie = conn.execute("""
            SELECT
                id,
                title,
                disc_label,
                status,
                destination,
                updated_at
            FROM jobs
            WHERE COALESCE(media_type, '') != 'TV'
              AND status = 'LIBRARY_MOVED'
            ORDER BY id DESC
            LIMIT 1
        """).fetchone()

    return tv, movie


# ---------------------------------------------------------------------------
# Process monitoring
# ---------------------------------------------------------------------------

def process_lines():
    output = run([
        "ps",
        "-eo",
        "pid,etime,%cpu,%mem,cmd",
        "--sort=-%cpu",
    ])

    wanted = (
        "makemkvcon",
        "HandBrakeCLI",
        "ripper.py",
        "transcoder.py",
    )

    results = []
    seen = set()

    for line in output.splitlines():
        lower = line.lower()

        if not any(
            name.lower() in lower
            for name in wanted
        ):
            continue

        if "grep" in lower:
            continue

        parts = line.split(None, 1)

        if not parts:
            continue

        pid = parts[0]

        if pid in seen:
            continue

        seen.add(pid)
        results.append(line)

    return results


def process_summary():
    processes = process_lines()

    makemkv = []
    handbrake = []

    for line in processes:
        lower = line.lower()

        if "makemkvcon" in lower:
            makemkv.append(line)

        elif "handbrakecli" in lower:
            handbrake.append(line)

    return makemkv, handbrake, processes


# ---------------------------------------------------------------------------
# Drive helpers
# ---------------------------------------------------------------------------

def configured_drives():
    config = configparser.ConfigParser()
    config.read(CONFIG)

    drives = []

    if "optical" in config:
        for key, value in config["optical"].items():
            if key.startswith("drive_"):
                drives.append(value)

    return drives


def drive_jobs(jobs):
    result = {
        device: []
        for device in configured_drives()
    }

    for row in jobs:
        if row["drive"] in result:
            result[row["drive"]].append(row)

    return result


def drive_media_state(device):
    """
    Determine whether the optical drive currently has media.

    We use lsblk rather than ARM's database because the database
    describes jobs, while this checks the physical drive.
    """
    output = run([
        "lsblk",
        "-n",
        "-o",
        "TYPE",
        device,
    ])

    if output:
        return "MEDIA"

    return "EMPTY"


def eject_drive(device):
    try:
        result = subprocess.run(
            ["eject", device],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            check=False,
        )
    except Exception as exc:
        return False, str(exc)

    if result.returncode == 0:
        return True, f"Ejected {device}"

    message = (
        result.stderr.strip()
        or result.stdout.strip()
        or f"eject returned {result.returncode}"
    )

    return False, message


# ---------------------------------------------------------------------------
# Keyboard handling
# ---------------------------------------------------------------------------

def read_key(timeout=2):
    """
    Read exactly one key without requiring ENTER.

    Returns:
        None  - no key pressed
        str   - single key
    """
    ready, _, _ = select.select(
        [sys.stdin],
        [],
        [],
        timeout,
    )

    if not ready:
        return None

    return sys.stdin.read(1)


def eject_menu():
    """
    Interactive single-key eject menu.

    e -> enter this menu
    0 -> eject sr0
    1 -> eject sr1
    q/ESC -> cancel
    """
    clear()

    print(
        f"{CYAN}{BOLD}"
        "╔══════════════════════════════════════════════════════════════════════════════╗"
        f"{RESET}"
    )

    print(
        f"{CYAN}{BOLD}"
        "║                            EJECT DRIVE                                     ║"
        f"{RESET}"
    )

    print(
        f"{CYAN}"
        "╚══════════════════════════════════════════════════════════════════════════════╝"
        f"{RESET}"
    )

    print()
    print("Press:")
    print()
    print(f"  {BOLD}0{RESET}  Eject /dev/sr0")
    print(f"  {BOLD}1{RESET}  Eject /dev/sr1")
    print(f"  {BOLD}ESC{RESET} Cancel")
    print()

    key = read_key(timeout=10)

    if key is None:
        return

    if key == "\x1b":
        return

    if key.lower() == "q":
        return

    if key not in ("0", "1"):
        return

    device = f"/dev/sr{key}"

    clear()

    print()
    print(
        f"{YELLOW}{BOLD}"
        f"Eject {device}? "
        f"{RESET}"
    )

    print()
    print("Press Y to confirm, or any other key to cancel.")

    confirm = read_key(timeout=10)

    if confirm is None:
        return

    if confirm.lower() != "y":
        return

    clear()

    print()
    print(f"Ejecting {device}...")
    print()

    ok, message = eject_drive(device)

    if ok:
        print(f"{GREEN}{message}{RESET}")
    else:
        print(f"{RED}{message}{RESET}")

    print()
    print("Returning to dashboard...")

    time.sleep(1.5)



# ---------------------------------------------------------------------------
# Job correction / deletion
# ---------------------------------------------------------------------------

JOB_CORRECTOR = "/opt/arm/scripts/job_corrector.py"


def job_action_menu(action, old_settings):
    """
    Ask for a Job ID and launch the standalone correction tool.

    arm-status normally uses cbreak keyboard input, while the
    corrector uses normal line input. Restore the terminal while
    the corrector is active, then return to cbreak mode.
    """

    termios.tcsetattr(
        sys.stdin,
        termios.TCSADRAIN,
        old_settings,
    )

    try:
        clear()

        if action == "correct":
            print(f"{BOLD}CORRECT JOB{RESET}")
        else:
            print(f"{BOLD}DELETE JOB{RESET}")

        print()
        print(
            "Enter the ARM Job ID. "
            "Use arm-status --all to find older jobs."
        )
        print()

        value = input(
            "Job ID (blank to cancel): "
        ).strip()

        if not value:
            return

        try:
            job_id = int(value)
        except ValueError:
            print()
            print(f"{RED}Invalid Job ID.{RESET}")
            input("Press ENTER to return...")
            return

        if action == "delete":
            command = [
                "sudo",
                JOB_CORRECTOR,
                "--delete",
                str(job_id),
            ]
        else:
            command = [
                "sudo",
                JOB_CORRECTOR,
                str(job_id),
            ]

        clear()

        subprocess.run(
            command,
            check=False,
        )

        print()
        input(
            "Press ENTER to return to ARM status..."
        )

    finally:
        tty.setcbreak(sys.stdin.fileno())


def auto_eject_enabled():
    """
    Return the persistent [automation] auto_eject setting.
    """
    config = configparser.ConfigParser()
    config.read(CONFIG)

    if not config.has_section("automation"):
        return False

    try:
        return config["automation"].getboolean(
            "auto_eject",
            fallback=False,
        )
    except ValueError:
        return False


def toggle_auto_eject():
    """
    Toggle [automation] auto_eject and persist it to arm.conf.
    Returns the new boolean state.
    """
    config = configparser.ConfigParser()
    config.optionxform = str
    config.read(CONFIG)

    if not config.has_section("automation"):
        config.add_section("automation")

    try:
        current = config["automation"].getboolean(
            "auto_eject",
            fallback=False,
        )
    except ValueError:
        current = False

    new_value = not current

    config["automation"]["auto_eject"] = (
        "true" if new_value else "false"
    )

    with open(CONFIG, "w") as handle:
        config.write(handle)

    return new_value


# ---------------------------------------------------------------------------
# Compact dashboard helpers
# ---------------------------------------------------------------------------

COMPACT_WIDTH = 100


def compact_rule():
    print("-" * COMPACT_WIDTH)


def progress_color(percent):
    """
    Progress color intentionally does NOT use red.

    Red is reserved exclusively for failures/problems.
    """
    if percent is None:
        return DIM

    if percent >= 100:
        return GREEN

    if percent >= 75:
        return MAGENTA

    if percent >= 50:
        return BLUE

    if percent >= 25:
        return CYAN

    return DIM


def rip_progress(conn, job_id):
    """
    Estimate whole-job ripping progress from:
        bytes currently present in /rips/ripping/job-N
        divided by
        total expected size of selected MakeMKV titles.
    """
    row = conn.execute(
        """
        SELECT COALESCE(SUM(size_bytes), 0)
        FROM job_titles
        WHERE job_id = ?
          AND selected = 1
        """,
        (job_id,),
    ).fetchone()

    expected = int(row[0] or 0)

    rip_dir = Path(RIP_ROOT) / f"job-{job_id}"
    actual = directory_size(str(rip_dir))

    if expected <= 0:
        return None, actual, expected

    percent = int((actual / expected) * 100)

    # While actively ripping, avoid claiming completion before
    # the ripper has changed database state.
    percent = max(0, min(percent, 100))

    return percent, actual, expected


def progress_text(percent, growing=False):
    if percent is None:
        return f"{DIM} --%{RESET}"

    color = progress_color(percent)

    arrow = " ↑" if growing else ""

    return (
        f"{color}"
        f"{percent:3d}%"
        f"{arrow:<2}"
        f"{RESET}"
    )


def display_name(value):
    """
    Pretty dashboard-only display name.

    This does not alter the database or library naming.
    """
    if not value:
        return "UNKNOWN"

    name = value.strip()

    # Remove common leading/trailing junk from physical disc labels.
    name = name.strip("_ ")

    # Remove trailing #1234-style mastering/catalog suffixes.
    import re

    name = re.sub(
        r"[_\s]+#\d+\s*$",
        "",
        name,
    )

    name = name.replace("_", " ")

    name = re.sub(
        r"\s+",
        " ",
        name,
    ).strip()

    # Leave explicit job titles alone if already human-readable;
    # otherwise title-case raw disc labels.
    if name.isupper():
        name = name.title()

    return name


def compact_job_status(status):
    if status == "LIBRARY_MOVED":
        return f"{GREEN}✓ MOVED{RESET}"

    if status in (
        "RIP_FAILED",
        "TRANSCODE_FAILED",
        "SELECTION_FAILED",
        "LIBRARY_MOVE_FAILED",
    ):
        return f"{RED}{status}{RESET}"

    if status in (
        "RIPPING",
        "TRANSCODING",
        "TITLE_SELECTED",
        "INSPECTED",
    ):
        return f"{CYAN}{status}{RESET}"

    if status in ("RIPPED", "TRANSCODED"):
        return f"{YELLOW}{status}{RESET}"

    if status == "SUPERSEDED":
        return f"{DIM}○ SUPERSEDED{RESET}"

    return f"{YELLOW}{status}{RESET}"


def gpu_status():
    """
    Return a short GPU status string.

    NVIDIA failure/absence is non-fatal to the dashboard.
    """
    output = run(
        [
            "nvidia-smi",
            "--query-gpu="
            "utilization.gpu,"
            "utilization.encoder,"
            "temperature.gpu,"
            "memory.used,"
            "memory.total",
            "--format=csv,noheader,nounits",
        ],
        timeout=2,
    )

    if not output:
        return "NVENC --   GPU --   TEMP --"

    first = output.splitlines()[0]

    parts = [
        part.strip()
        for part in first.split(",")
    ]

    if len(parts) < 5:
        return "NVENC --   GPU --   TEMP --"

    gpu, enc, temp, mem_used, mem_total = parts[:5]

    return (
        f"NVENC {enc}%   "
        f"GPU {gpu}%   "
        f"{temp}°C   "
        f"VRAM {mem_used}/{mem_total} MiB"
    )


def transcode_queue_depth(conn):
    """
    Count jobs that are actually eligible to enter the single
    transcode worker.

    This deliberately mirrors the scheduler's job-level RIPPED state.
    A TV episode that finishes ripping while the rest of its disc is
    still ripping is not yet considered queued.
    """
    row = conn.execute(
        """
        SELECT COUNT(*)
        FROM jobs
        WHERE status = 'RIPPED'
        """
    ).fetchone()

    return int(row[0] or 0)


def transcode_progress(job_id):
    """
    Read the newest HandBrake transcode log for this job and return
    the latest reported encoding percentage.

    Works for movie and TV log naming because we search by job ID.
    """
    import glob
    import re

    patterns = [
        f"/opt/arm/logs/job-{job_id}*transcode*.log",
        f"/opt/arm/logs/job-{job_id}*.log",
    ]

    candidates = []

    for pattern in patterns:
        candidates.extend(glob.glob(pattern))

    candidates = list(set(candidates))

    if not candidates:
        return None

    candidates.sort(
        key=lambda filename: Path(filename).stat().st_mtime,
        reverse=True,
    )

    log = Path(candidates[0])

    try:
        # HandBrake progress uses carriage returns in some builds,
        # so reading the complete text is more reliable than tailing
        # by newline.
        content = log.read_text(
            errors="replace",
        )
    except Exception:
        return None

    matches = re.findall(
        r"Encoding:\s+task\s+\d+\s+of\s+\d+,\s+"
        r"([0-9]+(?:\.[0-9]+)?)\s*%",
        content,
    )

    if not matches:
        return None

    try:
        return float(matches[-1])
    except ValueError:
        return None


def active_transcode_text(conn, previous_sizes):
    job = conn.execute(
        """
        SELECT
            id,
            disc_label,
            title,
            media_type,
            transcode_started_at
        FROM jobs
        WHERE status = 'TRANSCODING'
        ORDER BY id
        LIMIT 1
        """
    ).fetchone()

    if not job:
        return f"{DIM}○ IDLE{RESET}"

    label = display_name(
        job["title"]
        or job["disc_label"]
        or "UNKNOWN"
    )

    episode_text = ""

    if job["media_type"] == "TV":
        episode = conn.execute(
            """
            SELECT season_number, episode_number
            FROM job_files
            WHERE job_id = ?
              AND status = 'TRANSCODING'
            ORDER BY episode_number
            LIMIT 1
            """,
            (job["id"],),
        ).fetchone()

        if episode:
            episode_text = (
                f" S{int(episode['season_number']):02d}"
                f"E{int(episode['episode_number']):02d}"
            )

    percent = transcode_progress(job["id"])

    progress = ""

    if percent is not None:
        key = f"transcode-{job['id']}"
        previous = previous_sizes.get(key)

        growing = (
            previous is not None
            and percent > previous
        )

        previous_sizes[key] = percent

        # Keep one decimal internally but display a clean integer.
        display_percent = int(percent)

        progress = (
            progress_text(
                display_percent,
                growing,
            )
            + " "
        )

    age = elapsed(job["transcode_started_at"])

    return (
        f"{CYAN}● ACTIVE{RESET}  "
        f"{progress}"
        f"{label[:30]}"
        f"{episode_text}  "
        f"[{age}]"
    )

def episode_ranges(numbers):
    numbers = sorted(set(numbers))

    if not numbers:
        return ""

    ranges = []
    start = previous = numbers[0]

    for number in numbers[1:]:
        if number == previous + 1:
            previous = number
            continue

        if start == previous:
            ranges.append(f"E{start:02d}")
        else:
            ranges.append(
                f"E{start:02d}-E{previous:02d}"
            )

        start = previous = number

    if start == previous:
        ranges.append(f"E{start:02d}")
    else:
        ranges.append(
            f"E{start:02d}-E{previous:02d}"
        )

    return ",".join(ranges)


def compact_tv_job_progress(conn, job_id):
    """
    Return episode-level progress for one TV job.

    Example:
        E01-E05 ✓TX   E06 ●TX   E07-E08 ○RIP
    """

    rows = conn.execute(
        """
        SELECT
            jt.episode_number,
            COALESCE(
                (
                    SELECT jf.status
                    FROM job_files jf
                    WHERE jf.job_id = jt.job_id
                      AND (
                            jf.job_title_id = jt.id
                            OR (
                                jf.job_title_id IS NULL
                                AND jf.season_number = jt.season_number
                                AND jf.episode_number = jt.episode_number
                            )
                          )
                    ORDER BY jf.id DESC
                    LIMIT 1
                ),
                'WAITING'
            ) AS file_status
        FROM job_titles jt
        WHERE jt.job_id = ?
          AND jt.selected = 1
          AND jt.episode_number IS NOT NULL
        ORDER BY jt.episode_number
        """,
        (job_id,),
    ).fetchall()

    if not rows:
        return ""

    labels = {
        "LIBRARY_MOVED": "✓LIB",
        "TRANSCODED": "✓TX",
        "TRANSCODING": "●TX",
        "RIPPED": "○RIP",
        "RIPPING": "●RIP",
        "PENDING": "WAIT",
        "WAITING": "WAIT",
        "LIBRARY_MOVE_FAILED": "✗MOVE",
        "TRANSCODE_FAILED": "✗TX",
        "RIP_FAILED": "✗RIP",
    }

    groups = []
    current_status = None
    current_numbers = []

    for row in rows:
        ep = int(row["episode_number"])
        status = row["file_status"] or "WAITING"

        if (
            current_status is None
            or status == current_status
        ):
            current_status = status
            current_numbers.append(ep)
            continue

        groups.append(
            (current_status, current_numbers)
        )

        current_status = status
        current_numbers = [ep]

    if current_numbers:
        groups.append(
            (current_status, current_numbers)
        )

    parts = []

    for status, numbers in groups:
        ranges = episode_ranges(numbers)

        if ranges:
            parts.append(
                f"{ranges} "
                f"{labels.get(status, status)}"
            )

    return "   ".join(parts)


def compact_tv_library(conn):
    tv, _ = library_status(conn)

    lines = []

    for (show, season), episodes in sorted(
        tv.items(),
        key=lambda item: (
            item[0][0].lower(),
            item[0][1],
        ),
    ):
        episode_numbers = sorted(episodes)

        if not episode_numbers:
            continue

        failure = any(
            value == "✗"
            for value in episodes.values()
        )

        active = any(
            value == "●"
            for value in episodes.values()
        )

        # Detect internal gaps only. We cannot know whether episodes
        # before the first or after the last should exist.
        expected = set(
            range(
                episode_numbers[0],
                episode_numbers[-1] + 1,
            )
        )

        missing = expected - set(episode_numbers)

        if failure:
            flag = f"{RED}{RESET}"
        elif missing:
            flag = f"{YELLOW}{RESET}"
        elif active:
            flag = f"{CYAN}{RESET}"
        else:
            flag = " "

        summary = episode_ranges(episode_numbers)

        lines.append(
            (
                show,
                season,
                flag,
                summary,
            )
        )

    return lines


def recent_movies(conn, limit=3):
    return conn.execute(
        """
        SELECT
            COALESCE(NULLIF(title, ''), disc_label) AS name
        FROM jobs
        WHERE COALESCE(media_type, '') != 'TV'
          AND status = 'LIBRARY_MOVED'
        ORDER BY id DESC
        LIMIT ?
        """,
        (limit,),
    ).fetchall()


def raid_space_text():
    try:
        stats = os.statvfs("/mnt/jellyfin")

        total = stats.f_blocks * stats.f_frsize
        free = stats.f_bavail * stats.f_frsize
        used = total - free

        percent = (
            int((used / total) * 100)
            if total
            else 0
        )

        return (
            f"RAID {percent}% used   "
            f"{bytes_text(free)} free"
        )

    except Exception:
        return "RAID unavailable"


def active_inspections():
    """
    Return optical drives currently owned by disc_inspector.py.

    Using the inspector process itself is more reliable than trying
    to infer inspection from generic makemkvcon command lines.
    """
    output = run([
        "ps",
        "-eo",
        "args",
    ])

    drives = []

    for line in output.splitlines():
        if "disc_inspector.py" not in line:
            continue

        # Do not accidentally count this dashboard/process search.
        if "grep" in line:
            continue

        for device in configured_drives():
            if device in line and device not in drives:
                drives.append(device)

    return drives

def render_compact(
    conn,
    service_indicator,
    now,
    previous_sizes,
):
    # ------------------------------------------------------------
    # Banner
    # ------------------------------------------------------------

    print(
        f"{CYAN}{BOLD}"
        "╔══════════════════════════════════════════════════════════════════════════════════════════════════╗"
        f"{RESET}"
    )

    print(
        f"{CYAN}{BOLD}"
        "║                              ARM • AUTOMATED RIPPING MACHINE                                    ║"
        f"{RESET}"
    )

    print(
        f"{CYAN}"
        "╚══════════════════════════════════════════════════════════════════════════════════════════════════╝"
        f"{RESET}"
    )

    print(
        f"{service_indicator}   "
        f"{now.strftime('%H:%M:%S %Z')}"
    )

    # ------------------------------------------------------------
    # Drives / activity
    # ------------------------------------------------------------

    print()
    print(f"{BOLD}ACTIVITY{RESET}")
    compact_rule()

    active_statuses = (
        "INSPECTED",
        "TITLE_SELECTED",
        "RIPPING",
        "RIPPED",
        "TRANSCODING",
        "TRANSCODED",
    )

    drive_parts = []
    inspections = active_inspections()

    for index, device in enumerate(configured_drives()):

        # Inspection owns the physical drive before a job necessarily
        # exists in the database.
        if device in inspections:
            part = (
                f"D{index} {CYAN}● INSPECTING{RESET} "
                f"{DIM}MakeMKV scan{RESET}"
            )

            drive_parts.append(part)
            continue

        # Only these states still require the physical optical disc.
        #
        # Once the job reaches RIPPED, the data is safely on disk and
        # the user may replace the disc while transcoding/library work
        # continues independently.
        current = conn.execute(
            """
            SELECT
                id,
                disc_label,
                title,
                status
            FROM jobs
            WHERE drive = ?
              AND status IN (
                  'INSPECTED',
                  'TITLE_SELECTED',
                  'RIPPING'
              )
            ORDER BY id DESC
            LIMIT 1
            """,
            (device,),
        ).fetchone()

        if current and current["status"] == "RIPPING":
            percent, actual, expected = rip_progress(
                conn,
                current["id"],
            )

            key = f"job-{current['id']}"
            old_size = previous_sizes.get(key)
            growing = (
                old_size is not None
                and actual > old_size
            )

            previous_sizes[key] = actual

            label = display_name(
                current["title"]
                or current["disc_label"]
                or "UNKNOWN"
            )

            part = (
                f"D{index} {CYAN}● RIP{RESET} "
                f"{progress_text(percent, growing)} "
                f"{label[:26]}"
            )

        elif current:
            label = display_name(
                current["title"]
                or current["disc_label"]
                or "UNKNOWN"
            )

            part = (
                f"D{index} "
                f"{compact_job_status(current['status'])} "
                f"{label[:24]}"
            )

        else:
            media = drive_media_state(device)

            if media == "MEDIA":
                part = (
                    f"D{index} {GREEN}✓ REPLACE{RESET} "
                    f"{DIM}[E] eject{RESET}"
                )
            else:
                part = (
                    f"D{index} {DIM}○ EMPTY{RESET}"
                )

        drive_parts.append(part)

    if len(drive_parts) >= 2:
        print(
            f"{drive_parts[0]:<55}"
            f"{drive_parts[1]}"
        )
    else:
        for part in drive_parts:
            print(part)

    queue_depth = transcode_queue_depth(conn)

    print(
        "TX "
        + active_transcode_text(
            conn,
            previous_sizes,
        )
        + f"   Q {queue_depth} waiting   "
        + gpu_status()
    )

    inspections = active_inspections()

    if inspections:
        labels = []

        for device in inspections:
            if device.startswith("/dev/sr"):
                labels.append(
                    "D" + device.rsplit("sr", 1)[1]
                )
            else:
                labels.append(device)

        print(
            f"INSPECT {CYAN}{RESET} "
            + ", ".join(labels)
            + "  identifying new disc"
        )
    else:
        print(
            f"INSPECT {DIM}○ IDLE{RESET}"
        )

    # ------------------------------------------------------------
    # Recent jobs
    # ------------------------------------------------------------

    print()
    print(f"{BOLD}RECENT JOBS{RESET}")
    compact_rule()

    recent = conn.execute(
        """
        SELECT
            id,
            drive,
            disc_label,
            title,
            media_type,

            status
        FROM jobs
        ORDER BY id DESC
        LIMIT 6
        """
    ).fetchall()

    for row in recent:
        name = display_name(
            row["title"]
            or row["disc_label"]
            or "UNKNOWN"
        )

        drive = (
            row["drive"]
            .replace("/dev/", "")
            if row["drive"]
            else "-"
        )

        print(
            f"{row['id']:<4} "
            f"{drive:<4} "
            f"{name[:38]:<39} "
            f"{compact_job_status(row['status'])}"
        )

        if row["media_type"] == "TV":
            progress = compact_tv_job_progress(
                conn,
                row["id"],
            )

            if progress:
                print(
                    f"          {DIM}"
                    f"{progress}"
                    f"{RESET}"
                )


    # ------------------------------------------------------------
    # Library
    # ------------------------------------------------------------

    print()
    print(f"{BOLD}LIBRARY{RESET}")
    compact_rule()

    tv_lines = compact_tv_library(conn)

    if tv_lines:
        for show, season, flag, summary in tv_lines[:4]:
            print(
                f"TV  {show[:28]:<29} "
                f"S{season:02d} {flag} "
                f"{summary}"
            )
    else:
        print(f"{DIM}TV  No library entries yet.{RESET}")

    movies = recent_movies(conn, 3)

    movie_names = [
        display_name(row["name"])
        for row in movies
        if row["name"]
    ]

    if movie_names:
        print(
            "MOV "
            + " | ".join(
                name[:25]
                for name in movie_names
            )
        )

    # ------------------------------------------------------------
    # Storage / controls
    # ------------------------------------------------------------

    print()
    compact_rule()

    print(
        f"{raid_space_text()}   "
        f"|   {BOLD}[E]{RESET} Eject   "
        f"{BOLD}[A]{RESET} Auto-eject "
        f"{GREEN + 'ON' + RESET if auto_eject_enabled() else DIM + 'OFF' + RESET}   "
        f"{BOLD}[C]{RESET} Correct   "
        f"{BOLD}[D]{RESET} Delete   "
        f"{BOLD}[Q]{RESET} Quit   "
        f"|   {DIM}arm-status --all for diagnostics{RESET}"
    )


# ---------------------------------------------------------------------------
# Main dashboard
# ---------------------------------------------------------------------------

def job_episode_summary(conn, job_id):
    """
    Return selected TV episode ranges grouped by season.
    Example: S01 E01-E05, E07
    """
    rows = conn.execute(
        """
        SELECT
            season_number,
            episode_number
        FROM job_titles
        WHERE job_id = ?
          AND selected = 1
          AND season_number IS NOT NULL
          AND episode_number IS NOT NULL
        ORDER BY season_number, episode_number
        """,
        (job_id,),
    ).fetchall()

    if not rows:
        return None

    by_season = {}

    for row in rows:
        season = int(row["season_number"])
        episode = int(row["episode_number"])

        by_season.setdefault(
            season,
            [],
        ).append(episode)

    parts = []

    for season in sorted(by_season):
        numbers = sorted(
            set(by_season[season])
        )

        ranges = []
        start = previous = numbers[0]

        for number in numbers[1:]:
            if number == previous + 1:
                previous = number
                continue

            if start == previous:
                ranges.append(
                    f"E{start:02d}"
                )
            else:
                ranges.append(
                    f"E{start:02d}-E{previous:02d}"
                )

            start = previous = number

        if start == previous:
            ranges.append(
                f"E{start:02d}"
            )
        else:
            ranges.append(
                f"E{start:02d}-E{previous:02d}"
            )

        parts.append(
            f"S{season:02d} "
            + ",".join(ranges)
        )

    return " | ".join(parts)


def render_jobs_history():
    """
    Print the complete ARM job history once and return.
    """
    with sqlite3.connect(DB) as conn:
        conn.row_factory = sqlite3.Row

        rows = conn.execute(
            """
            SELECT
                id,
                drive,
                disc_label,
                title,
                media_type,
                status,
                destination,
                error_code
            FROM jobs
            ORDER BY id
            """
        ).fetchall()

        print()
        print(
            f"{BOLD}ARM JOB HISTORY{RESET}"
        )
        print("-" * 118)

        print(
            f"{'ID':<6}"
            f"{'TYPE':<8}"
            f"{'STATUS':<24}"
            f"{'DRIVE':<8}"
            f"{'LABEL':<34}"
            f"TITLE"
        )

        print("-" * 118)

        if not rows:
            print("No ARM jobs found.")
            return

        for row in rows:
            drive = (
                row["drive"].replace("/dev/", "")
                if row["drive"]
                else "-"
            )

            label = (
                row["disc_label"]
                or "-"
            )

            title = (
                row["title"]
                or "-"
            )

            print(
                f"{row['id']:<6}"
                f"{str(row['media_type'] or '-'):<8}"
                f"{str(row['status'] or '-'):<24}"
                f"{drive:<8}"
                f"{label[:33]:<34}"
                f"{title}"
            )

            if row["media_type"] == "TV":
                episodes = job_episode_summary(
                    conn,
                    row["id"],
                )

                if episodes:
                    print(
                        f"      Episodes:    {episodes}"
                    )

            print(
                "      Destination: "
                f"{row['destination'] or '-'}"
            )

            if row["error_code"]:
                print(
                    "      Error:       "
                    f"{row['error_code']}"
                )

            print()

        print("-" * 118)
        print(
            f"{len(rows)} job(s)"
        )


def main():
    args = {
        arg.lower()
        for arg in sys.argv[1:]
    }

    show_jobs = (
        "--jobs" in args
        or "-jobs" in args
    )

    show_all = (
        "--all" in args
        or "-all" in args
    )

    if show_jobs:
        render_jobs_history()
        return 0
    previous_sizes = {}

    # Put terminal into cbreak mode so single keys work.
    old_settings = termios.tcgetattr(sys.stdin)

    try:
        tty.setcbreak(sys.stdin.fileno())

        while True:
            clear()

            now = datetime.now().astimezone()

            service = run([
                "systemctl",
                "is-active",
                "arm.service",
            ])

            service_indicator = (
                f"{GREEN}● ACTIVE{RESET}"
                if service == "active"
                else f"{RED}● "
                f"{service.upper() or 'UNKNOWN'}"
                f"{RESET}"
            )

            if show_all:
                print(
                    f"{CYAN}{BOLD}"
                    "╔══════════════════════════════════════════════════════════════════════════════╗"
                    f"{RESET}"
                )

                print(
                    f"{CYAN}{BOLD}"
                    "║                         ARM MACHINE STATUS                                 ║"
                    f"{RESET}"
                )

                print(
                    f"{CYAN}"
                    "╚══════════════════════════════════════════════════════════════════════════════╝"
                    f"{RESET}"
                )

                print()
                print(
                    f"ARM service: {service_indicator}"
                )

                print(
                    "Last refresh:",
                    now.strftime(
                        "%Y-%m-%d %H:%M:%S %Z"
                    ),
                )

            try:
                with sqlite3.connect(DB) as conn:

                    conn.row_factory = sqlite3.Row

                    jobs = conn.execute("""
                        SELECT
                            id,
                            drive,
                            disc_label,
                            status,
                            rip_started_at,
                            rip_completed_at,
                            transcode_started_at,
                            transcode_completed_at,
                            destination,
                            error_code,
                            error_message
                        FROM jobs
                        ORDER BY id DESC
                        LIMIT 5
                    """).fetchall()

                    drive_map = drive_jobs(jobs)

                    if not show_all:
                        render_compact(
                            conn,
                            service_indicator,
                            now,
                            previous_sizes,
                        )

                        key = read_key(timeout=2)

                        if key:
                            if key.lower() == "q":
                                return 0

                            if key.lower() == "e":
                                eject_menu()

                            if key.lower() == "a":
                                toggle_auto_eject()

                            if key.lower() == "c":
                                job_action_menu(
                                    "correct",
                                    old_settings,
                                )

                            if key.lower() == "d":
                                job_action_menu(
                                    "delete",
                                    old_settings,
                                )


                        continue

                    # -------------------------------------------------------
                    # CURRENT ACTIVITY
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}CURRENT ACTIVITY{RESET}"
                    )

                    print("-" * 120)

                    ripping = [
                        row
                        for row in jobs
                        if row["status"] == "RIPPING"
                    ]

                    transcoding = [
                        row
                        for row in jobs
                        if row["status"] == "TRANSCODING"
                    ]

                    selecting = [
                        row
                        for row in jobs
                        if row["status"] == "TITLE_SELECTED"
                    ]

                    processes = process_lines()

                    inspecting = [
                        line
                        for line in processes
                        if "makemkvcon" in line.lower()
                        and " info " in (
                            " " + line.lower() + " "
                        )
                    ]

                    if inspecting and not ripping and not transcoding:
                        print(
                            f"{YELLOW}● INSPECTING{RESET}    "
                            "disc identification / title scan"
                        )
                    else:
                        print(
                            f"{DIM}"
                            "○ INSPECTING    none"
                            f"{RESET}"
                        )

                    if ripping:
                        for row in ripping:
                            print(
                                f"{GREEN}● RIPPING{RESET}       "
                                f"{row['disc_label']} "
                                f"({row['drive']}) "
                                f"[{elapsed(row['rip_started_at'])}]"
                            )
                    else:
                        print(
                            f"{DIM}"
                            "○ RIPPING        none"
                            f"{RESET}"
                        )

                    if transcoding:
                        for row in transcoding:
                            print(
                                f"{GREEN}● TRANSCODING{RESET}  "
                                f"{row['disc_label']} "
                                f"({row['drive']}) "
                                f"[{elapsed(row['transcode_started_at'])}]"
                            )
                    else:
                        print(
                            f"{DIM}"
                            "○ TRANSCODING   none"
                            f"{RESET}"
                        )

                    if selecting:
                        for row in selecting:
                            print(
                                f"{YELLOW}● TITLE SELECT{RESET} "
                                f"{row['disc_label']} "
                                f"({row['drive']})"
                            )

                    # -------------------------------------------------------
                    # DRIVES
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}DRIVES{RESET}"
                    )

                    print("-" * 120)

                    for device in configured_drives():

                        drive_number = device[-1]

                        rows = drive_map[device]

                        current = next(
                            (
                                row
                                for row in rows
                                if row["status"]
                                not in ("LIBRARY_MOVED",)
                            ),
                            None,
                        )

                        media = drive_media_state(
                            device
                        )

                        if current:

                            print(
                                f"Drive {drive_number}: "
                                f"{GREEN}● "
                                f"{current['status']:<12}"
                                f"{RESET} "
                                f"{device:<10} "
                                f"{current['disc_label'] or 'UNKNOWN':<28}"
                            )

                        elif media == "MEDIA":

                            print(
                                f"Drive {drive_number}: "
                                f"{YELLOW}● DISC READY"
                                f"{RESET}   "
                                f"{device:<10} "
                                f"[{GREEN}E{RESET}] eject"
                            )

                        else:

                            print(
                                f"Drive {drive_number}: "
                                f"{DIM}○ EMPTY{RESET}        "
                                f"{device}"
                            )

                    # -------------------------------------------------------
                    # PIPELINE
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}PIPELINE{RESET}"
                    )

                    print("-" * 120)

                    active_statuses = {
                        "RIPPING",
                        "TRANSCODING",
                        "TITLE_SELECTED",
                        "MOVING",
                    }

                    drive0_active = any(
                        r["drive"] == "/dev/sr0"
                        and r["status"] in active_statuses
                        for r in jobs
                    )

                    drive1_active = any(
                        r["drive"] == "/dev/sr1"
                        and r["status"] in active_statuses
                        for r in jobs
                    )

                    print(
                        "Drive 0: "
                        + (
                            f"{GREEN}● ACTIVE{RESET}"
                            if drive0_active
                            else f"{DIM}○ IDLE{RESET}"
                        )
                    )

                    print(
                        "Drive 1: "
                        + (
                            f"{GREEN}● ACTIVE{RESET}"
                            if drive1_active
                            else f"{DIM}○ IDLE{RESET}"
                        )
                    )

                    print(
                        "Transcode: "
                        + (
                            f"{GREEN}● ACTIVE{RESET}"
                            if transcoding
                            else f"{DIM}○ IDLE{RESET}"
                        )
                    )

                    # -------------------------------------------------------
                    # LAST 5 JOBS
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}LAST 5 JOBS{RESET}"
                    )

                    print("-" * 120)

                    print(
                        f"{'ID':<4} "
                        f"{'DRIVE':<11} "
                        f"{'DISC':<28} "
                        f"{'STATUS':<22} "
                        f"{'AGE':<10}"
                    )

                    print("-" * 120)

                    for row in jobs:

                        if row["status"] == "RIPPING":
                            start = row["rip_started_at"]

                        elif row["status"] == "TRANSCODING":
                            start = row[
                                "transcode_started_at"
                            ]

                        else:
                            start = row[
                                "rip_started_at"
                            ]

                        label = (
                            row["disc_label"] or ""
                        )[:27]

                        print(
                            f"{row['id']:<4} "
                            f"{row['drive']:<11} "
                            f"{label:<28} "
                            f"{status_text(row['status']):<31} "
                            f"{elapsed(start):<10}"
                        )

                    # -------------------------------------------------------
                    # TV / MOVIE LIBRARY
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}TV LIBRARY{RESET}"
                    )

                    print("-" * 120)

                    tv_library, movie_library = library_status(conn)

                    if tv_library:
                        for (show, season), episodes in sorted(
                            tv_library.items(),
                            key=lambda item: (
                                item[0][0].lower(),
                                item[0][1],
                            ),
                        ):
                            # Put the status indicators before the season
                            # and episode text so the dashboard can be
                            # scanned visually from left to right.
                            parts = []

                            for episode in sorted(episodes):
                                indicator = episodes[episode]

                                if indicator == "✓":
                                    symbol = f"{GREEN}{RESET}"
                                elif indicator == "●":
                                    symbol = f"{YELLOW}{RESET}"
                                elif indicator == "○":
                                    symbol = f"{DIM}{RESET}"
                                else:
                                    symbol = f"{RED}{RESET}"

                                parts.append(
                                    f"{symbol} E{episode:02d}"
                                )

                            print(
                                f"{GREEN}{RESET} {show}"
                            )

                            if parts:
                                print(
                                    f"  "
                                    f"{GREEN}{RESET} S{season:02d}  "
                                    + "  ".join(parts)
                                )
                    else:
                        print(
                            f"{DIM}"
                            "No TV library entries yet."
                            f"{RESET}"
                        )

                    print()
                    print(
                        f"{BOLD}MOVIE LIBRARY{RESET}"
                    )

                    print("-" * 120)

                    if movie_library:
                        movie_name = (
                            movie_library["title"]
                            or movie_library["disc_label"]
                            or "UNKNOWN MOVIE"
                        )

                        movie_status = movie_library["status"]

                        if movie_status == "LIBRARY_MOVED":
                            print(
                                f"{GREEN}{RESET} "
                                f"{movie_name}"
                            )

                        elif movie_status in (
                            "RIPPING",
                            "TRANSCODING",
                        ):
                            print(
                                f"{YELLOW}{RESET} "
                                f"{movie_name}"
                            )

                        elif movie_status in (
                            "RIP_FAILED",
                            "TRANSCODE_FAILED",
                        ):
                            print(
                                f"{RED}{RESET} "
                                f"{movie_name}"
                            )

                        else:
                            print(
                                f"{YELLOW}{RESET} "
                                f"{movie_name}"
                            )
                    else:
                        print(
                            f"{DIM}"
                            "No recent movie processing."
                            f"{RESET}"
                        )

                    # -------------------------------------------------------
                    # ACTIVE PROCESSES
                    # -------------------------------------------------------


                    print()
                    print(
                        f"{BOLD}ACTIVE PROCESSES{RESET}"
                    )

                    print("-" * 120)

                    makemkv, handbrake, processes = (
                        process_summary()
                    )

                    if makemkv:
                        for line in makemkv:
                            print(line[:119])

                    if handbrake:
                        for line in handbrake:
                            print(line[:119])

                    if not makemkv and not handbrake:
                        print(
                            f"{DIM}"
                            "No MakeMKV or HandBrake process "
                            "currently detected."
                            f"{RESET}"
                        )

                    # -------------------------------------------------------
                    # RIP OUTPUT
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}RIP OUTPUT{RESET}"
                    )

                    print("-" * 120)

                    if os.path.isdir(RIP_ROOT):

                        entries = sorted(
                            os.scandir(RIP_ROOT),
                            key=lambda entry: entry.name,
                        )

                        found = False

                        for entry in entries:

                            if not entry.is_dir():
                                continue

                            found = True

                            size = directory_size(
                                entry.path
                            )

                            old = previous_sizes.get(
                                entry.name
                            )

                            growth = ""

                            if old is not None:

                                delta = size - old

                                if delta > 0:
                                    growth = (
                                        f"  {GREEN}+"
                                        f"{bytes_text(delta)}"
                                        f" since last refresh"
                                        f"{RESET}"
                                    )

                                elif delta == 0:
                                    growth = (
                                        f"  {YELLOW}"
                                        "no growth since last refresh"
                                        f"{RESET}"
                                    )

                            previous_sizes[
                                entry.name
                            ] = size

                            print(
                                f"{entry.name:<30}"
                                f"{bytes_text(size):>12}"
                                f"{growth}"
                            )

                        if not found:
                            print(
                                f"{DIM}"
                                "No active rip directories."
                                f"{RESET}"
                            )

                    else:
                        print(
                            f"{DIM}"
                            f"{RIP_ROOT} does not exist."
                            f"{RESET}"
                        )

                    # -------------------------------------------------------
                    # RECENT ARM ACTIVITY
                    # -------------------------------------------------------

                    print()
                    print(
                        f"{BOLD}RECENT ARM ACTIVITY{RESET}"
                    )

                    print("-" * 120)

                    events = conn.execute("""
                        SELECT
                            timestamp,
                            level,
                            event,
                            message
                        FROM events
                        ORDER BY timestamp DESC
                        LIMIT 8
                    """).fetchall()

                    for event in reversed(events):

                        timestamp = (
                            event["timestamp"]
                            or ""
                        )

                        timestamp = (
                            timestamp
                            .replace("T", " ")
                            [:19]
                        )

                        message = (
                            event["message"]
                            or ""
                        ).replace(
                            "\n",
                            " ",
                        )

                        print(
                            f"{timestamp}  "
                            f"{event['level']:<5} "
                            f"{event['event']:<28} "
                            f"{message[:55]}"
                        )

            except Exception as exc:

                print()
                print(
                    f"{RED}"
                    f"DATABASE ERROR: {exc}"
                    f"{RESET}"
                )

            # ---------------------------------------------------------------
            # SYSTEM
            # ---------------------------------------------------------------

            print()
            print(
                f"{BOLD}SYSTEM{RESET}"
            )

            print("-" * 120)

            load = run([
                "awk",
                "{print $1}",
                "/proc/loadavg",
            ])

            memory = run([
                "bash",
                "-c",
                "free -h | "
                "awk '/^Mem:/ "
                "{print $3 \" / \" $2}'",
            ])

            disk = run([
                "bash",
                "-c",
                "df -h /rips | "
                "awk 'NR==2 "
                "{print $3 \" / \" $2 \" (\" $5 \")\"}'",
            ])

            print(
                f"Load: {load or '-':<10}"
                f"RAM: {memory or '-':<20}"
                f"/rips: {disk or '-'}"
            )

            # ---------------------------------------------------------------
            # KEYBOARD CONTROLS
            # ---------------------------------------------------------------

            print()
            print(
                f"{BOLD}"
                "[E] Eject    [A] Auto-eject    "
                "[C] Correct    [D] Delete    [Q] Quit"
                f"{RESET}"
            )

            print(
                f"{DIM}"
                "Press a key at any time • "
                "Refreshing every 2 seconds"
                f"{RESET}"
            )

            # Wait for one key, but refresh automatically after 2 seconds.
            key = read_key(timeout=2)

            if key is None:
                continue

            key = key.lower()

            if key == "q":
                clear()
                print(
                    "ARM status dashboard closed."
                )
                break

            if key == "e":
                eject_menu()

            if key == "a":
                toggle_auto_eject()

            if key == "c":
                job_action_menu(
                    "correct",
                    old_settings,
                )

            if key == "d":
                job_action_menu(
                    "delete",
                    old_settings,
                )

    finally:
        # Always restore the user's terminal settings.
        termios.tcsetattr(
            sys.stdin,
            termios.TCSADRAIN,
            old_settings,
        )

        clear()


if __name__ == "__main__":
    main()

scripts/arm_config.py

#!/usr/bin/env python3

from configparser import ConfigParser
from pathlib import Path

CONFIG_FILE = Path("/opt/arm/config/arm.conf")


def load_config():
    if not CONFIG_FILE.exists():
        raise FileNotFoundError(f"ARM configuration not found: {CONFIG_FILE}")

    config = ConfigParser()
    config.read(CONFIG_FILE)

    return config


def get_paths(config):
    return {
        "rip_root": Path(config["ripping"]["rip_root"]),
        "rip_work": Path(config["ripping"]["rip_work"]),
        "media_root": Path(config["jellyfin"]["media_root"]),
        "state": Path(config["state"]["root"]),
        "logs": Path(config["state"]["logs"]),
        "backups": Path(config["state"]["backups"]),
    }


def get_drives(config):
    drives = []

    for key, value in config["optical"].items():
        if key.startswith("drive_"):
            drives.append(value)

    return drives


if __name__ == "__main__":
    config = load_config()
    paths = get_paths(config)
    drives = get_drives(config)

    print("ARM configuration loaded successfully.")
    print()
    print("Drives:")
    for drive in drives:
        print(f"  {drive}")

    print()
    print("Paths:")
    for name, path in paths.items():
        print(f"  {name}: {path}")

scripts/arm_logger.py

#!/usr/bin/env python3

import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path


DB = Path("/opt/arm/state/arm.db")
LOG_DIR = Path("/opt/arm/logs")
LOG_FILE = LOG_DIR / "arm.log"


def utc_now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def get_logger():
    LOG_DIR.mkdir(parents=True, exist_ok=True)

    logger = logging.getLogger("ARM")
    logger.setLevel(logging.INFO)

    if not logger.handlers:
        handler = logging.FileHandler(LOG_FILE)
        formatter = logging.Formatter(
            "%(asctime)s [%(levelname)s] %(message)s"
        )
        handler.setFormatter(formatter)
        logger.addHandler(handler)

    return logger


def log_event(level, event, message, job_id=None):
    timestamp = utc_now()

    logger = get_logger()

    log_method = getattr(logger, level.lower(), logger.info)
    log_method(f"{event}: {message}")

    with sqlite3.connect(DB) as conn:
        conn.execute(
            """
            INSERT INTO events (
                job_id,
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                job_id,
                timestamp,
                level.upper(),
                event,
                message,
            ),
        )


def info(event, message, job_id=None):
    log_event("INFO", event, message, job_id)


def warning(event, message, job_id=None):
    log_event("WARNING", event, message, job_id)


def error(event, message, job_id=None):
    log_event("ERROR", event, message, job_id)


if __name__ == "__main__":
    info(
        "SYSTEM_TEST",
        "ARM logging subsystem initialized successfully"
    )

    print(f"Log file: {LOG_FILE}")
    print("Database event recorded.")

scripts/init_db.py

#!/usr/bin/env python3

import sqlite3
from pathlib import Path

DB = Path("/opt/arm/state/arm.db")

DB.parent.mkdir(parents=True, exist_ok=True)

with sqlite3.connect(DB) as conn:
    conn.execute("PRAGMA foreign_keys = ON")

    conn.executescript("""
    CREATE TABLE IF NOT EXISTS jobs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        job_uuid TEXT NOT NULL UNIQUE,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,

        drive TEXT,
        disc_label TEXT,
        disc_source TEXT,

        media_type TEXT,
        title TEXT,
        year INTEGER,

        status TEXT NOT NULL,

        rip_started_at TEXT,
        rip_completed_at TEXT,

        transcode_started_at TEXT,
        transcode_completed_at TEXT,

        destination TEXT,

        error_code TEXT,
        error_message TEXT,

        retry_count INTEGER NOT NULL DEFAULT 0,

        notes TEXT
    );

    CREATE TABLE IF NOT EXISTS job_titles (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        job_id INTEGER NOT NULL,

        title_number INTEGER,
	name TEXT,
        duration_seconds INTEGER,
        duration TEXT,
        size_bytes INTEGER,
        size_display TEXT,
        chapters INTEGER,
        source_file TEXT,

        selected INTEGER NOT NULL DEFAULT 0,
        makemkv_title_id INTEGER,

        FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        job_id INTEGER,

        timestamp TEXT NOT NULL,
        level TEXT NOT NULL,
        event TEXT NOT NULL,
        message TEXT,

        FOREIGN KEY(job_id) REFERENCES jobs(id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS system_state (
        key TEXT PRIMARY KEY,
        value TEXT,
        updated_at TEXT NOT NULL
    );

    CREATE INDEX IF NOT EXISTS idx_jobs_status
        ON jobs(status);

    CREATE INDEX IF NOT EXISTS idx_jobs_created
        ON jobs(created_at);

    CREATE INDEX IF NOT EXISTS idx_events_job
        ON events(job_id);
    """)

print(f"ARM database initialized: {DB}")

scripts/arm_orchestrator.py

#!/usr/bin/env python3

import configparser
import sqlite3
import contextlib
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path


CONFIG = Path("/opt/arm/config/arm.conf")
DB = Path("/opt/arm/state/arm.db")

# Active disc_inspector.py children, keyed by optical config key.
# Example: drive_0 -> (device, label, Popen object)
ACTIVE_INSPECTIONS = {}

# Active ripper.py children, keyed by device path.
# Example: /dev/sr0 -> Popen object
ACTIVE_RIPPERS = {}

# Exactly one transcoder.py worker may run at a time.
ACTIVE_TRANSCODER = None


@contextlib.contextmanager
def db_connection():
    """
    Open an ARM SQLite connection and always close it.

    sqlite3.Connection's own context manager commits/rolls back but
    does not close the connection, which leaks file descriptors in
    this long-running orchestrator.
    """
    conn = sqlite3.connect(DB)

    try:
        with conn:
            yield conn
    finally:
        conn.close()


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def load_config():
    config = configparser.ConfigParser()
    config.read(CONFIG)
    return config


def log_event(conn, level, event, message, job_id=None):
    conn.execute(
        """
        INSERT INTO events(
            job_id,
            timestamp,
            level,
            event,
            message
        )
        VALUES (?, ?, ?, ?, ?)
        """,
        (
            job_id,
            now(),
            level,
            event,
            message,
        ),
    )
    conn.commit()


def process_exists(pattern):
    import subprocess

    result = subprocess.run(
        ["pgrep", "-f", pattern],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )

    return bool(result.stdout.strip())


def recover_orphaned_jobs():
    """
    Recover jobs left active in the database after an interrupted process.

    This only repairs database state. It does not delete files or retry work.
    """

    with db_connection() as conn:
        jobs = conn.execute(
            """
            SELECT id, disc_label, status
            FROM jobs
            WHERE status IN ('RIPPING','TRANSCODING')
            ORDER BY id
            """
        ).fetchall()

        for job_id, label, status in jobs:

            if status == "RIPPING":
                if not process_exists("makemkvcon.*mkv"):
                    print(
                        f"RECOVERY: Job {job_id} "
                        f"{label} reset RIPPING -> TITLE_SELECTED"
                    )

                    conn.execute(
                        """
                        UPDATE jobs
                        SET status='TITLE_SELECTED'
                        WHERE id=?
                        """,
                        (job_id,),
                    )

                    log_event(
                        conn,
                        "WARNING",
                        "JOB_RECOVERED",
                        "Reset orphaned RIPPING job",
                        job_id,
                    )

            elif status == "TRANSCODING":
                if not process_exists("HandBrakeCLI"):
                    print(
                        f"RECOVERY: Job {job_id} "
                        f"{label} reset TRANSCODING -> RIPPED"
                    )

                    conn.execute(
                        """
                        UPDATE jobs
                        SET status='RIPPED'
                        WHERE id=?
                        """,
                        (job_id,),
                    )

                    log_event(
                        conn,
                        "WARNING",
                        "JOB_RECOVERED",
                        "Reset orphaned TRANSCODING job",
                        job_id,
                    )

        conn.commit()


def get_state(conn, key):
    row = conn.execute(
        """
        SELECT value
        FROM system_state
        WHERE key = ?
        """,
        (key,),
    ).fetchone()

    return row[0] if row else None


def set_state(conn, key, value):
    conn.execute(
        """
        INSERT INTO system_state(key, value, updated_at)
        VALUES (?, ?, ?)
        ON CONFLICT(key)
        DO UPDATE SET
            value = excluded.value,
            updated_at = excluded.updated_at
        """,
        (
            key,
            value,
            now(),
        ),
    )
    conn.commit()


def run_script(script, *args):
    command = [
        "python3",
        str(script),
        *args,
    ]

    print()
    print("=" * 70)
    print(f"RUNNING: {script}")
    print("=" * 70)

    result = subprocess.run(
        command,
        check=False,
    )

    print(f"RESULT: {script.name} returncode={result.returncode}")

    return result.returncode


def detect_disc(drive):
    """
    Return the MakeMKV disc label when a disc is present.

    Return None when the drive is empty or MakeMKV cannot identify
    a disc.
    """

    try:
        result = subprocess.run(
            [
                "makemkvcon",
                "-r",
                "info",
                f"disc:{drive}",
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=120,
            check=False,
        )
    except Exception as exc:
        print(
            f"Disc detection exception on {drive}: {exc}"
        )
        return None

    # MakeMKV can return a non-zero status while still providing
    # valid DRV records describing the installed optical discs.
    # Parse the DRV records instead of rejecting the output solely
    # because of the process return code.

    for line in result.stdout.splitlines():
        if not line.startswith("DRV:"):
            continue

        parts = line.split(",")

        if len(parts) < 7:
            continue

        device = parts[6].strip('"').strip()

        if device != drive:
            continue

        label = parts[5].strip('"').strip()

        if label:
            return label

    return None


def auto_eject_drive(config, drive):
    """
    Eject a drive after its optical rip is completely finished.

    This never closes a tray. It only issues an eject/open command
    when [automation] auto_eject is enabled.
    """
    automation = config["automation"]

    if not automation.getboolean(
        "auto_eject",
        fallback=False,
    ):
        return False

    # Confirm this drive has no remaining selected optical work.
    with db_connection() as conn:
        remaining = conn.execute(
            """
            SELECT COUNT(*)
            FROM jobs j
            JOIN job_titles jt
              ON jt.job_id = j.id
            WHERE j.drive = ?
              AND j.status IN (
                  'TITLE_SELECTED',
                  'RIPPING'
              )
              AND jt.selected = 1
            """,
            (drive,),
        ).fetchone()[0]

        if remaining:
            print(
                f"{drive}: auto-eject deferred; "
                f"{remaining} selected title(s) still need optical work"
            )
            return False

    print(f"{drive}: optical work complete; auto-ejecting tray...")

    try:
        result = subprocess.run(
            ["eject", drive],
            capture_output=True,
            text=True,
            timeout=10,
        )

    except Exception as exc:
        print(
            f"{drive}: auto-eject failed: {exc}"
        )

        with db_connection() as conn:
            log_event(
                conn,
                "ERROR",
                "AUTO_EJECT_FAILED",
                f"drive={drive}; error={exc}",
            )

        return False

    if result.returncode != 0:
        message = (
            result.stderr.strip()
            or result.stdout.strip()
            or f"eject returned {result.returncode}"
        )

        print(
            f"{drive}: auto-eject failed: {message}"
        )

        with db_connection() as conn:
            log_event(
                conn,
                "ERROR",
                "AUTO_EJECT_FAILED",
                f"drive={drive}; error={message}",
            )

        return False

    with db_connection() as conn:
        # Clear remembered media immediately. The next closed/new
        # disc can therefore be treated as a genuine insertion.
        for drive_key, device in config["optical"].items():
            if (
                drive_key.startswith("drive_")
                and device == drive
            ):
                set_state(
                    conn,
                    f"{drive_key}_disc_label",
                    "",
                )

                set_state(
                    conn,
                    f"{drive_key}_tray_open",
                    "1",
                )

                break

        log_event(
            conn,
            "INFO",
            "AUTO_EJECTED",
            f"drive={drive}",
        )

    print(f"{drive}: tray ejected; ready for replacement.")
    return True


def process_pipeline(config):
    """
    Advance jobs without blocking the ARM main loop.

    Optical drives rip independently, with at most one ripper worker
    per drive. Exactly one transcoder worker may run at a time.

    This allows inspection, title selection, ripping, transcoding,
    and library movement for different jobs to overlap safely.
    """

    global ACTIVE_RIPPERS
    global ACTIVE_TRANSCODER

    automation = config["automation"]

    # ------------------------------------------------------------
    # Reap completed ripper workers.
    # ------------------------------------------------------------

    for drive, process in list(ACTIVE_RIPPERS.items()):
        rc = process.poll()

        if rc is None:
            continue

        print(
            f"Ripper finished for {drive}; "
            f"returncode={rc}"
        )

        del ACTIVE_RIPPERS[drive]

        # The optical disc is no longer required once ripping
        # finishes successfully. Transcoding/library work continues
        # independently from SSD staging.
        if rc == 0:
            auto_eject_drive(
                config,
                drive,
            )

    # ------------------------------------------------------------
    # Reap the single transcode worker.
    # ------------------------------------------------------------

    if ACTIVE_TRANSCODER is not None:
        rc = ACTIVE_TRANSCODER.poll()

        if rc is not None:
            print(
                "Transcoder worker finished; "
                f"returncode={rc}"
            )

            ACTIVE_TRANSCODER = None

    # ------------------------------------------------------------
    # Title selection.
    #
    # This is short-lived and may safely run synchronously.
    # ------------------------------------------------------------

    if automation.getboolean(
        "auto_rip",
        fallback=True,
    ):
        rc = run_script(
            Path("/opt/arm/scripts/title_selector.py")
        )

        if rc != 0:
            print(
                "Pipeline stage failed: title_selector.py"
            )
            return False

        # --------------------------------------------------------
        # Start one ripper per eligible optical drive.
        #
        # Do not wait for these processes. The main ARM loop must
        # remain free to inspect and advance other drives.
        # --------------------------------------------------------

        with db_connection() as conn:
            for key, drive in config["optical"].items():
                if not key.startswith("drive_"):
                    continue

                active = ACTIVE_RIPPERS.get(drive)

                if active is not None:
                    if active.poll() is None:
                        continue

                    del ACTIVE_RIPPERS[drive]

                eligible = conn.execute(
                    """
                    SELECT 1
                    FROM jobs j
                    JOIN job_titles jt
                      ON jt.job_id = j.id
                    WHERE j.status = 'TITLE_SELECTED'
                      AND j.drive = ?
                      AND jt.selected = 1
                      AND NOT EXISTS (
                          SELECT 1
                          FROM job_files jf
                          WHERE jf.job_id = j.id
                            AND jf.job_title_id = jt.id
                            AND jf.status = 'RIPPED'
                      )
                    LIMIT 1
                    """,
                    (drive,),
                ).fetchone()

                if not eligible:
                    continue

                command = [
                    "python3",
                    "/opt/arm/scripts/ripper.py",
                    drive,
                ]

                process = subprocess.Popen(command)

                ACTIVE_RIPPERS[drive] = process

                print(
                    f"Started ripper for {drive}; "
                    f"pid={process.pid}"
                )

    # ------------------------------------------------------------
    # Single transcode queue worker.
    #
    # If any RIPPED job exists and no transcoder worker is active,
    # start exactly one transcoder.py process.
    # ------------------------------------------------------------

    if automation.getboolean(
        "auto_transcode",
        fallback=True,
    ):
        if ACTIVE_TRANSCODER is None:

            with db_connection() as conn:
                eligible = conn.execute(
                    """
                    SELECT id
                    FROM jobs
                    WHERE status = 'RIPPED'
                    ORDER BY id
                    LIMIT 1
                    """
                ).fetchone()

            if eligible:
                ACTIVE_TRANSCODER = subprocess.Popen(
                    [
                        "python3",
                        "/opt/arm/scripts/transcoder.py",
                    ]
                )

                print(
                    "Started transcode worker; "
                    f"pid={ACTIVE_TRANSCODER.pid}; "
                    f"queued_job={eligible[0]}"
                )

    # ------------------------------------------------------------
    # Library movement.
    #
    # This stage is intentionally synchronous because moves are
    # short. It may move a TRANSCODED job while other drives rip
    # or another job transcodes.
    # ------------------------------------------------------------

    if automation.getboolean(
        "auto_library_move",
        fallback=True,
    ):
        rc = run_script(
            Path("/opt/arm/scripts/library_mover.py")
        )

        if rc != 0:
            return False

        if automation.getboolean(
            "auto_cleanup_rips",
            fallback=True,
        ):
            cleanup_script = Path(
                "/opt/arm/scripts/cleanup_rips.py"
            )

            if cleanup_script.exists():
                rc = run_script(
                    cleanup_script,
                    "--execute",
                )

                if rc != 0:
                    return False

    return True



def inspect_new_discs(config):
    """
    Detect insertion/removal transitions and run inspections independently.

    A drive must never be probed while either disc_inspector.py or
    ripper.py owns it. MakeMKV info calls against an actively-ripping
    optical drive can temporarily make media detection look like a
    removal/reinsertion and create duplicate jobs.
    """

    global ACTIVE_INSPECTIONS
    global ACTIVE_RIPPERS

    drives = [
        (key, value)
        for key, value in config["optical"].items()
        if key.startswith("drive_")
    ]

    # Reap completed inspector children first.
    for drive_key, active in list(ACTIVE_INSPECTIONS.items()):
        device, label, process = active
        rc = process.poll()

        if rc is None:
            continue

        print(
            f"Disc inspection finished for {device}; "
            f"returncode={rc}"
        )

        with db_connection() as conn:
            if rc == 0:
                # Only mark the disc as handled after inspection succeeds.
                set_state(
                    conn,
                    f"{drive_key}_disc_label",
                    label,
                )
            else:
                log_event(
                    conn,
                    "ERROR",
                    "DISC_INSPECTION_FAILED",
                    (
                        f"disc inspector returned {rc}; "
                        f"drive={device}; label={label}"
                    ),
                )

        del ACTIVE_INSPECTIONS[drive_key]

    with db_connection() as conn:
        for drive_key, device in drives:

            # Never probe a drive while its inspector owns it.
            active = ACTIVE_INSPECTIONS.get(drive_key)

            if active is not None:
                _, label, process = active

                print(
                    f"{device}: inspection active "
                    f"(pid={process.pid}, label={label})"
                )
                continue

            # Never run MakeMKV/media detection against a drive while
            # ripper.py owns it. The ripper must have exclusive access
            # to the optical device until the rip completes.
            ripper = ACTIVE_RIPPERS.get(device)

            if ripper is not None:
                if ripper.poll() is None:
                    print(
                        f"{device}: rip active "
                        f"(pid={ripper.pid}); inspection deferred"
                    )
                    continue

            current_label = detect_disc(device)
            state_key = f"{drive_key}_disc_label"
            previous_label = get_state(
                conn,
                state_key,
            )

            if current_label:
                print(
                    f"{device}: disc present: "
                    f"{current_label}"
                )

                if previous_label != current_label:
                    print(
                        f"NEW DISC DETECTED: "
                        f"{device} -> {current_label}"
                    )

                    log_event(
                        conn,
                        "INFO",
                        "DISC_INSERTED",
                        (
                            f"drive={device}; "
                            f"label={current_label}"
                        ),
                    )

                    process = subprocess.Popen(
                        [
                            "python3",
                            "/opt/arm/scripts/disc_inspector.py",
                            device,
                        ],
                    )

                    ACTIVE_INSPECTIONS[drive_key] = (
                        device,
                        current_label,
                        process,
                    )

                    print(
                        f"Started disc inspection for "
                        f"{device}; pid={process.pid}"
                    )

                else:
                    print(
                        f"{device}: same disc still present."
                    )

            else:
                if previous_label:
                    print(
                        f"{device}: disc removed."
                    )

                    log_event(
                        conn,
                        "INFO",
                        "DISC_REMOVED",
                        (
                            f"drive={device}; "
                            f"previous_label={previous_label}"
                        ),
                    )

                    set_state(
                        conn,
                        state_key,
                        "",
                    )

                else:
                    print(
                        f"{device}: empty."
                    )



def main():
    config = load_config()

    poll_seconds = config["automation"].getint(
        "poll_seconds",
        fallback=15,
    )

    print("ARM orchestrator starting.")
    print(f"Database: {DB}")
    print(f"Poll interval: {poll_seconds}s")

    if not DB.exists():
        raise SystemExit(
            f"ARM database does not exist: {DB}"
        )

    while True:
        try:
            # Reload configuration every cycle so dashboard toggles
            # such as auto_eject take effect without restarting ARM.
            config = load_config()

            recover_orphaned_jobs()

            inspect_new_discs(config)

            process_pipeline(config)

        except KeyboardInterrupt:
            print()
            print("ARM orchestrator stopped.")
            return 0

        except Exception as exc:
            print(
                f"ARM orchestrator exception: {exc}"
            )

            try:
                with db_connection() as conn:
                    log_event(
                        conn,
                        "ERROR",
                        "ORCHESTRATOR_EXCEPTION",
                        str(exc),
                    )
            except Exception:
                pass

        time.sleep(poll_seconds)


if __name__ == "__main__":
    raise SystemExit(main())

scripts/disc_inspector.py

#!/usr/bin/env python3

import configparser
import hashlib
import json
import re
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


CONFIG = Path("/opt/arm/config/arm.conf")
DB = Path("/opt/arm/state/arm.db")


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def load_config():
    config = configparser.ConfigParser()
    config.read(CONFIG)
    return config


def run_makemkv(drive):
    try:
        result = subprocess.run(
            ["makemkvcon", "-r", "info", f"disc:{drive}"],
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=600,
            check=False,
        )

        return result.returncode, result.stdout, None

    except subprocess.TimeoutExpired as exc:
        output = exc.stdout or ""

        if isinstance(output, bytes):
            output = output.decode(errors="replace")

        return None, output, "INSPECTION_TIMEOUT"


def parse_makemkv(output):
    disc = {
        "label": None,
        "source": None,
        "titles": [],
    }

    titles = {}

    for line in output.splitlines():
        if line.startswith("CINFO:"):
            parts = line.split(",", 2)

            if len(parts) != 3:
                continue

            field = parts[0].split(":", 1)[1]
            value = parts[2].strip('"')

            if field == "2":
                disc["label"] = value
            elif field == "32":
                disc["source"] = value

        elif line.startswith("TINFO:"):
            match = re.match(
                r'^TINFO:(\d+),(\d+),\d+,"(.*)"$',
                line,
            )

            if not match:
                continue

            makemkv_title_id = int(match.group(1))
            field = match.group(2)
            value = match.group(3)

            if makemkv_title_id not in titles:
                titles[makemkv_title_id] = {
                    "title_number": makemkv_title_id,
                    "name": None,
                    "makemkv_title_id": makemkv_title_id,
                    "duration": None,
                    "size_display": None,
                    "size_bytes": None,
                    "chapters": None,
                    "source_file": None,
                }

            title = titles[makemkv_title_id]

            if field == "2":
                title["name"] = value
            elif field == "8":
                title["chapters"] = int(value)
            elif field == "9":
                title["duration"] = value
            elif field == "10":
                title["size_display"] = value
            elif field == "11":
                title["size_bytes"] = int(value)
            elif field == "27":
                title["source_file"] = value

    ordered_titles = [
        titles[number]
        for number in sorted(titles)
    ]

    for database_title_number, title in enumerate(
        ordered_titles
    ):
        title["title_number"] = database_title_number

    disc["titles"] = ordered_titles

    return disc


def duration_seconds(value):
    if not value:
        return None

    try:
        h, m, s = value.split(":")
        return int(h) * 3600 + int(m) * 60 + int(s)
    except ValueError:
        return None


def disc_fingerprint(disc):
    """
    Build a stable SHA-256 fingerprint from normalized MakeMKV
    title metadata.

    Deliberately excludes:
      - drive path
      - drive model
      - timestamps
      - physical disc label

    so the same physical disc should fingerprint identically
    in either optical drive.

    Exact byte size is normalized to MiB to reduce sensitivity
    to tiny reporting differences.
    """

    parts = []

    for title in disc.get("titles", []):
        duration = duration_seconds(
            title.get("duration")
        ) or 0

        size_bytes = title.get("size_bytes") or 0
        size_mib = int(
            round(
                size_bytes
                / (1024 * 1024)
            )
        )

        chapters = title.get("chapters") or 0

        makemkv_id = title.get(
            "makemkv_title_id"
        )

        if makemkv_id is None:
            makemkv_id = title.get(
                "title_number",
                0,
            )

        parts.append(
            (
                int(makemkv_id),
                int(duration),
                int(size_mib),
                int(chapters),
            )
        )

    normalized = "|".join(
        f"{title_id}:{duration}:{size_mib}:{chapters}"
        for title_id, duration, size_mib, chapters
        in parts
    )

    return hashlib.sha256(
        normalized.encode("utf-8")
    ).hexdigest()


def is_generic_disc_label(label):
    """
    Return True for physical labels that do not meaningfully
    identify the movie.
    """
    if not label:
        return True

    normalized = re.sub(
        r"[^A-Z0-9]+",
        "_",
        label.upper(),
    ).strip("_")

    generic = {
        "DVD_VIDEO",
        "VIDEO_DVD",
        "DVDVIDEO",
        "VIDEO",
        "DVD",
        "UNTITLED",
        "NO_LABEL",
        "NOLABEL",
        "UNKNOWN",
    }

    return normalized in generic


def generic_fingerprint_title(label, fingerprint):
    """
    Build the unique human-facing temporary title used by ARM
    and Jellyfin for a generic physical disc label.
    """
    normalized = re.sub(
        r"[^A-Z0-9]+",
        "_",
        (label or "DVD_VIDEO").upper(),
    ).strip("_")

    if not normalized:
        normalized = "DVD_VIDEO"

    return f"{normalized}_{fingerprint[:12]}"


def ambiguous_tv_volume_label(label):
    """
    Recognize publisher-style volume/disc labels that strongly
    suggest episodic media but do NOT provide a trustworthy season.

    Example:
        Seinfeld - Vol. 2 (Disc 1)

    Volume numbers are deliberately NOT treated as season numbers.
    Returns a normalized possible show title, or None.
    """
    if not label:
        return None

    name = label.replace("_", " ")

    has_volume = re.search(
        r"\b(?:VOL\.?|VOLUME)\s*\d+\b",
        name,
        flags=re.IGNORECASE,
    )

    has_disc = re.search(
        r"\bDISC\s*\d+\b",
        name,
        flags=re.IGNORECASE,
    )

    if not (has_volume and has_disc):
        return None

    # Remove "(Disc 1)" or "Disc 1".
    name = re.sub(
        r"\(?\bDISC\s*\d+\b\)?",
        "",
        name,
        flags=re.IGNORECASE,
    )

    # Remove "Vol. 2" or "Volume 2".
    name = re.sub(
        r"\b(?:VOL\.?|VOLUME)\s*\d+\b",
        "",
        name,
        flags=re.IGNORECASE,
    )

    # Clean separators left behind.
    name = re.sub(r"[-–—]+", " ", name)
    name = re.sub(r"\s+", " ", name).strip()

    name = name.title().replace(" Of ", " of ")

    return name or None


def classify_disc(label, config=None):
    """
    Classify a disc conservatively.

    TV detection order:
      1. Explicit season marker in the disc label.
      2. Configured [tv_overrides] label prefix.
      3. Otherwise treat the disc as a movie.

    Returns:
        (media_type, season_number, show_title)
    """

    if not label:
        return "MOVIE", None, None

    normalized = label.upper().replace("-", "_")

    patterns = [
        r"(?:^|[_\s])SEASON[_\s]?(\d{1,2})(?:$|[_\s])",
        r"(?:^|[_\s])S(\d{1,2})(?:$|[_\s])",
    ]

    for pattern in patterns:
        match = re.search(pattern, normalized)

        if match:
            season = int(match.group(1))

            if 1 <= season <= 99:
                return "TV", season, None

    # Some commercial TV sets use spelled-out season numbers,
    # for example BOONDOCKS_SEASON_ONE_DISC_ONE.
    number_words = {
        "ONE": 1,
        "TWO": 2,
        "THREE": 3,
        "FOUR": 4,
        "FIVE": 5,
        "SIX": 6,
        "SEVEN": 7,
        "EIGHT": 8,
        "NINE": 9,
        "TEN": 10,
        "ELEVEN": 11,
        "TWELVE": 12,
        "THIRTEEN": 13,
        "FOURTEEN": 14,
        "FIFTEEN": 15,
        "SIXTEEN": 16,
        "SEVENTEEN": 17,
        "EIGHTEEN": 18,
        "NINETEEN": 19,
        "TWENTY": 20,
    }

    match = re.search(
        r"(?:^|[_\s])SEASON[_\s]+"
        r"(ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN|"
        r"ELEVEN|TWELVE|THIRTEEN|FOURTEEN|FIFTEEN|SIXTEEN|"
        r"SEVENTEEN|EIGHTEEN|NINETEEN|TWENTY)"
        r"(?:$|[_\s])",
        normalized,
    )

    if match:
        return "TV", number_words[match.group(1)], None

    if config is not None and config.has_section("tv_overrides"):
        for prefix, value in config["tv_overrides"].items():
            prefix_normalized = prefix.upper().replace("-", "_")

            if not normalized.startswith(prefix_normalized):
                continue

            try:
                show_title, season_text = value.rsplit("|", 1)
                season = int(season_text.strip())
            except (ValueError, AttributeError):
                continue

            if 1 <= season <= 99:
                return "TV", season, show_title.strip() or None

    return "MOVIE", None, None


def inspect_drive(conn, drive):
    started = now()

    rc, output, error_code = run_makemkv(drive)

    if error_code:
        conn.execute(
            """
            INSERT INTO events(
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?)
            """,
            (
                now(),
                "ERROR",
                "INSPECTION_FAILED",
                f"drive=/dev/sr{drive}; error={error_code}",
            ),
        )

        print(
            f"  Inspection failed: {error_code}"
        )

        return None

    # MakeMKV can return non-zero while still giving us useful
    # DRV/TINFO information. Therefore parse the output before
    # treating the inspection as failed.
    disc = parse_makemkv(output)

    if not disc["label"]:
        conn.execute(
            """
            INSERT INTO events(timestamp, level, event, message)
            VALUES (?, ?, ?, ?)
            """,
            (
                now(),
                "INFO",
                "NO_DISC",
                f"drive={drive}",
            ),
        )
        return None

    fingerprint = disc_fingerprint(disc)

    media_type, season_number, show_title = classify_disc(
        disc["label"],
        load_config(),
    )

    # Publisher "Volume N / Disc N" labels often identify TV sets
    # without revealing the real season. Treat these as TV requiring
    # metadata confirmation rather than guessing that volume=season.
    if media_type == "MOVIE":
        ambiguous_show = ambiguous_tv_volume_label(
            disc["label"]
        )

        if ambiguous_show:
            media_type = "TV"
            season_number = None
            show_title = ambiguous_show

            print(
                "  Ambiguous TV volume/disc label detected."
            )
            print(
                f"  Possible show: {show_title}"
            )
            print(
                "  Season number requires confirmation."
            )

    duplicate_job_id = None

    if (
        media_type == "MOVIE"
        and is_generic_disc_label(disc["label"])
    ):
        show_title = generic_fingerprint_title(
            disc["label"],
            fingerprint,
        )

        print(
            f"  Generic label detected: {disc['label']}"
        )

        print(
            f"  Effective title: {show_title}"
        )

        duplicate = conn.execute(
            """
            SELECT id
            FROM jobs
            WHERE disc_fingerprint = ?
              AND status = 'LIBRARY_MOVED'
            ORDER BY id DESC
            LIMIT 1
            """,
            (fingerprint,),
        ).fetchone()

        if duplicate:
            duplicate_job_id = duplicate[0]

            print(
                f"  Known fingerprint: already completed "
                f"as Job {duplicate_job_id}"
            )

    job_uuid = (
        f"inspect-{drive}-"
        f"{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}"
    )

    conn.execute(
        """
        INSERT INTO jobs(
            job_uuid,
            created_at,
            updated_at,
            drive,
            disc_label,
            disc_source,
            media_type,
            title,
            status,
            notes,
            disc_fingerprint
        )
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            job_uuid,
            started,
            now(),
            f"/dev/sr{drive}",
            disc["label"],
            disc["source"],
            media_type,
            show_title,
            (
                "SUPERSEDED"
                if duplicate_job_id is not None
                else (
                    "TV_METADATA_NEEDED"
                    if (
                        media_type == "TV"
                        and season_number is None
                    )
                    else "INSPECTED"
                )
            ),
            (
                (
                    "Duplicate generic disc; "
                    f"already completed as Job {duplicate_job_id}"
                )
                if duplicate_job_id is not None
                else (
                    "Created by disc inspector"
                    if media_type == "MOVIE"
                    else (
                        (
                            "Created by disc inspector; "
                            "TV season requires confirmation"
                        )
                        if season_number is None
                        else (
                            "Created by disc inspector; "
                            f"TV season {season_number}"
                        )
                    )
                )
            ),
            fingerprint,
        ),
    )

    job_id = conn.execute(
        "SELECT id FROM jobs WHERE job_uuid = ?",
        (job_uuid,),
    ).fetchone()[0]

    for title in disc["titles"]:
        if (
            not title.get("duration")
            and not title.get("source_file")
        ):
            continue

        conn.execute(
            """
            INSERT INTO job_titles(
                job_id,
                title_number,
                name,
                duration_seconds,
                duration,
                size_bytes,
                size_display,
                chapters,
                source_file,
                selected,
                makemkv_title_id,
                season_number,
                episode_number
            )
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
            """,
            (
                job_id,
                title["title_number"],
                title.get("name"),
                duration_seconds(
                    title.get("duration")
                ),
                title.get("duration"),
                title.get("size_bytes"),
                title.get("size_display"),
                title.get("chapters"),
                title.get("source_file"),
                title.get("makemkv_title_id"),
                season_number,
                None,
            ),
        )

    conn.execute(
        """
        INSERT INTO events(
            timestamp,
            level,
            event,
            message
        )
        VALUES (?, ?, ?, ?)
        """,
        (
            now(),
            "INFO",
            "DISC_INSPECTED",
            json.dumps({
                "job_id": job_id,
                "drive": drive,
                "label": disc["label"],
                "media_type": media_type,
                "season_number": season_number,
                "titles": len(disc["titles"]),
                "fingerprint": fingerprint,
                "fingerprint_short": fingerprint[:12],
                "duplicate_job_id": duplicate_job_id,
                "makemkv_returncode": rc,
            }),
        ),
    )

    print(
        f"  Disc classification: {media_type}"
    )

    print(
        f"  Fingerprint: {fingerprint[:12]}"
    )

    if media_type == "TV":
        print(
            f"  Season: {season_number}"
        )

    if duplicate_job_id is not None:
        print(
            f"  Duplicate disc suppressed: "
            f"Job {job_id} -> SUPERSEDED"
        )

    return job_id


def main():
    config = load_config()

    if len(sys.argv) > 1:
        devices = [sys.argv[1]]
    else:
        devices = [
            value
            for key, value in config["optical"].items()
            if key.startswith("drive_")
        ]

    with sqlite3.connect(DB) as conn:
        for device in devices:
            match = re.search(
                r"sr(\d+)$",
                device,
            )

            if not match:
                print(
                    f"Skipping invalid optical device: "
                    f"{device}"
                )
                continue

            drive = match.group(1)

            print(
                f"Inspecting {device}..."
            )

            job_id = inspect_drive(
                conn,
                drive,
            )

            if job_id:
                print(
                    f"  Disc detected. "
                    f"Job ID: {job_id}"
                )
            else:
                print(
                    "  No disc detected."
                )

        conn.commit()


if __name__ == "__main__":
    main()

scripts/title_selector.py

#!/usr/bin/env python3

import configparser
import json
import re
import sqlite3
from datetime import datetime, timezone
from pathlib import Path


CONFIG = Path("/opt/arm/config/arm.conf")
DB = Path("/opt/arm/state/arm.db")


def load_config():
    config = configparser.ConfigParser()
    config.read(CONFIG)
    return config


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def score_title(title):
    """
    Score a title as a likely main feature.

    Used for MOVIE jobs only.
    """

    duration = title["duration_seconds"] or 0
    chapters = title["chapters"] or 0
    size = title["size_bytes"] or 0

    score = 0

    if duration >= 5400:
        score += 100
    elif duration >= 3600:
        score += 60
    elif duration >= 1800:
        score += 20

    score += min(duration // 60, 180)

    if chapters >= 10:
        score += 30
    elif chapters >= 5:
        score += 15
    elif chapters >= 2:
        score += 5

    score += min(
        size // (1024 * 1024 * 100),
        20,
    )

    return score


def tv_show_name(disc_label, title):
    """
    Determine the canonical Jellyfin TV series name.

    Supports numeric and spelled-out season/disc markers.
    """
    if title:
        return title.strip()

    name = disc_label or ""
    name = name.replace("_", " ")

    number_word = (
        r"(?:ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN|"
        r"ELEVEN|TWELVE|THIRTEEN|FOURTEEN|FIFTEEN|SIXTEEN|"
        r"SEVENTEEN|EIGHTEEN|NINETEEN|TWENTY)"
    )

    name = re.sub(
        rf"\bDISC\s*(?:\d+|{number_word})\b",
        "",
        name,
        flags=re.IGNORECASE,
    )

    name = re.sub(
        rf"\bSEASON\s*(?:\d+|{number_word})\b",
        "",
        name,
        flags=re.IGNORECASE,
    )

    name = re.sub(
        r"\bS\d{1,2}\b",
        "",
        name,
        flags=re.IGNORECASE,
    )

    name = re.sub(r"\s+", " ", name).strip()
    name = name.title().replace(" Of ", " of ")

    return name or "Unknown TV Show"


def promote_selected_title(conn, job_id, title_name):
    """
    Copy a selected title name into jobs.title.

    For TV jobs this is informational only; the library
    mover will ultimately use the disc label/show name.
    """

    if not title_name:
        return False

    conn.execute(
        """
        UPDATE jobs
        SET
            title = CASE
                WHEN title IS NULL OR TRIM(title) = ''
                    THEN ?
                ELSE title
            END,
            updated_at = ?
        WHERE id = ?
        """,
        (
            title_name.strip(),
            now(),
            job_id,
        ),
    )

    return True


def is_tv_episode_candidate(title):
    """
    Broad first-pass TV episode detector.

    The final selection is made by select_tv_titles(), which groups
    short-form titles by consistent chapter structure.
    """
    duration = title["duration_seconds"] or 0
    chapters = title["chapters"] or 0
    size = title["size_bytes"] or 0

    # Permit slightly-under-20-minute broadcast episodes.
    if duration < 19 * 60:
        return False

    if duration > 75 * 60:
        return False

    if size and size < 500 * 1024 * 1024:
        return False

    if chapters < 1:
        return False

    return True


def choose_tv_episode_group(candidates):
    """
    Choose the strongest coherent TV episode group.

    For short-form television (19-35 minutes), DVDs sometimes
    contain multiple alternate program-chain groups with nearly
    identical durations but different chapter structures.

    Conservative behavior:
      - One short-form chapter-count group: keep all candidates.
      - If competing short-form groups exist, automatically choose
        one only when it has more titles than every other group
        and contains at least 3 titles.
      - If long-form TV candidates are mixed in, do not guess.
    """

    if not candidates:
        return []

    short = [
        item
        for item in candidates
        if (item["duration_seconds"] or 0) <= 35 * 60
    ]

    long_form = [
        item
        for item in candidates
        if (item["duration_seconds"] or 0) > 35 * 60
    ]

    if not short:
        return list(candidates)

    by_chapters = {}

    for item in short:
        chapters = int(item["chapters"] or 0)

        by_chapters.setdefault(
            chapters,
            [],
        ).append(item)

    # No competing short-form structures.
    if len(by_chapters) <= 1:
        return list(candidates)

    ranked = sorted(
        by_chapters.items(),
        key=lambda pair: (
            len(pair[1]),
            sum(
                int(item["size_bytes"] or 0)
                for item in pair[1]
            ),
        ),
        reverse=True,
    )

    winning_chapters, winning_group = ranked[0]
    runner_up_count = len(ranked[1][1])

    # Mixed short/long discs are ambiguous.
    if long_form:
        return list(candidates)

    if (
        len(winning_group) >= 3
        and len(winning_group) > runner_up_count
    ):
        return list(winning_group)

    return list(candidates)


def select_tv_titles(conn, job, titles):
    """
    Select episode titles for a confidently classified TV disc.

    Strategy:
      1. Find independently strong episode candidates.
      2. Reject combined titles, short extras, low-chapter bonus
         features, and tiny files using is_tv_episode_candidate().
      3. Select all remaining candidates in disc/title order.
      4. Allow a single strong candidate for legitimate
         single-episode TV discs.
      5. Assign sequential episode numbers.
    """

    job_id = job["id"]

    candidates = [
        dict(row)
        for row in titles
        if is_tv_episode_candidate(dict(row))
    ]

    if not candidates:
        conn.execute(
            """
            UPDATE jobs
            SET
                status = ?,
                updated_at = ?,
                error_code = ?,
                error_message = ?
            WHERE id = ?
            """,
            (
                "SELECTION_FAILED",
                now(),
                "TV_EPISODES_NOT_FOUND",
                "Could not identify any strong episode-sized titles.",
                job_id,
            ),
        )

        conn.execute(
            """
            INSERT INTO events(
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?)
            """,
            (
                now(),
                "ERROR",
                "TITLE_SELECTION_FAILED",
                json.dumps({
                    "job_id": job_id,
                    "media_type": "TV",
                    "reason": "no strong episode candidates",
                    "candidate_count": 0,
                }),
            ),
        )

        return False

    selected_group = choose_tv_episode_group(
        candidates
    )

    # Sort episodes in physical disc/title order.
    selected_group.sort(
        key=lambda item: item["title_number"]
    )

    if len(selected_group) != len(candidates):
        rejected = [
            item["title_number"]
            for item in candidates
            if item not in selected_group
        ]

        print(
            "  TV structural grouping: "
            f"selected {len(selected_group)} of "
            f"{len(candidates)} candidates; "
            f"rejected titles {rejected}"
        )

    # Clear every previous selection.
    conn.execute(
        """
        UPDATE job_titles
        SET
            selected = 0,
            season_number = NULL,
            episode_number = NULL
        WHERE job_id = ?
        """,
        (job_id,),
    )

    season_number = job["season_number"]

    # Determine the normalized show identity first. Episode numbering
    # must continue only within the same show and season.
    show_name = tv_show_name(
        job["disc_label"],
        job["title"],
    )

    # Continue TV episode numbering across successfully completed discs
    # belonging to THIS show and season only.
    #
    # We compare normalized show names in Python because older jobs may
    # not have jobs.title populated and instead derive their show name
    # from the physical disc label.
    previous_rows = conn.execute(
        """
        SELECT
            j.disc_label,
            j.title,
            jt.episode_number
        FROM job_titles jt
        JOIN jobs j
          ON j.id = jt.job_id
        WHERE j.media_type = 'TV'
          AND j.status = 'LIBRARY_MOVED'
          AND jt.selected = 1
          AND jt.season_number = ?
          AND jt.episode_number IS NOT NULL
        """,
        (season_number,),
    ).fetchall()

    previous_max_episode = 0

    for previous_row in previous_rows:
        previous_show_name = tv_show_name(
            previous_row["disc_label"],
            previous_row["title"],
        )

        if previous_show_name != show_name:
            continue

        previous_max_episode = max(
            previous_max_episode,
            int(previous_row["episode_number"]),
        )

    # Also account for episodes already present in the Jellyfin
    # library. This protects against manually added episodes and
    # previous runs whose database state was not recorded as
    # LIBRARY_MOVED.
    config = load_config()
    media_root = Path(config["jellyfin"]["media_root"])

    tv_root = media_root / "TV Shows"
    season_dir = (
        tv_root
        / show_name
        / f"Season {int(season_number):02d}"
    )

    library_max_episode = 0

    if season_dir.is_dir():
        pattern = re.compile(
            rf"^.+ - S{int(season_number):02d}E(\d{{2}})\.mp4$",
            re.IGNORECASE,
        )

        for library_file in season_dir.iterdir():
            if not library_file.is_file():
                continue

            match = pattern.match(library_file.name)

            if match:
                library_max_episode = max(
                    library_max_episode,
                    int(match.group(1)),
                )

    effective_max_episode = max(
        previous_max_episode,
        library_max_episode,
    )

    start_episode = effective_max_episode + 1

    print(
        f"  Season {int(season_number):02d} episode state: "
        f"DB=E{previous_max_episode:02d}, "
        f"library=E{library_max_episode:02d}, "
        f"next=E{start_episode:02d}"
    )

    for episode_number, title in enumerate(
        selected_group,
        start=start_episode,
    ):
        conn.execute(
            """
            UPDATE job_titles
            SET
                selected = 1,
                season_number = ?,
                episode_number = ?
            WHERE id = ?
            """,
            (
                season_number,
                episode_number,
                title["id"],
            ),
        )

        conn.execute(
            """
            INSERT INTO events(
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?)
            """,
            (
                now(),
                "INFO",
                "TV_EPISODE_SELECTED",
                json.dumps({
                    "job_id": job_id,
                    "title_number": title["title_number"],
                    "season_number": season_number,
                    "episode_number": episode_number,
                    "duration": title["duration"],
                    "size": title["size_display"],
                    "chapters": title["chapters"],
                    "source_file": title["source_file"],
                }),
            ),
        )

        print(
            f"  Selected episode "
            f"S{season_number:02d}E{episode_number:02d}: "
            f"title {title['title_number']} "
            f"'{title['name']}' "
            f"({title['duration']}, "
            f"{title['size_display']}, "
            f"{title['chapters']} chapters)"
        )

    conn.execute(
        """
        UPDATE jobs
        SET
            status = ?,
            updated_at = ?,
            error_code = NULL,
            error_message = NULL
        WHERE id = ?
        """,
        (
            "TITLE_SELECTED",
            now(),
            job_id,
        ),
    )

    conn.execute(
        """
        INSERT INTO events(
            timestamp,
            level,
            event,
            message
        )
        VALUES (?, ?, ?, ?)
        """,
        (
            now(),
            "INFO",
            "TV_TITLES_SELECTED",
            json.dumps({
                "job_id": job_id,
                "season_number": season_number,
                "episode_count": len(selected_group),
                "title_numbers": [
                    item["title_number"]
                    for item in selected_group
                ],
            }),
        ),
    )

    return True


def select_movie_title(conn, job, titles):
    """
    Existing movie behavior: select exactly one best feature.
    """

    job_id = job["id"]

    candidates = []

    for row in titles:
        title = dict(row)

        title["score"] = score_title(title)
        candidates.append(title)

    candidates.sort(
        key=lambda x: (
            x["score"],
            x["duration_seconds"] or 0,
            x["size_bytes"] or 0,
        ),
        reverse=True,
    )

    selected = candidates[0]

    conn.execute(
        """
        UPDATE job_titles
        SET
            selected = 0,
            season_number = NULL,
            episode_number = NULL
        WHERE job_id = ?
        """,
        (job_id,),
    )

    conn.execute(
        """
        UPDATE job_titles
        SET selected = 1
        WHERE id = ?
        """,
        (selected["id"],),
    )

    promote_selected_title(
        conn,
        job_id,
        selected["name"],
    )

    conn.execute(
        """
        UPDATE jobs
        SET
            status = ?,
            updated_at = ?,
            error_code = NULL,
            error_message = NULL
        WHERE id = ?
        """,
        (
            "TITLE_SELECTED",
            now(),
            job_id,
        ),
    )

    conn.execute(
        """
        INSERT INTO events(
            timestamp,
            level,
            event,
            message
        )
        VALUES (?, ?, ?, ?)
        """,
        (
            now(),
            "INFO",
            "TITLE_SELECTED",
            json.dumps({
                "job_id": job_id,
                "title_number": selected["title_number"],
                "name": selected["name"],
                "duration": selected["duration"],
                "size": selected["size_display"],
                "chapters": selected["chapters"],
                "source_file": selected["source_file"],
                "score": selected["score"],
                "reason": "highest feature candidate score",
            }),
        ),
    )

    print(
        f"  Selected title "
        f"{selected['title_number']} "
        f"'{selected['name']}' "
        f"({selected['duration']}, "
        f"{selected['size_display']}, "
        f"{selected['chapters']} chapters)"
    )

    return True


def select_job(conn, job):
    job_id = job["id"]

    titles = conn.execute(
        """
        SELECT
            id,
            title_number,
            name,
            duration_seconds,
            duration,
            size_bytes,
            size_display,
            chapters,
            source_file,
            selected,
            makemkv_title_id,
            season_number,
            episode_number
        FROM job_titles
        WHERE job_id = ?
        ORDER BY title_number
        """,
        (job_id,),
    ).fetchall()

    if not titles:
        conn.execute(
            """
            UPDATE jobs
            SET
                status = ?,
                updated_at = ?,
                error_code = ?,
                error_message = ?
            WHERE id = ?
            """,
            (
                "SELECTION_FAILED",
                now(),
                "NO_TITLES",
                "No titles were available for selection.",
                job_id,
            ),
        )

        conn.execute(
            """
            INSERT INTO events(
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?)
            """,
            (
                now(),
                "ERROR",
                "TITLE_SELECTION_FAILED",
                json.dumps({
                    "job_id": job_id,
                    "reason": "NO_TITLES",
                }),
            ),
        )

        return False

    # Never overwrite an existing selection.
    existing = [
        row for row in titles
        if row["selected"] == 1
    ]

    if existing:
        if job["media_type"] == "TV":
            print(
                f"  Existing TV selection: "
                f"{len(existing)} title(s)"
            )

            conn.execute(
                """
                UPDATE jobs
                SET
                    status = ?,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    "TITLE_SELECTED",
                    now(),
                    job_id,
                ),
            )

            return True

        selected = existing[0]

        promote_selected_title(
            conn,
            job_id,
            selected["name"],
        )

        conn.execute(
            """
            UPDATE jobs
            SET
                status = ?,
                updated_at = ?
            WHERE id = ?
            """,
            (
                "TITLE_SELECTED",
                now(),
                job_id,
            ),
        )

        print(
            f"  Existing selection: title "
            f"{selected['title_number']} "
            f"'{selected['name']}'"
        )

        return True

    if job["media_type"] == "TV":
        return select_tv_titles(
            conn,
            job,
            titles,
        )

    return select_movie_title(
        conn,
        job,
        titles,
    )


def main():
    if not DB.exists():
        raise SystemExit(
            f"Database does not exist: {DB}"
        )

    with sqlite3.connect(DB) as conn:
        conn.row_factory = sqlite3.Row

        jobs = conn.execute(
            """
            SELECT
                id,
                drive,
                disc_label,
                media_type,
                title,
                year,
                status,
                (
                    SELECT season_number
                    FROM job_titles jt
                    WHERE jt.job_id = jobs.id
                      AND jt.season_number IS NOT NULL
                    LIMIT 1
                ) AS season_number
            FROM jobs
            WHERE status = 'INSPECTED'
            ORDER BY id
            """
        ).fetchall()

        if not jobs:
            print(
                "No INSPECTED jobs require title selection."
            )
            return

        for job in jobs:
            print(
                f"Selecting titles for Job {job['id']} "
                f"({job['disc_label']})..."
            )

            select_job(
                conn,
                job,
            )

        conn.commit()

    print("Title selection complete.")


if __name__ == "__main__":
    main()

scripts/ripper.py

#!/usr/bin/env python3

import configparser
import json
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


CONFIG = Path("/opt/arm/config/arm.conf")
DB = Path("/opt/arm/state/arm.db")


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def load_config():
    config = configparser.ConfigParser()
    config.read(CONFIG)
    return config


def log_event(conn, job_id, level, event, message):
    conn.execute(
        """
        INSERT INTO events(
            job_id,
            timestamp,
            level,
            event,
            message
        )
        VALUES (?, ?, ?, ?, ?)
        """,
        (job_id, now(), level, event, message),
    )


def get_next_title(conn, drive):
    """
    Return the next selected title that has not already been ripped.

    Selected titles remain selected in job_titles. Completion is
    tracked separately in job_files.
    """

    return conn.execute(
        """
        SELECT
            j.id,
            j.drive,
            j.disc_label,
            j.media_type,
            jt.id,
            jt.title_number,
            jt.makemkv_title_id,
            jt.source_file,
            jt.season_number,
            jt.episode_number
        FROM jobs j
        JOIN job_titles jt
          ON jt.job_id = j.id
        WHERE j.status = 'TITLE_SELECTED'
          AND j.drive = ?
          AND jt.selected = 1
          AND NOT EXISTS (
              SELECT 1
              FROM job_files jf
              WHERE jf.job_id = j.id
                AND jf.job_title_id = jt.id
                AND jf.status = 'RIPPED'
          )
        ORDER BY
            j.id,
            jt.episode_number IS NULL,
            jt.episode_number,
            jt.title_number
        LIMIT 1
        """,
        (drive,),
    ).fetchone()


def selected_titles_total(conn, job_id):
    return conn.execute(
        """
        SELECT COUNT(*)
        FROM job_titles
        WHERE job_id = ?
          AND selected = 1
        """,
        (job_id,),
    ).fetchone()[0]


def ripped_selected_titles(conn, job_id):
    return conn.execute(
        """
        SELECT COUNT(*)
        FROM job_titles jt
        WHERE jt.job_id = ?
          AND jt.selected = 1
          AND EXISTS (
              SELECT 1
              FROM job_files jf
              WHERE jf.job_id = jt.job_id
                AND jf.job_title_id = jt.id
                AND jf.status = 'RIPPED'
          )
        """,
        (job_id,),
    ).fetchone()[0]


def ensure_job_file(
    conn,
    job_id,
    title_row_id,
    season_number,
    episode_number,
):
    """
    Create the per-title tracking row if it does not already exist.
    """

    row = conn.execute(
        """
        SELECT id
        FROM job_files
        WHERE job_id = ?
          AND job_title_id = ?
        LIMIT 1
        """,
        (
            job_id,
            title_row_id,
        ),
    ).fetchone()

    if row:
        return row[0]

    timestamp = now()

    cursor = conn.execute(
        """
        INSERT INTO job_files(
            job_id,
            job_title_id,
            season_number,
            episode_number,
            status,
            created_at,
            updated_at
        )
        VALUES (?, ?, ?, ?, 'PENDING', ?, ?)
        """,
        (
            job_id,
            title_row_id,
            season_number,
            episode_number,
            timestamp,
            timestamp,
        ),
    )

    return cursor.lastrowid


def mark_job_file_failed(
    conn,
    job_file_id,
    error_code,
    message,
):
    conn.execute(
        """
        UPDATE job_files
        SET
            status = 'RIP_FAILED',
            error_code = ?,
            error_message = ?,
            updated_at = ?
        WHERE id = ?
        """,
        (
            error_code,
            message,
            now(),
            job_file_id,
        ),
    )


def mark_job_failed(
    conn,
    job_id,
    error_code,
    message,
):
    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'RIP_FAILED',
            error_code = ?,
            error_message = ?,
            updated_at = ?
        WHERE id = ?
        """,
        (
            error_code,
            message,
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "ERROR",
        "RIP_FAILED",
        message,
    )

    conn.commit()


def rip_title(conn, config, job):
    (
        job_id,
        drive,
        disc_label,
        media_type,
        title_row_id,
        title_number,
        makemkv_title_id,
        source_file,
        season_number,
        episode_number,
    ) = job

    rip_work = Path(config["ripping"]["rip_work"])
    log_root = Path(config["state"]["logs"])

    job_dir = rip_work / f"job-{job_id}"

    if (
        media_type == "TV"
        and season_number is not None
        and episode_number is not None
    ):
        episode_dir = (
            job_dir
            / f"S{season_number:02d}E{episode_number:02d}"
        )
    else:
        episode_dir = job_dir

    episode_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    log_suffix = (
        f"-S{season_number:02d}E{episode_number:02d}"
        if (
            media_type == "TV"
            and season_number is not None
            and episode_number is not None
        )
        else ""
    )

    log_file = (
        log_root
        / f"job-{job_id}-rip{log_suffix}.log"
    )

    if makemkv_title_id is None:
        raise RuntimeError(
            f"Job {job_id}, title {title_number} "
            f"has no MakeMKV title ID"
        )

    makemkv_title_id = int(makemkv_title_id)

    # MakeMKV title IDs are used directly by makemkvcon.
    rip_title_id = makemkv_title_id

    job_file_id = ensure_job_file(
        conn,
        job_id,
        title_row_id,
        season_number,
        episode_number,
    )

    conn.execute(
        """
        UPDATE job_files
        SET
            status = 'RIPPING',
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            now(),
            job_file_id,
        ),
    )

    total_selected = selected_titles_total(
        conn,
        job_id,
    )

    ripped_before = ripped_selected_titles(
        conn,
        job_id,
    )

    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'RIPPING',
            rip_started_at = COALESCE(rip_started_at, ?),
            updated_at = ?
        WHERE id = ?
        """,
        (
            now(),
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "INFO",
        "RIP_STARTED",
        json.dumps({
            "job_file_id": job_file_id,
            "media_type": media_type,
            "drive": drive,
            "database_title_number": title_number,
            "job_title_row_id": title_row_id,
            "makemkv_reported_title_id": makemkv_title_id,
            "rip_selection_id": rip_title_id,
            "source_file": source_file,
            "destination": str(episode_dir),
            "season_number": season_number,
            "episode_number": episode_number,
            "selected_titles_total": total_selected,
            "ripped_selected_before": ripped_before,
        }),
    )

    conn.commit()

    device_number = drive.replace(
        "/dev/sr",
        "",
    )

    command = [
        "makemkvcon",
        "-r",
        "mkv",
        f"disc:{device_number}",
        str(rip_title_id),
        str(episode_dir),
    ]

    print(
        f"Ripping Job {job_id}: {disc_label}"
    )

    if media_type == "TV":
        print(
            f"  Episode: "
            f"S{season_number:02d}E{episode_number:02d}"
        )

    print(f"  Drive: {drive}")
    print(f"  Database title: {title_number}")
    print(
        f"  MakeMKV reported title: "
        f"{makemkv_title_id}"
    )
    print(
        f"  MakeMKV rip selection: "
        f"{rip_title_id}"
    )
    print(f"  Output: {episode_dir}")
    print(f"  Log: {log_file}")
    print()

    try:
        with log_file.open("w") as log:
            result = subprocess.run(
                command,
                stdout=log,
                stderr=subprocess.STDOUT,
                text=True,
                check=False,
            )

    except Exception as exc:
        message = (
            f"Exception while running MakeMKV: {exc}"
        )

        mark_job_file_failed(
            conn,
            job_file_id,
            "RIP_EXCEPTION",
            message,
        )

        mark_job_failed(
            conn,
            job_id,
            "RIP_EXCEPTION",
            message,
        )

        print("RIP FAILED")
        print(f"  {message}")
        return False

    output_files = [
        path
        for path in episode_dir.glob("*.mkv")
        if path.is_file()
        and path.stat().st_size > 0
    ]

    expected_file = (
        episode_dir / source_file
        if source_file
        else None
    )

    if (
        result.returncode != 0
        or expected_file is None
        or not expected_file.is_file()
    ):
        message = (
            f"MakeMKV returncode={result.returncode}; "
            f"expected_file={expected_file}; "
            f"valid_mkv_files={len(output_files)}"
        )

        mark_job_file_failed(
            conn,
            job_file_id,
            "RIP_OUTPUT_INVALID",
            message,
        )

        mark_job_failed(
            conn,
            job_id,
            "RIP_OUTPUT_INVALID",
            message,
        )

        print("RIP FAILED")
        print(f"  {message}")

        if output_files:
            print("  Actual MKV files:")
            for path in output_files:
                print(f"    {path}")

        return False

    if expected_file.stat().st_size <= 0:
        message = (
            f"Expected output file is empty: "
            f"{expected_file}"
        )

        mark_job_file_failed(
            conn,
            job_file_id,
            "RIP_OUTPUT_EMPTY",
            message,
        )

        mark_job_failed(
            conn,
            job_id,
            "RIP_OUTPUT_EMPTY",
            message,
        )

        print("RIP FAILED")
        print(f"  {message}")
        return False

    output_size = expected_file.stat().st_size

    conn.execute(
        """
        UPDATE job_files
        SET
            source_file = ?,
            rip_file = ?,
            status = 'RIPPED',
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            source_file,
            str(expected_file),
            now(),
            job_file_id,
        ),
    )

    total_selected = selected_titles_total(
        conn,
        job_id,
    )

    ripped_after = ripped_selected_titles(
        conn,
        job_id,
    )

    all_ripped = (
        total_selected > 0
        and ripped_after >= total_selected
    )

    if all_ripped:
        next_status = "RIPPED"

        # Preserve the old destination field for compatibility,
        # but the authoritative TV file list is now job_files.
        destination = str(expected_file)
    else:
        next_status = "TITLE_SELECTED"

        # Do not overwrite jobs.destination for an unfinished TV job.
        destination = None

    conn.execute(
        """
        UPDATE jobs
        SET
            status = ?,
            rip_completed_at = CASE
                WHEN ? = 'RIPPED' THEN ?
                ELSE rip_completed_at
            END,
            destination = CASE
                WHEN ? = 'RIPPED' THEN ?
                ELSE destination
            END,
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            next_status,
            next_status,
            now(),
            next_status,
            destination,
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "INFO",
        "RIP_COMPLETED",
        json.dumps({
            "job_file_id": job_file_id,
            "file": str(expected_file),
            "size_bytes": output_size,
            "files": [
                str(path)
                for path in output_files
            ],
            "title_number": title_number,
            "season_number": season_number,
            "episode_number": episode_number,
            "selected_titles_total": total_selected,
            "ripped_selected_after": ripped_after,
            "next_status": next_status,
        }),
    )

    conn.commit()

    print("RIP COMPLETED")
    print(f"  File: {expected_file}")
    print(f"  Size: {output_size:,} bytes")

    if not all_ripped:
        print(
            f"  Episodes ripped: "
            f"{ripped_after}/{total_selected}"
        )
        print(
            "  Job remains TITLE_SELECTED "
            "for the next episode."
        )
    else:
        print(
            "  All selected titles for this job "
            "are ripped."
        )

    return True


def main():
    config = load_config()

    if len(sys.argv) != 2:
        print("Usage: ripper.py <drive>")
        return 2

    drive = sys.argv[1]

    with sqlite3.connect(DB) as conn:
        job = get_next_title(conn, drive)

        if not job:
            print(
                "No selected titles available "
                "for ripping."
            )
            return 0

        try:
            return 0 if rip_title(
                conn,
                config,
                job,
            ) else 1

        except Exception as exc:
            job_id = job[0]

            message = (
                f"Unexpected ripper error "
                f"for Job {job_id}: {exc}"
            )

            conn.execute(
                """
                UPDATE jobs
                SET
                    status = 'RIP_FAILED',
                    error_code = 'RIP_UNEXPECTED',
                    error_message = ?,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    message,
                    now(),
                    job_id,
                ),
            )

            log_event(
                conn,
                job_id,
                "ERROR",
                "RIP_FAILED",
                message,
            )

            conn.commit()

            print("RIP FAILED")
            print(f"  {message}")
            return 1


if __name__ == "__main__":
    sys.exit(main())

scripts/transcoder.py

#!/usr/bin/env python3

import configparser
import json
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


CONFIG = Path("/opt/arm/config/arm.conf")
DB = Path("/opt/arm/state/arm.db")


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def load_config():
    config = configparser.ConfigParser()
    config.read(CONFIG)
    return config


def log_event(conn, job_id, level, event, message):
    conn.execute(
        """
        INSERT INTO events(
            job_id, timestamp, level, event, message
        )
        VALUES (?, ?, ?, ?, ?)
        """,
        (job_id, now(), level, event, message),
    )


def get_next_movie_job(conn):
    return conn.execute(
        """
        SELECT id, disc_label, destination
        FROM jobs
        WHERE status = 'RIPPED'
          AND COALESCE(media_type, '') != 'TV'
          AND destination IS NOT NULL
        ORDER BY id
        LIMIT 1
        """
    ).fetchone()


def get_next_tv_file(conn):
    return conn.execute(
        """
        SELECT
            jf.id,
            jf.job_id,
            jf.rip_file,
            jf.season_number,
            jf.episode_number,
            j.disc_label
        FROM job_files jf
        JOIN jobs j ON j.id = jf.job_id
        WHERE j.status = 'RIPPED'
          AND j.media_type = 'TV'
          AND jf.status = 'RIPPED'
          AND jf.rip_file IS NOT NULL
        ORDER BY
            jf.job_id,
            jf.season_number,
            jf.episode_number,
            jf.id
        LIMIT 1
        """
    ).fetchone()


def fail_job(conn, job_id, error_code, message):
    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'TRANSCODE_FAILED',
            error_code = ?,
            error_message = ?,
            updated_at = ?
        WHERE id = ?
        """,
        (error_code, message, now(), job_id),
    )

    log_event(
        conn,
        job_id,
        "ERROR",
        "TRANSCODE_FAILED",
        message,
    )

    conn.commit()


def fail_job_file(
    conn,
    job_file_id,
    error_code,
    message,
):
    conn.execute(
        """
        UPDATE job_files
        SET
            status = 'TRANSCODE_FAILED',
            error_code = ?,
            error_message = ?,
            updated_at = ?
        WHERE id = ?
        """,
        (
            error_code,
            message,
            now(),
            job_file_id,
        ),
    )


def all_tv_files_transcoded(conn, job_id):
    row = conn.execute(
        """
        SELECT COUNT(*)
        FROM job_files
        WHERE job_id = ?
          AND status != 'TRANSCODED'
        """,
        (job_id,),
    ).fetchone()

    return row[0] == 0


def build_command(source, output_file):
    return [
        "HandBrakeCLI",
        "--input",
        str(source),
        "--output",
        str(output_file),
        "--encoder",
        "nvenc_h264",
        "--quality",
        "15",
        "--rate",
        "auto",
        "--crop-mode",
        "none",
        "--custom-anamorphic",
        "--keep-display-aspect",
        "--all-audio",
        "--all-subtitles",
        "--format",
        "av_mp4",
        "--optimize",
    ]


def run_transcode(
    config,
    job_id,
    source,
    output_file,
    label,
):
    log_root = Path(config["state"]["logs"])
    log_file = (
        log_root
        / f"job-{job_id}-{label}-transcode.log"
    )

    print(f"Transcoding Job {job_id}: {label}")
    print(f"  Source: {source}")
    print(f"  Output: {output_file}")
    print("  Encoder: nvenc_h264")
    print(f"  Log: {log_file}")
    print()

    command = build_command(
        source,
        output_file,
    )

    try:
        with log_file.open("w") as log:
            result = subprocess.run(
                command,
                stdout=log,
                stderr=subprocess.STDOUT,
                text=True,
                check=False,
            )
    except Exception as exc:
        return (
            False,
            f"Exception while running HandBrakeCLI: {exc}",
        )

    if result.returncode != 0:
        return (
            False,
            (
                f"HandBrakeCLI returncode="
                f"{result.returncode}; "
                f"output={output_file}"
            ),
        )

    if not output_file.is_file():
        return (
            False,
            (
                "HandBrake completed but output "
                f"is missing: {output_file}"
            ),
        )

    if output_file.stat().st_size <= 0:
        return (
            False,
            (
                "HandBrake produced an empty output: "
                f"{output_file}"
            ),
        )

    return True, None


def transcode_movie(conn, config, job):
    job_id, disc_label, destination = job
    source = Path(destination)

    transcode_root = Path(
        config["transcoding"].get(
            "transcode_root",
            "/rips/transcoding",
        )
    )

    output_dir = (
        transcode_root
        / "output"
        / f"job-{job_id}"
    )

    output_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    if not source.is_file():
        message = (
            f"Source MKV does not exist: {source}"
        )

        fail_job(
            conn,
            job_id,
            "TRANSCODE_SOURCE_MISSING",
            message,
        )

        print(f"TRANSCODE FAILED\n  {message}")
        return False

    if source.stat().st_size <= 0:
        message = (
            f"Source MKV is empty: {source}"
        )

        fail_job(
            conn,
            job_id,
            "TRANSCODE_SOURCE_EMPTY",
            message,
        )

        print(f"TRANSCODE FAILED\n  {message}")
        return False

    output_file = (
        output_dir
        / f"{source.stem}.mp4"
    )

    if output_file.exists():
        output_file.unlink()

    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'TRANSCODING',
            transcode_started_at = ?,
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            now(),
            now(),
            job_id,
        ),
    )

    conn.commit()

    success, message = run_transcode(
        config,
        job_id,
        source,
        output_file,
        "movie",
    )

    if not success:
        fail_job(
            conn,
            job_id,
            "TRANSCODE_PROCESS_FAILED",
            message,
        )

        print(f"TRANSCODE FAILED\n  {message}")
        return False

    output_size = output_file.stat().st_size

    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'TRANSCODED',
            transcode_completed_at = ?,
            destination = ?,
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            now(),
            str(output_file),
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "INFO",
        "TRANSCODE_COMPLETED",
        json.dumps({
            "source": str(source),
            "output": str(output_file),
            "size_bytes": output_size,
        }),
    )

    conn.commit()

    print("TRANSCODE COMPLETED")
    print(f"  File: {output_file}")
    print(f"  Size: {output_size:,} bytes")

    return True


def transcode_tv_file(conn, config, row):
    (
        job_file_id,
        job_id,
        rip_file,
        season_number,
        episode_number,
        disc_label,
    ) = row

    source = Path(rip_file)

    transcode_root = Path(
        config["transcoding"].get(
            "transcode_root",
            "/rips/transcoding",
        )
    )

    episode_label = (
        f"S{season_number:02d}"
        f"E{episode_number:02d}"
    )

    output_dir = (
        transcode_root
        / "output"
        / f"job-{job_id}"
        / episode_label
    )

    output_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    output_file = (
        output_dir
        / f"{source.stem}.mp4"
    )

    if not source.is_file():
        message = (
            f"Source MKV does not exist: {source}"
        )

        fail_job_file(
            conn,
            job_file_id,
            "TRANSCODE_SOURCE_MISSING",
            message,
        )

        fail_job(
            conn,
            job_id,
            "TRANSCODE_SOURCE_MISSING",
            message,
        )

        print(f"TRANSCODE FAILED\n  {message}")
        return False

    if source.stat().st_size <= 0:
        message = (
            f"Source MKV is empty: {source}"
        )

        fail_job_file(
            conn,
            job_file_id,
            "TRANSCODE_SOURCE_EMPTY",
            message,
        )

        fail_job(
            conn,
            job_id,
            "TRANSCODE_SOURCE_EMPTY",
            message,
        )

        print(f"TRANSCODE FAILED\n  {message}")
        return False

    if (
        output_file.exists()
        and output_file.stat().st_size > 0
    ):
        conn.execute(
            """
            UPDATE job_files
            SET
                transcode_file = ?,
                status = 'TRANSCODED',
                error_code = NULL,
                error_message = NULL,
                updated_at = ?
            WHERE id = ?
            """,
            (
                str(output_file),
                now(),
                job_file_id,
            ),
        )

        if all_tv_files_transcoded(
            conn,
            job_id,
        ):
            conn.execute(
                """
                UPDATE jobs
                SET
                    status = 'TRANSCODED',
                    transcode_completed_at = ?,
                    error_code = NULL,
                    error_message = NULL,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    now(),
                    now(),
                    job_id,
                ),
            )

        conn.commit()

        print("Existing transcode reused")
        print(f"  File: {output_file}")

        return True

    if output_file.exists():
        output_file.unlink()

    conn.execute(
        """
        UPDATE job_files
        SET
            status = 'TRANSCODING',
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            now(),
            job_file_id,
        ),
    )

    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'RIPPED',
            transcode_started_at =
                COALESCE(
                    transcode_started_at,
                    ?
                ),
            updated_at = ?
        WHERE id = ?
        """,
        (
            now(),
            now(),
            job_id,
        ),
    )

    conn.commit()

    success, message = run_transcode(
        config,
        job_id,
        source,
        output_file,
        episode_label,
    )

    if not success:
        fail_job_file(
            conn,
            job_file_id,
            "TRANSCODE_PROCESS_FAILED",
            message,
        )

        fail_job(
            conn,
            job_id,
            "TRANSCODE_PROCESS_FAILED",
            message,
        )

        print(f"TRANSCODE FAILED\n  {message}")
        return False

    output_size = output_file.stat().st_size

    conn.execute(
        """
        UPDATE job_files
        SET
            transcode_file = ?,
            status = 'TRANSCODED',
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            str(output_file),
            now(),
            job_file_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "INFO",
        "TV_EPISODE_TRANSCODE_COMPLETED",
        json.dumps({
            "job_file_id": job_file_id,
            "season_number": season_number,
            "episode_number": episode_number,
            "source": str(source),
            "output": str(output_file),
            "size_bytes": output_size,
        }),
    )

    if all_tv_files_transcoded(
        conn,
        job_id,
    ):
        conn.execute(
            """
            UPDATE jobs
            SET
                status = 'TRANSCODED',
                transcode_completed_at = ?,
                error_code = NULL,
                error_message = NULL,
                updated_at = ?
            WHERE id = ?
            """,
            (
                now(),
                now(),
                job_id,
            ),
        )

        log_event(
            conn,
            job_id,
            "INFO",
            "TRANSCODE_COMPLETED",
            "All TV episodes transcoded.",
        )

    conn.commit()

    print("TV EPISODE TRANSCODE COMPLETED")
    print(f"  File: {output_file}")
    print(f"  Size: {output_size:,} bytes")

    return True


def main():
    config = load_config()

    with sqlite3.connect(DB) as conn:
        tv_file = get_next_tv_file(conn)

        if tv_file:
            return (
                0
                if transcode_tv_file(
                    conn,
                    config,
                    tv_file,
                )
                else 1
            )

        movie_job = get_next_movie_job(conn)

        if movie_job:
            return (
                0
                if transcode_movie(
                    conn,
                    config,
                    movie_job,
                )
                else 1
            )

        print("No RIPPED jobs available.")
        return 0


if __name__ == "__main__":
    sys.exit(main())

scripts/library_mover.py

#!/usr/bin/env python3

import configparser
import hashlib
import re
import sqlite3
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path


CONFIG = Path("/opt/arm/config/arm.conf")
DB = Path("/opt/arm/state/arm.db")


def now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def load_config():
    config = configparser.ConfigParser()
    config.read(CONFIG)
    return config


def log_event(conn, job_id, level, event, message):
    conn.execute(
        """
        INSERT INTO events(job_id, timestamp, level, event, message)
        VALUES (?, ?, ?, ?, ?)
        """,
        (job_id, now(), level, event, message),
    )


def get_next_job(conn):
    """
    Return the next completed job requiring library movement.

    TV jobs do not use jobs.destination because each TV episode
    has its own destination in job_files.transcode_file.
    """

    return conn.execute(
        """
        SELECT
            id,
            media_type,
            disc_label,
            title,
            year,
            destination
        FROM jobs
        WHERE status = 'TRANSCODED'
        ORDER BY id
        LIMIT 1
        """
    ).fetchone()


def clean_name(value):
    if not value:
        return ""

    value = value.strip()

    # Remove common MakeMKV/disc suffixes.
    value = re.sub(
        r"\s+DISC\s+\d+\b",
        "",
        value,
        flags=re.IGNORECASE,
    )

    value = re.sub(
        r"[-_ ]+[A-Z]\d+_t\d+\b",
        "",
        value,
        flags=re.IGNORECASE,
    )

    value = re.sub(
        r"[-_ ]+t\d+\b",
        "",
        value,
        flags=re.IGNORECASE,
    )

    # Collapse whitespace.
    value = re.sub(r"\s+", " ", value)

    # Filesystem-safe characters.
    value = re.sub(r'[<>:"/\\|?*]', "", value)

    # Avoid trailing dots/spaces.
    value = value.strip(" .")

    return value


def movie_name(disc_label, title, year):
    name = clean_name(title) if title else clean_name(disc_label)

    if not name:
        name = "Unknown Movie"

    if year:
        return f"{name} ({int(year)})"

    return name


def tv_show_name(disc_label, title, year):
    """
    Determine the canonical Jellyfin TV series directory name.

    Supports numeric and spelled-out season/disc markers.
    """
    if title:
        name = clean_name(title)

    else:
        name = clean_name(disc_label)
        name = name.replace("_", " ")

        number_word = (
            r"(?:ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN|"
            r"ELEVEN|TWELVE|THIRTEEN|FOURTEEN|FIFTEEN|SIXTEEN|"
            r"SEVENTEEN|EIGHTEEN|NINETEEN|TWENTY)"
        )

        name = re.sub(
            rf"\bDISC\s*(?:\d+|{number_word})\b",
            "",
            name,
            flags=re.IGNORECASE,
        )

        name = re.sub(
            rf"\bSEASON\s*(?:\d+|{number_word})\b",
            "",
            name,
            flags=re.IGNORECASE,
        )

        name = re.sub(
            r"\bS\d{1,2}\b",
            "",
            name,
            flags=re.IGNORECASE,
        )

        name = re.sub(r"\s+", " ", name).strip()
        name = name.title().replace(" Of ", " of ")
        name = re.sub(r"\bOf\b", "of", name)

    if not name:
        name = "Unknown TV Show"

    return name


def tv_episode_name(show_name, season_number, episode_number):
    return (
        f"{show_name} - "
        f"S{int(season_number):02d}"
        f"E{int(episode_number):02d}.mp4"
    )


def fail_job(conn, job_id, error_code, message):
    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'LIBRARY_MOVE_FAILED',
            error_code = ?,
            error_message = ?,
            updated_at = ?
        WHERE id = ?
        """,
        (
            error_code,
            message,
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "ERROR",
        "LIBRARY_MOVE_FAILED",
        message,
    )

    conn.commit()


def fail_job_file(
    conn,
    job_file_id,
    error_code,
    message,
):
    conn.execute(
        """
        UPDATE job_files
        SET
            status = 'LIBRARY_MOVE_FAILED',
            error_code = ?,
            error_message = ?,
            updated_at = ?
        WHERE id = ?
        """,
        (
            error_code,
            message,
            now(),
            job_file_id,
        ),
    )


def sha256_file(path, chunk_size=8 * 1024 * 1024):
    """
    Return the SHA-256 digest of a file without loading the
    entire file into memory.
    """
    digest = hashlib.sha256()

    with Path(path).open("rb") as handle:
        while True:
            chunk = handle.read(chunk_size)

            if not chunk:
                break

            digest.update(chunk)

    return digest.hexdigest()


def validate_source(source):
    if not source.is_file():
        return (
            False,
            f"Transcoded source does not exist: {source}",
        )

    if source.stat().st_size <= 0:
        return (
            False,
            f"Transcoded source is empty: {source}",
        )

    return True, None


def move_tv_job(conn, config, job):
    """
    Move every transcoded TV episode belonging to a job.

    Each job_file represents exactly one episode.
    The job is only marked LIBRARY_MOVED after all files
    have successfully reached the Jellyfin library.
    """

    (
        job_id,
        media_type,
        disc_label,
        title,
        year,
        destination,
    ) = job

    media_root = Path(
        config["jellyfin"]["media_root"]
    )

    tv_root = media_root / "TV Shows"

    files = conn.execute(
        """
        SELECT
            id,
            season_number,
            episode_number,
            transcode_file,
            library_file,
            status
        FROM job_files
        WHERE job_id = ?
        ORDER BY
            season_number,
            episode_number,
            id
        """,
        (job_id,),
    ).fetchall()

    if not files:
        message = (
            f"TV job {job_id} has no job_files."
        )

        fail_job(
            conn,
            job_id,
            "NO_TV_FILES",
            message,
        )

        print(f"LIBRARY MOVE FAILED\n  {message}")
        return False

    show_name = tv_show_name(
        disc_label,
        title,
        year,
    )

    print(
        f"Moving TV Job {job_id}: "
        f"{show_name}"
    )

    print(
        f"  Library root: {tv_root}"
    )

    moved_count = 0

    for file_row in files:
        (
            job_file_id,
            season_number,
            episode_number,
            transcode_file,
            library_file,
            file_status,
        ) = file_row

        if not season_number or not episode_number:
            message = (
                f"Job file {job_file_id} is missing "
                f"season/episode metadata."
            )

            fail_job_file(
                conn,
                job_file_id,
                "TV_EPISODE_METADATA_MISSING",
                message,
            )

            fail_job(
                conn,
                job_id,
                "TV_EPISODE_METADATA_MISSING",
                message,
            )

            conn.commit()

            print(f"LIBRARY MOVE FAILED\n  {message}")
            return False

        # Already completed files are left alone.
        if file_status == "LIBRARY_MOVED":
            if library_file:
                existing_library_file = Path(library_file)

                if existing_library_file.is_file():
                    print(
                        f"  Already moved: "
                        f"S{int(season_number):02d}"
                        f"E{int(episode_number):02d}"
                    )
                    continue

            # Database says moved but file is gone.
            message = (
                f"Job file {job_file_id} is marked "
                f"LIBRARY_MOVED but library file is missing."
            )

            fail_job_file(
                conn,
                job_file_id,
                "LIBRARY_FILE_MISSING",
                message,
            )

            fail_job(
                conn,
                job_id,
                "LIBRARY_FILE_MISSING",
                message,
            )

            conn.commit()

            print(f"LIBRARY MOVE FAILED\n  {message}")
            return False

        source = Path(transcode_file) if transcode_file else None

        if source is None:
            message = (
                f"Job file {job_file_id} has no "
                f"transcode_file."
            )

            fail_job_file(
                conn,
                job_file_id,
                "TV_TRANSCODE_FILE_MISSING",
                message,
            )

            fail_job(
                conn,
                job_id,
                "TV_TRANSCODE_FILE_MISSING",
                message,
            )

            conn.commit()

            print(f"LIBRARY MOVE FAILED\n  {message}")
            return False

        valid, message = validate_source(source)

        if not valid:
            fail_job_file(
                conn,
                job_file_id,
                "LIBRARY_SOURCE_MISSING",
                message,
            )

            fail_job(
                conn,
                job_id,
                "LIBRARY_SOURCE_MISSING",
                message,
            )

            conn.commit()

            print(f"LIBRARY MOVE FAILED\n  {message}")
            return False

        season_dir = (
            tv_root
            / show_name
            / f"Season {int(season_number):02d}"
        )

        output_file = (
            season_dir
            / tv_episode_name(
                show_name,
                season_number,
                episode_number,
            )
        )

        season_dir.mkdir(
            parents=True,
            exist_ok=True,
        )

        # Never silently overwrite a library episode.
        #
        # If the destination already exists, determine whether this
        # is a recoverable partially-recorded move. A matching size
        # alone is not sufficient; SHA-256 must also match.
        if output_file.exists():
            existing_size = output_file.stat().st_size
            source_size = source.stat().st_size

            recovered = False
            source_hash = None

            if (
                existing_size > 0
                and source_size > 0
                and existing_size == source_size
            ):
                print(
                    "  Existing destination has matching size; "
                    "verifying SHA-256..."
                )

                source_hash = sha256_file(source)
                destination_hash = sha256_file(
                    output_file
                )

                if source_hash == destination_hash:
                    recovered = True

            if recovered:
                print(
                    f"  Recovered existing library episode: "
                    f"S{int(season_number):02d}"
                    f"E{int(episode_number):02d}"
                )

                conn.execute(
                    """
                    UPDATE job_files
                    SET
                        status = 'LIBRARY_MOVED',
                        library_file = ?,
                        error_code = NULL,
                        error_message = NULL,
                        updated_at = ?
                    WHERE id = ?
                    """,
                    (
                        str(output_file),
                        now(),
                        job_file_id,
                    ),
                )

                log_event(
                    conn,
                    job_id,
                    "INFO",
                    "TV_EPISODE_LIBRARY_RECONCILED",
                    (
                        f"job_file_id={job_file_id}; "
                        f"season={season_number}; "
                        f"episode={episode_number}; "
                        f"source={source}; "
                        f"destination={output_file}; "
                        f"size_bytes={existing_size}; "
                        f"sha256={source_hash}"
                    ),
                )

                conn.commit()
                moved_count += 1
                continue

            message = (
                "TV episode destination already exists and "
                f"does not match source: {output_file}"
            )

            fail_job_file(
                conn,
                job_file_id,
                "LIBRARY_DESTINATION_EXISTS",
                message,
            )

            fail_job(
                conn,
                job_id,
                "LIBRARY_DESTINATION_EXISTS",
                message,
            )

            conn.commit()

            print("LIBRARY MOVE FAILED")
            print(f"  {message}")

            return False

        print(
            f"  S{int(season_number):02d}"
            f"E{int(episode_number):02d}"
        )
        print(f"    Source:      {source}")
        print(f"    Destination: {output_file}")

        try:
            shutil.move(
                str(source),
                str(output_file),
            )

        except Exception as exc:
            message = (
                f"Move failed for job_file "
                f"{job_file_id}: {exc}"
            )

            fail_job_file(
                conn,
                job_file_id,
                "LIBRARY_MOVE_EXCEPTION",
                message,
            )

            fail_job(
                conn,
                job_id,
                "LIBRARY_MOVE_EXCEPTION",
                message,
            )

            conn.commit()

            print("LIBRARY MOVE FAILED")
            print(f"  {message}")
            return False

        if not output_file.is_file():
            message = (
                f"Move reported success but destination "
                f"is missing: {output_file}"
            )

            fail_job_file(
                conn,
                job_file_id,
                "LIBRARY_DESTINATION_MISSING",
                message,
            )

            fail_job(
                conn,
                job_id,
                "LIBRARY_DESTINATION_MISSING",
                message,
            )

            conn.commit()

            print("LIBRARY MOVE FAILED")
            print(f"  {message}")
            return False

        output_size = output_file.stat().st_size

        if output_size <= 0:
            message = (
                f"Destination file is empty: "
                f"{output_file}"
            )

            fail_job_file(
                conn,
                job_file_id,
                "LIBRARY_DESTINATION_EMPTY",
                message,
            )

            fail_job(
                conn,
                job_id,
                "LIBRARY_DESTINATION_EMPTY",
                message,
            )

            conn.commit()

            print("LIBRARY MOVE FAILED")
            print(f"  {message}")
            return False

        conn.execute(
            """
            UPDATE job_files
            SET
                status = 'LIBRARY_MOVED',
                library_file = ?,
                error_code = NULL,
                error_message = NULL,
                updated_at = ?
            WHERE id = ?
            """,
            (
                str(output_file),
                now(),
                job_file_id,
            ),
        )

        log_event(
            conn,
            job_id,
            "INFO",
            "TV_EPISODE_LIBRARY_MOVED",
            (
                f"job_file_id={job_file_id}; "
                f"season={season_number}; "
                f"episode={episode_number}; "
                f"source={source}; "
                f"destination={output_file}; "
                f"size_bytes={output_size}"
            ),
        )

        conn.commit()

        moved_count += 1

        print(
            f"    Moved successfully: "
            f"{output_size:,} bytes"
        )

    remaining = conn.execute(
        """
        SELECT COUNT(*)
        FROM job_files
        WHERE job_id = ?
          AND status != 'LIBRARY_MOVED'
        """,
        (job_id,),
    ).fetchone()[0]

    if remaining != 0:
        message = (
            f"TV job {job_id} still has "
            f"{remaining} file(s) not moved."
        )

        fail_job(
            conn,
            job_id,
            "TV_FILES_REMAINING",
            message,
        )

        print(f"LIBRARY MOVE FAILED\n  {message}")
        return False

    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'LIBRARY_MOVED',
            destination = ?,
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            str(tv_root / show_name),
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "INFO",
        "LIBRARY_MOVE_COMPLETED",
        (
            f"media_type=TV; "
            f"show={show_name}; "
            f"episodes_moved={moved_count}; "
            f"destination={tv_root / show_name}"
        ),
    )

    conn.commit()

    print()
    print("TV LIBRARY MOVE COMPLETED")
    print(f"  Show:     {show_name}")
    print(f"  Episodes: {moved_count}")
    print(f"  Library:  {tv_root / show_name}")

    return True


def move_movie_job(conn, config, job):
    (
        job_id,
        media_type,
        disc_label,
        title,
        year,
        destination,
    ) = job

    source = Path(destination)

    media_root = Path(
        config["jellyfin"]["media_root"]
    )

    movies_root = media_root / "Movies"

    valid, message = validate_source(source)

    if not valid:
        fail_job(
            conn,
            job_id,
            (
                "LIBRARY_SOURCE_MISSING"
                if not source.is_file()
                else "LIBRARY_SOURCE_EMPTY"
            ),
            message,
        )

        print(f"LIBRARY MOVE FAILED\n  {message}")
        return False

    name = movie_name(
        disc_label,
        title,
        year,
    )

    movie_dir = movies_root / name
    output_file = movie_dir / f"{name}.mp4"

    movies_root.mkdir(
        parents=True,
        exist_ok=True,
    )

    movie_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    # Never silently overwrite an existing movie.
    #
    # If the destination already exists, determine whether a
    # previous filesystem move completed but the database update
    # did not persist. Recovery requires both matching size and
    # matching SHA-256.
    if output_file.exists():
        existing_size = output_file.stat().st_size
        source_size = source.stat().st_size

        recovered = False
        source_hash = None

        if (
            existing_size > 0
            and source_size > 0
            and existing_size == source_size
        ):
            print(
                "Existing movie destination has matching size; "
                "verifying SHA-256..."
            )

            source_hash = sha256_file(source)
            destination_hash = sha256_file(
                output_file
            )

            if source_hash == destination_hash:
                recovered = True

        if recovered:
            print(
                "Recovered existing movie library file."
            )

            conn.execute(
                """
                UPDATE jobs
                SET
                    status = 'LIBRARY_MOVED',
                    destination = ?,
                    error_code = NULL,
                    error_message = NULL,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    str(output_file),
                    now(),
                    job_id,
                ),
            )

            log_event(
                conn,
                job_id,
                "INFO",
                "MOVIE_LIBRARY_RECONCILED",
                (
                    f"source={source}; "
                    f"destination={output_file}; "
                    f"size_bytes={existing_size}; "
                    f"sha256={source_hash}"
                ),
            )

            conn.commit()

            print("MOVIE LIBRARY MOVE RECOVERED")
            print(f"  File: {output_file}")

            return True

        message = (
            "Movie destination already exists and "
            f"does not match source: {output_file}"
        )

        fail_job(
            conn,
            job_id,
            "LIBRARY_DESTINATION_EXISTS",
            message,
        )

        conn.commit()

        print("LIBRARY MOVE FAILED")
        print(f"  {message}")

        return False

    print(f"Moving Movie Job {job_id}: {disc_label}")
    print(f"  Source:      {source}")
    print(f"  Destination: {output_file}")

    try:
        shutil.move(
            str(source),
            str(output_file),
        )

    except Exception as exc:
        message = f"Move failed: {exc}"

        fail_job(
            conn,
            job_id,
            "LIBRARY_MOVE_EXCEPTION",
            message,
        )

        print("LIBRARY MOVE FAILED")
        print(f"  {message}")
        return False

    if not output_file.is_file():
        message = (
            f"Move reported success but destination "
            f"is missing: {output_file}"
        )

        fail_job(
            conn,
            job_id,
            "LIBRARY_DESTINATION_MISSING",
            message,
        )

        print("LIBRARY MOVE FAILED")
        print(f"  {message}")
        return False

    output_size = output_file.stat().st_size

    if output_size <= 0:
        message = (
            f"Destination file is empty: {output_file}"
        )

        fail_job(
            conn,
            job_id,
            "LIBRARY_DESTINATION_EMPTY",
            message,
        )

        print("LIBRARY MOVE FAILED")
        print(f"  {message}")
        return False

    conn.execute(
        """
        UPDATE jobs
        SET
            status = 'LIBRARY_MOVED',
            destination = ?,
            error_code = NULL,
            error_message = NULL,
            updated_at = ?
        WHERE id = ?
        """,
        (
            str(output_file),
            now(),
            job_id,
        ),
    )

    log_event(
        conn,
        job_id,
        "INFO",
        "LIBRARY_MOVE_COMPLETED",
        (
            f"source={source}; "
            f"destination={output_file}; "
            f"size_bytes={output_size}"
        ),
    )

    conn.commit()

    print("LIBRARY MOVE COMPLETED")
    print(f"  File: {output_file}")
    print(f"  Size: {output_size:,} bytes")

    return True


def move_job(conn, config, job):
    media_type = job["media_type"]

    if media_type == "TV":
        return move_tv_job(
            conn,
            config,
            job,
        )

    return move_movie_job(
        conn,
        config,
        job,
    )


def main():
    config = load_config()

    with sqlite3.connect(DB) as conn:
        conn.row_factory = sqlite3.Row

        job = get_next_job(conn)

        if not job:
            print("No TRANSCODED jobs available.")
            return 0

        return 0 if move_job(
            conn,
            config,
            job,
        ) else 1


if __name__ == "__main__":
    sys.exit(main())

scripts/cleanup_rips.py

#!/usr/bin/env python3

import argparse
import sqlite3
from pathlib import Path


DB = Path("/opt/arm/state/arm.db")
RIP_ROOT = Path("/rips/ripping")
TRANSCODE_ROOT = Path("/rips/transcoding/output")


def size_bytes(path):
    if not path.exists():
        return 0

    if path.is_file():
        return path.stat().st_size

    return sum(
        p.stat().st_size
        for p in path.rglob("*")
        if p.is_file()
    )


def gb(value):
    return value / (1024 ** 3)


def library_file_exists(path):
    if not path:
        return False

    return Path(path).is_file()


def inspect_job(conn, job):
    job_id = job["id"]
    status = job["status"]
    media_type = job["media_type"]

    rip_dir = RIP_ROOT / f"job-{job_id}"
    trans_dir = TRANSCODE_ROOT / f"job-{job_id}"

    rip_size = size_bytes(rip_dir)
    trans_size = size_bytes(trans_dir)

    reasons = []

    if status != "LIBRARY_MOVED":
        reasons.append(f"status={status}")

    if media_type == "TV":
        files = conn.execute(
            """
            SELECT
                id,
                status,
                library_file
            FROM job_files
            WHERE job_id = ?
            ORDER BY id
            """,
            (job_id,),
        ).fetchall()

        if not files:
            reasons.append("TV job has no job_files")
        else:
            for f in files:
                if f["status"] != "LIBRARY_MOVED":
                    reasons.append(
                        f"job_file {f['id']} status={f['status']}"
                    )

                if not library_file_exists(f["library_file"]):
                    reasons.append(
                        f"job_file {f['id']} library_file missing"
                    )

    else:
        if not job["destination"]:
            reasons.append("destination missing")
        elif not Path(job["destination"]).is_file():
            reasons.append("destination file missing")

    eligible = not reasons

    return {
        "id": job_id,
        "disc_label": job["disc_label"],
        "status": status,
        "media_type": media_type or "LEGACY",
        "rip_dir": rip_dir,
        "trans_dir": trans_dir,
        "rip_size": rip_size,
        "trans_size": trans_size,
        "eligible": eligible,
        "reasons": reasons,
    }


def main():
    parser = argparse.ArgumentParser(
        description="Safely clean completed ARM rip working data."
    )

    parser.add_argument(
        "--execute",
        action="store_true",
        help="Actually remove eligible working directories.",
    )

    args = parser.parse_args()

    mode = "EXECUTE" if args.execute else "DRY RUN"

    print(f"=== ARM RIP CLEANUP: {mode} ===")
    print()

    with sqlite3.connect(DB) as conn:
        conn.row_factory = sqlite3.Row

        jobs = conn.execute(
            """
            SELECT
                id,
                disc_label,
                media_type,
                status,
                destination
            FROM jobs
            ORDER BY id
            """
        ).fetchall()

        safe_total = 0
        blocked_total = 0
        safe_count = 0

        for job in jobs:
            result = inspect_job(conn, job)

            total_size = (
                result["rip_size"]
                + result["trans_size"]
            )

            if total_size == 0:
                continue

            if result["eligible"]:
                print(
                    f"Job {result['id']:3d} | "
                    f"SAFE     | "
                    f"{gb(total_size):6.2f} GB | "
                    f"{result['disc_label']}"
                )

                safe_total += total_size
                safe_count += 1

                if args.execute:
                    if result["rip_dir"].exists():
                        import shutil
                        shutil.rmtree(result["rip_dir"])

                    if result["trans_dir"].exists():
                        import shutil
                        shutil.rmtree(result["trans_dir"])

            else:
                print(
                    f"Job {result['id']:3d} | "
                    f"BLOCKED  | "
                    f"{gb(total_size):6.2f} GB | "
                    f"{result['disc_label']}"
                )

                for reason in result["reasons"]:
                    print(f"             {reason}")

                blocked_total += total_size

        print()
        print(f"Eligible jobs:        {safe_count}")
        print(f"Safe to remove:       {gb(safe_total):.2f} GB")
        print(f"Blocked working data: {gb(blocked_total):.2f} GB")

        if args.execute:
            print()
            print("Cleanup completed.")
        else:
            print()
            print("DRY RUN ONLY — no files were removed.")
            print("Use --execute only after reviewing the list.")


if __name__ == "__main__":
    main()

scripts/recovery_check.py

#!/usr/bin/env python3

import sqlite3
import subprocess

DB = "/opt/arm/state/arm.db"


def process_exists(pattern):
    result = subprocess.run(
        ["pgrep", "-f", pattern],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )

    return bool(result.stdout.strip())


with sqlite3.connect(DB) as conn:
    rows = conn.execute("""
        SELECT id, disc_label, status
        FROM jobs
        WHERE status IN ('RIPPING','TRANSCODING')
    """).fetchall()

    if not rows:
        print("No active jobs.")
        exit(0)

    for job_id, label, status in rows:
        print()
        print(f"Job {job_id}: {label}")
        print(f"Status: {status}")

        if status == "RIPPING":
            running = process_exists("makemkvcon.*mkv")
            print(f"MakeMKV running: {running}")

        elif status == "TRANSCODING":
            running = process_exists("HandBrakeCLI")
            print(f"HandBrake running: {running}")

scripts/job_corrector.py

#!/usr/bin/env python3

import configparser
import json
import shutil
import sqlite3
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

from title_selector import is_tv_episode_candidate as arm_tv_candidate


DB = Path("/opt/arm/state/arm.db")
BACKUP_DIR = Path("/opt/arm/backups")
CONFIG = Path("/opt/arm/config/arm.conf")

SAFE_MUTATION_STATES = {
    "LIBRARY_MOVED",
    "LIBRARY_MOVE_FAILED",
    "TRANSCODED",
    "RIP_FAILED",
    "SELECTION_FAILED",
    "SUPERSEDED",
    "TV_METADATA_NEEDED",
}


def now():
    return datetime.now(timezone.utc).isoformat()


def backup_db(tag):
    BACKUP_DIR.mkdir(parents=True, exist_ok=True)

    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    target = BACKUP_DIR / f"arm.db.before-{tag}-{stamp}"

    shutil.copy2(DB, target)

    print()
    print(f"Database backup: {target}")

    return target


def connect():
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    return conn


def get_job(conn, job_id):
    return conn.execute(
        """
        SELECT *
        FROM jobs
        WHERE id = ?
        """,
        (job_id,),
    ).fetchone()


def get_titles(conn, job_id):
    return conn.execute(
        """
        SELECT *
        FROM job_titles
        WHERE job_id = ?
        ORDER BY title_number
        """,
        (job_id,),
    ).fetchall()


def get_files(conn, job_id):
    return conn.execute(
        """
        SELECT *
        FROM job_files
        WHERE job_id = ?
        ORDER BY id
        """,
        (job_id,),
    ).fetchall()


def yes_no(prompt):
    answer = input(f"{prompt} [y/N]: ").strip().lower()
    return answer == "y"


def print_job(conn, job_id):
    job = get_job(conn, job_id)

    if not job:
        print(f"Job {job_id} not found.")
        return False

    print()
    print("=" * 78)
    print(f"JOB {job_id}")
    print("=" * 78)

    print(f"Disc label : {job['disc_label']}")
    print(f"Title      : {job['title']}")
    print(f"Media type : {job['media_type']}")
    print(f"Status     : {job['status']}")
    print(f"Drive      : {job['drive']}")
    print(f"Destination: {job['destination']}")
    print(
        "Fingerprint: "
        f"{job['disc_fingerprint'] or '-'}"
    )

    if job["error_code"]:
        print(f"Error      : {job['error_code']}")
        print(f"Message    : {job['error_message']}")

    print()
    print("TITLES")

    for row in get_titles(conn, job_id):
        marker = "*" if row["selected"] else " "

        season_episode = ""

        if (
            row["season_number"] is not None
            and row["episode_number"] is not None
        ):
            season_episode = (
                f"S{int(row['season_number']):02d}"
                f"E{int(row['episode_number']):02d}"
            )

        print(
            f"{marker} "
            f"title {row['title_number']:<3} "
            f"{row['duration'] or '-':>10}  "
            f"{row['size_display'] or '-':>10}  "
            f"{row['chapters'] or 0:>3} ch  "
            f"{season_episode}"
        )

    files = get_files(conn, job_id)

    if files:
        print()
        print("FILES")

        for row in files:
            episode = ""

            if (
                row["season_number"] is not None
                and row["episode_number"] is not None
            ):
                episode = (
                    f"S{int(row['season_number']):02d}"
                    f"E{int(row['episode_number']):02d}"
                )

            print(
                f"{row['id']:<5} "
                f"{episode:<8} "
                f"{row['status']:<20} "
                f"{row['library_file'] or row['transcode_file'] or row['rip_file'] or '-'}"
            )

    return True


def is_tv_episode_candidate(row):
    """
    Use ARM's production TV episode-candidate rule.
    Keeping correction and normal title selection identical
    prevents the two workflows from drifting apart.
    """
    return arm_tv_candidate(row)


def add_tv_override(prefix, show, season):
    config = configparser.ConfigParser()
    config.optionxform = str

    with CONFIG.open() as fh:
        config.read_file(fh)

    if not config.has_section("tv_overrides"):
        config.add_section("tv_overrides")

    desired = f"{show}|{season}"

    existing_key = None

    for key in config["tv_overrides"]:
        if key.upper() == prefix.upper():
            existing_key = key
            break

    if existing_key:
        existing = config["tv_overrides"][existing_key]

        if existing == desired:
            print(
                f"Override already present: "
                f"{existing_key} = {existing}"
            )
            return

        raise RuntimeError(
            f"Override prefix already exists:\n"
            f"{existing_key} = {existing}\n"
            f"Refusing to replace it automatically."
        )

    config["tv_overrides"][prefix] = desired

    backup = BACKUP_DIR / (
        "arm.conf.before-job-correction-"
        + datetime.now().strftime("%Y%m%d-%H%M%S")
    )

    shutil.copy2(CONFIG, backup)

    with CONFIG.open("w") as fh:
        config.write(fh)

    print(f"Config backup: {backup}")
    print(f"Added override: {prefix} = {desired}")


def resolve_tv_metadata(conn, job_id):
    """
    Resolve a TV job whose show identity is known but whose season
    could not be determined safely from the physical disc label.

    This is intentionally pre-rip only. Once released, the normal
    TV title selector determines the episode titles and numbering.
    """
    job = get_job(conn, job_id)

    if not job:
        raise RuntimeError("Job not found.")

    if job["media_type"] != "TV":
        raise RuntimeError(
            f"Job {job_id} is {job['media_type']}, not TV."
        )

    if job["status"] != "TV_METADATA_NEEDED":
        raise RuntimeError(
            f"Job {job_id} is {job['status']}.\n"
            "This operation requires TV_METADATA_NEEDED."
        )

    files = get_files(conn, job_id)

    if files:
        raise RuntimeError(
            "This metadata-needed job already has job_files.\n"
            "Refusing to release a job that may have started ripping."
        )

    titles = get_titles(conn, job_id)

    if not titles:
        raise RuntimeError(
            "No inspected titles are available for this job."
        )

    current_show = (
        (job["title"] or "").strip()
        or (job["disc_label"] or "").strip()
        or "Unknown TV Show"
    )

    candidates = [
        row
        for row in titles
        if arm_tv_candidate(row)
    ]

    print()
    print("TV METADATA REQUIRED")
    print("-" * 78)
    print(f"Job        : {job_id}")
    print(f"Disc label : {job['disc_label']}")
    print(f"Show       : {current_show}")
    print("Season     : UNKNOWN")

    print()
    print("LIKELY TV EPISODE TITLES")
    print("-" * 78)

    if candidates:
        for row in candidates:
            print(
                f"title {row['title_number']:<3} "
                f"{row['duration']:<10} "
                f"{row['size_display']:<10} "
                f"{row['chapters']} chapters"
            )
    else:
        print("No strong episode-sized titles detected.")

    show = input(
        f"\nShow name [{current_show}]: "
    ).strip()

    if not show:
        show = current_show

    season_text = input(
        "Season number: "
    ).strip()

    try:
        season = int(season_text)
    except ValueError:
        print("Cancelled: invalid season.")
        return

    if not 1 <= season <= 99:
        print("Cancelled: season must be 1-99.")
        return

    print()
    print("RELEASE PREVIEW")
    print("-" * 78)
    print(f"Job        : {job_id}")
    print(f"Show       : {show}")
    print(f"Season     : {season:02d}")
    print(f"Candidates : {len(candidates)}")
    print()
    print(
        "Episode numbering will be determined automatically "
        "by the normal TV selector."
    )

    if not yes_no(
        "\nApply TV metadata and release this job"
    ):
        print("Cancelled.")
        return

    backup_db(
        f"tv-metadata-job-{job_id}"
    )

    try:
        conn.execute("BEGIN IMMEDIATE")

        # Reset all title selection state. The normal selector owns
        # the final episode-title decision.
        conn.execute(
            """
            UPDATE job_titles
            SET
                selected = 0,
                season_number = ?,
                episode_number = NULL
            WHERE job_id = ?
            """,
            (
                season,
                job_id,
            ),
        )

        conn.execute(
            """
            UPDATE jobs
            SET
                title = ?,
                status = 'INSPECTED',
                destination = NULL,
                error_code = NULL,
                error_message = NULL,
                notes = COALESCE(notes, '') ||
                    CASE
                        WHEN COALESCE(notes, '') = ''
                        THEN ''
                        ELSE '; '
                    END ||
                    ?,
                updated_at = ?
            WHERE id = ?
            """,
            (
                show,
                (
                    "TV metadata confirmed; "
                    f"show={show}; season={season}; "
                    "released to normal selector"
                ),
                now(),
                job_id,
            ),
        )

        conn.execute(
            """
            INSERT INTO events(
                job_id,
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                job_id,
                now(),
                "INFO",
                "TV_METADATA_CONFIRMED",
                (
                    f"show={show}; season={season}; "
                    "status=INSPECTED"
                ),
            ),
        )

        conn.commit()

    except Exception:
        conn.rollback()
        raise

    print()
    print("TV metadata saved.")
    print(
        f"Job {job_id} released as "
        f"{show} Season {season:02d}."
    )
    print(
        "ARM will run the normal TV title selector "
        "on its next cycle."
    )


def movie_to_tv(conn, job_id):
    job = get_job(conn, job_id)

    if not job:
        raise RuntimeError("Job not found.")

    if job["media_type"] != "MOVIE":
        raise RuntimeError(
            f"Job {job_id} is {job['media_type']}, not MOVIE."
        )

    titles = get_titles(conn, job_id)

    candidates = [
        row
        for row in titles
        if is_tv_episode_candidate(row)
    ]

    print()
    print("TV EPISODE CANDIDATES")
    print("-" * 78)

    if not candidates:
        print("No strong TV episode candidates found.")
        return

    for row in candidates:
        print(
            f"title {row['title_number']:<3} "
            f"{row['duration']:<10} "
            f"{row['size_display']:<10} "
            f"{row['chapters']} chapters"
        )

    selected_movie = [
        row
        for row in titles
        if row["selected"] == 1
    ]

    print()
    print("CURRENT MOVIE SELECTION")
    print("-" * 78)

    for row in selected_movie:
        print(
            f"title {row['title_number']} "
            f"{row['duration']} "
            f"{row['size_display']}"
        )

    show = input("\nShow name: ").strip()

    if not show:
        print("Cancelled: show name is required.")
        return

    season_text = input("Season number: ").strip()

    try:
        season = int(season_text)
    except ValueError:
        print("Cancelled: invalid season.")
        return

    if not 1 <= season <= 99:
        print("Cancelled: season must be 1-99.")
        return

    default_prefix = job["disc_label"] or ""

    print()
    print(
        "Override prefix controls which future disc labels "
        "receive this classification."
    )
    print(
        f"Press ENTER to use the full current label: "
        f"{default_prefix}"
    )

    prefix = input("Override prefix: ").strip()

    if not prefix:
        prefix = default_prefix

    print()
    print("CORRECTION PREVIEW")
    print("-" * 78)
    print(f"Job           : {job_id}")
    print(f"Current type  : MOVIE")
    print(f"Correct type  : TV")
    print(f"Show          : {show}")
    print(f"Season        : {season}")
    print(f"Override      : {prefix} = {show}|{season}")
    print(f"Episode titles: {len(candidates)}")

    if job["status"] == "LIBRARY_MOVED":
        print()
        print(
            "The old movie has already reached Jellyfin."
        )
        print(
            "Individual TV episodes were not ripped by this job."
        )
        print(
            "A rerip is required. This operation WILL NOT delete "
            "the existing movie yet."
        )

    if not yes_no("\nSave this TV classification override"):
        print("Cancelled.")
        return

    add_tv_override(prefix, show, season)

    conn.execute(
        """
        UPDATE jobs
        SET
            notes = COALESCE(notes, '') ||
                CASE
                    WHEN COALESCE(notes, '') = ''
                    THEN ''
                    ELSE '; '
                END ||
                ?,
            updated_at = ?
        WHERE id = ?
        """,
        (
            (
                f"Correction prepared: rerip as TV; "
                f"show={show}; season={season}; "
                f"override_prefix={prefix}"
            ),
            now(),
            job_id,
        ),
    )

    conn.execute(
        """
        INSERT INTO events(
            job_id,
            timestamp,
            level,
            event,
            message
        )
        VALUES (?, ?, ?, ?, ?)
        """,
        (
            job_id,
            now(),
            "INFO",
            "JOB_CORRECTION_PREPARED",
            (
                f"MOVIE->TV; show={show}; "
                f"season={season}; prefix={prefix}"
            ),
        ),
    )

    conn.commit()

    print()
    print("Correction prepared.")
    print(
        "Reinsert the disc. ARM should now classify it as TV."
    )
    print(
        "After the corrected TV job succeeds, use Delete on "
        f"old Job {job_id} to remove its obsolete movie."
    )


def remove_path(path):
    if not path:
        return

    p = Path(path)

    if not p.exists():
        return

    if p.is_dir():
        shutil.rmtree(p)
    else:
        p.unlink()


def delete_job(conn, job_id):
    job = get_job(conn, job_id)

    if not job:
        raise RuntimeError("Job not found.")

    if job["status"] not in SAFE_MUTATION_STATES:
        raise RuntimeError(
            f"Job {job_id} is {job['status']}.\n"
            "Refusing to delete a job that may still be active."
        )

    files = get_files(conn, job_id)

    print()
    print("DELETE PREVIEW")
    print("-" * 78)

    owned_paths = set()

    for row in files:
        for column in (
            "rip_file",
            "transcode_file",
            "library_file",
        ):
            value = row[column]

            if value:
                owned_paths.add(value)

    # Movie jobs often store the library result only in jobs.destination.
    destination = job["destination"]

    if (
        job["status"] == "LIBRARY_MOVED"
        and destination
        and job["media_type"] == "MOVIE"
    ):
        owned_paths.add(destination)

    for value in sorted(owned_paths):
        print(value)

    if job["media_type"] == "MOVIE" and destination:
        destination_path = Path(destination)

        if destination_path.suffix:
            parent = destination_path.parent

            if parent.name == destination_path.stem:
                owned_paths.add(str(parent))

    print()
    print(
        f"Database rows for Job {job_id} will also be removed."
    )

    if not yes_no("Delete this job and its owned media"):
        print("Cancelled.")
        return

    backup_db(f"delete-job-{job_id}")

    # Delete the most-specific paths first.
    for value in sorted(
        owned_paths,
        key=lambda item: len(Path(item).parts),
        reverse=True,
    ):
        p = Path(value)

        if p.exists():
            print(f"Removing: {p}")
            remove_path(p)

    conn.execute("BEGIN IMMEDIATE")

    conn.execute(
        "DELETE FROM job_files WHERE job_id = ?",
        (job_id,),
    )

    conn.execute(
        "DELETE FROM job_titles WHERE job_id = ?",
        (job_id,),
    )

    conn.execute(
        "DELETE FROM events WHERE job_id = ?",
        (job_id,),
    )

    conn.execute(
        "DELETE FROM jobs WHERE id = ?",
        (job_id,),
    )

    conn.commit()

    print(f"Job {job_id} removed.")


def renumber_tv(conn, job_id):
    job = get_job(conn, job_id)

    if not job:
        raise RuntimeError("Job not found.")

    if job["media_type"] != "TV":
        raise RuntimeError(
            f"Job {job_id} is not a TV job."
        )

    if job["status"] not in SAFE_MUTATION_STATES:
        raise RuntimeError(
            f"Job {job_id} is {job['status']}.\n"
            "Refusing to renumber a job that may still be active."
        )

    selected = conn.execute(
        """
        SELECT *
        FROM job_titles
        WHERE job_id = ?
          AND selected = 1
        ORDER BY title_number
        """,
        (job_id,),
    ).fetchall()

    if not selected:
        raise RuntimeError(
            "No selected TV titles exist for this job."
        )

    season = selected[0]["season_number"]

    if season is None:
        raise RuntimeError(
            "Selected titles do not have a season number."
        )

    print()
    print("CURRENT EPISODE MAP")
    print("-" * 78)

    for row in selected:
        print(
            f"title {row['title_number']:<3} "
            f"S{int(season):02d}"
            f"E{int(row['episode_number']):02d} "
            f"{row['duration']}"
        )

    text = input(
        "\nNew starting episode number: "
    ).strip()

    try:
        start = int(text)
    except ValueError:
        print("Cancelled: invalid episode number.")
        return

    if start < 1:
        print("Cancelled.")
        return

    mapping = {}

    for offset, row in enumerate(selected):
        mapping[row["episode_number"]] = start + offset

    print()
    print("NEW MAP")
    print("-" * 78)

    for old_ep, new_ep in mapping.items():
        print(
            f"S{int(season):02d}E{int(old_ep):02d}"
            f" -> "
            f"S{int(season):02d}E{int(new_ep):02d}"
        )

    if not yes_no("\nApply this renumbering"):
        print("Cancelled.")
        return

    # Initial version deliberately handles library-complete jobs only
    # when each episode has a unique library_file.
    files = get_files(conn, job_id)

    by_episode = {
        row["episode_number"]: row
        for row in files
        if row["episode_number"] is not None
    }

    for old_ep in mapping:
        if old_ep not in by_episode:
            raise RuntimeError(
                f"No job_file for E{int(old_ep):02d}."
            )

    backup_db(f"renumber-job-{job_id}")

    # Stage library-file renames to collision-proof temporary names.
    staged = []

    for old_ep, new_ep in mapping.items():
        row = by_episode[old_ep]
        library_file = row["library_file"]

        if not library_file:
            continue

        src = Path(library_file)

        if not src.exists():
            raise RuntimeError(
                f"Library file missing: {src}"
            )

        suffix = src.suffix

        final = (
            src.parent
            / (
                f"{src.name.split(' - S')[0]} - "
                f"S{int(season):02d}"
                f"E{int(new_ep):02d}{suffix}"
            )
        )

        temp = (
            src.parent
            / f".arm-renumber-job-{job_id}-{row['id']}{suffix}"
        )

        if temp.exists():
            raise RuntimeError(
                f"Temporary path already exists: {temp}"
            )

        src.rename(temp)

        staged.append(
            (row, old_ep, new_ep, temp, final)
        )

    # Refuse final collisions before committing.
    for _, _, _, _, final in staged:
        if final.exists():
            # Put staged files back before aborting.
            for row, old_ep, _, temp, _ in staged:
                original = Path(row["library_file"])

                if temp.exists():
                    temp.rename(original)

            raise RuntimeError(
                f"Destination already exists: {final}"
            )

    try:
        conn.execute("BEGIN IMMEDIATE")

        # Update titles by stable title ID, never by old episode number.
        for offset, title_row in enumerate(selected):
            new_ep = start + offset

            conn.execute(
                """
                UPDATE job_titles
                SET episode_number = ?
                WHERE id = ?
                """,
                (
                    new_ep,
                    title_row["id"],
                ),
            )

        for row, _, new_ep, temp, final in staged:
            temp.rename(final)

            conn.execute(
                """
                UPDATE job_files
                SET
                    episode_number = ?,
                    library_file = ?,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    new_ep,
                    str(final),
                    now(),
                    row["id"],
                ),
            )

        # Non-library rows still need their episode number corrected.
        staged_ids = {
            item[0]["id"]
            for item in staged
        }

        ordered_files = sorted(
            [
                row
                for row in files
                if row["episode_number"] is not None
            ],
            key=lambda row: row["episode_number"],
        )

        for offset, row in enumerate(ordered_files):
            if row["id"] in staged_ids:
                continue

            conn.execute(
                """
                UPDATE job_files
                SET
                    episode_number = ?,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    start + offset,
                    now(),
                    row["id"],
                ),
            )

        conn.execute(
            """
            UPDATE jobs
            SET updated_at = ?
            WHERE id = ?
            """,
            (
                now(),
                job_id,
            ),
        )

        conn.commit()

    except Exception:
        conn.rollback()
        raise

    print()
    print(f"Job {job_id} renumbered.")




def jellyfin_refresh():
    """
    Ask Jellyfin to rescan its libraries.

    Returns True on success and False on failure. A refresh failure
    does not roll back an otherwise successful ARM correction.
    """

    config = configparser.ConfigParser()
    config.read(CONFIG)

    if not config.has_section("jellyfin"):
        print("Jellyfin refresh skipped: no [jellyfin] config.")
        return False

    url = config["jellyfin"].get("url", "").strip().rstrip("/")
    key = config["jellyfin"].get("api_key", "").strip()

    if not url or not key:
        print(
            "Jellyfin refresh skipped: url/api_key not configured."
        )
        return False

    request = urllib.request.Request(
        url + "/Library/Refresh",
        method="POST",
        headers={
            "X-Emby-Token": key,
            "Accept": "application/json",
        },
    )

    try:
        with urllib.request.urlopen(
            request,
            timeout=10,
        ) as response:
            response.read()

    except Exception as exc:
        print(
            f"WARNING: Jellyfin refresh request failed: {exc}"
        )
        return False

    print("Jellyfin library refresh requested.")
    return True


def cleanup_old_jellyfin_tv_tree(show_dir):
    """
    Remove stale Jellyfin-generated artifacts after TV media has
    been moved to a corrected show path.

    Known generated artifacts:
      - *.nfo
      - *-thumb.jpg
      - *.trickplay directories

    Refuses recursive removal when other real/unknown files remain.
    """

    show_dir = Path(show_dir)

    if not show_dir.exists():
        return True

    print()
    print("Cleaning old Jellyfin metadata:")
    print(f"  {show_dir}")

    # Remove trickplay trees first.
    trickplay_dirs = sorted(
        [
            p
            for p in show_dir.rglob("*.trickplay")
            if p.is_dir()
        ],
        key=lambda p: len(p.parts),
        reverse=True,
    )

    for directory in trickplay_dirs:
        print(f"  remove trickplay: {directory}")
        shutil.rmtree(directory)

    # Remove known per-episode generated metadata.
    generated_files = []

    for p in show_dir.rglob("*"):
        if not p.is_file():
            continue

        name = p.name.lower()

        if name.endswith(".nfo"):
            generated_files.append(p)
            continue

        if name.endswith("-thumb.jpg"):
            generated_files.append(p)
            continue

    for generated in generated_files:
        print(f"  remove metadata: {generated}")
        generated.unlink()

    # Determine whether anything real/unknown remains.
    remaining_files = [
        p
        for p in show_dir.rglob("*")
        if p.is_file()
    ]

    if remaining_files:
        print()
        print(
            "Old show directory still contains files."
        )
        print(
            "It will NOT be recursively removed:"
        )

        for remaining in remaining_files:
            print(f"  KEEP: {remaining}")

        return False

    # Remove now-empty directories from deepest to shallowest.
    directories = sorted(
        [
            p
            for p in show_dir.rglob("*")
            if p.is_dir()
        ],
        key=lambda p: len(p.parts),
        reverse=True,
    )

    for directory in directories:
        try:
            directory.rmdir()
        except OSError:
            pass

    try:
        show_dir.rmdir()
        print(f"Removed obsolete show directory: {show_dir}")
        return True

    except OSError:
        print(
            f"Old show directory remains non-empty: {show_dir}"
        )
        return False


def correct_tv_show(conn, job_id):
    """
    Correct the canonical show name for a completed TV job and,
    when needed, renumber that job's episodes.

    Moves existing Jellyfin library files, updates jobs.title,
    jobs.destination, job_titles, and job_files, and can save a
    tv_overrides prefix for future discs.
    """

    job = get_job(conn, job_id)

    if not job:
        raise RuntimeError("Job not found.")

    if job["media_type"] != "TV":
        raise RuntimeError(
            f"Job {job_id} is not a TV job."
        )

    if job["status"] != "LIBRARY_MOVED":
        raise RuntimeError(
            f"Job {job_id} is {job['status']}.\n"
            "Correct TV show currently requires LIBRARY_MOVED."
        )

    selected = conn.execute(
        """
        SELECT *
        FROM job_titles
        WHERE job_id = ?
          AND selected = 1
          AND season_number IS NOT NULL
          AND episode_number IS NOT NULL
        ORDER BY title_number
        """,
        (job_id,),
    ).fetchall()

    if not selected:
        raise RuntimeError(
            "No selected TV episode titles found."
        )

    seasons = {
        int(row["season_number"])
        for row in selected
    }

    if len(seasons) != 1:
        raise RuntimeError(
            "Selected titles contain multiple seasons."
        )

    season = seasons.pop()

    files = get_files(conn, job_id)

    episode_files = [
        row
        for row in files
        if row["episode_number"] is not None
    ]

    if len(episode_files) != len(selected):
        raise RuntimeError(
            "Selected-title count does not match job_file count."
        )

    # Determine the currently used show directory.
    current_show = None

    if job["destination"]:
        destination = Path(job["destination"])

        if destination.parent.name == "TV Shows":
            current_show = destination.name

    if not current_show:
        library_rows = [
            row
            for row in episode_files
            if row["library_file"]
        ]

        if library_rows:
            p = Path(library_rows[0]["library_file"])

            # .../TV Shows/<show>/Season XX/file.mp4
            if len(p.parents) >= 2:
                current_show = p.parent.parent.name

    current_show = current_show or job["title"] or job["disc_label"]

    print()
    print("CORRECT TV SHOW")
    print("-" * 78)
    print(f"Job         : {job_id}")
    print(f"Disc label  : {job['disc_label']}")
    print(f"Current show: {current_show}")
    print(f"Season      : {season:02d}")

    new_show = input(
        f"\nNew show name [{current_show}]: "
    ).strip()

    if not new_show:
        new_show = current_show

    default_start = min(
        int(row["episode_number"])
        for row in selected
    )

    start_text = input(
        f"Starting episode [{default_start}]: "
    ).strip()

    if start_text:
        try:
            start_episode = int(start_text)
        except ValueError:
            print("Cancelled: invalid episode number.")
            return
    else:
        start_episode = default_start

    if start_episode < 1:
        print("Cancelled: episode must be >= 1.")
        return

    # Stable mapping by title_number/job_title_id.
    title_mapping = []

    for offset, title_row in enumerate(selected):
        new_episode = start_episode + offset

        title_mapping.append(
            (
                title_row,
                new_episode,
            )
        )

    # Match job_files to job_titles primarily by job_title_id.
    file_by_title_id = {
        row["job_title_id"]: row
        for row in episode_files
        if row["job_title_id"] is not None
    }

    file_by_old_episode = {
        int(row["episode_number"]): row
        for row in episode_files
    }

    planned = []

    media_root = Path("/mnt/jellyfin")
    new_show_dir = media_root / "TV Shows" / new_show
    new_season_dir = (
        new_show_dir
        / f"Season {season:02d}"
    )

    print()
    print("CORRECTION PREVIEW")
    print("-" * 78)

    for title_row, new_episode in title_mapping:
        old_episode = int(title_row["episode_number"])

        file_row = file_by_title_id.get(
            title_row["id"]
        )

        if file_row is None:
            file_row = file_by_old_episode.get(
                old_episode
            )

        if file_row is None:
            raise RuntimeError(
                f"No job_file found for old E{old_episode:02d}."
            )

        if not file_row["library_file"]:
            raise RuntimeError(
                f"job_file {file_row['id']} has no library_file."
            )

        source = Path(file_row["library_file"])

        if not source.exists():
            raise RuntimeError(
                f"Library file missing:\n{source}"
            )

        final = (
            new_season_dir
            / (
                f"{new_show} - "
                f"S{season:02d}"
                f"E{new_episode:02d}"
                f"{source.suffix}"
            )
        )

        planned.append(
            (
                title_row,
                file_row,
                old_episode,
                new_episode,
                source,
                final,
            )
        )

        print(
            f"S{season:02d}E{old_episode:02d} "
            f"-> "
            f"S{season:02d}E{new_episode:02d}"
        )
        print(f"  FROM: {source}")
        print(f"  TO:   {final}")

    # Refuse collisions with files not owned by this job.
    source_paths = {
        source.resolve()
        for _, _, _, _, source, _ in planned
    }

    for _, _, _, _, _, final in planned:
        if (
            final.exists()
            and final.resolve() not in source_paths
        ):
            raise RuntimeError(
                f"Destination already exists:\n{final}"
            )

    print()
    print(f"jobs.title       -> {new_show}")
    print(f"jobs.destination -> {new_show_dir}")

    print()
    save_override = yes_no(
        "Save/update a TV classification override for future discs"
    )

    override_prefix = None

    if save_override:
        default_prefix = job["disc_label"] or ""

        print()
        print(
            "Enter the common label prefix. "
            "For Example Show Season 11 use EXAMPLE_SHOW_S11_"
        )

        override_prefix = input(
            f"Override prefix [{default_prefix}]: "
        ).strip()

        if not override_prefix:
            override_prefix = default_prefix

        print(
            f"Override: {override_prefix} = "
            f"{new_show}|{season}"
        )

    if not yes_no("\nApply this correction"):
        print("Cancelled.")
        return

    backup_db(f"correct-tv-show-job-{job_id}")

    # Save config override before touching media. add_tv_override()
    # performs its own config backup and refuses conflicting entries.
    if save_override:
        add_tv_override(
            override_prefix,
            new_show,
            season,
        )

    new_season_dir.mkdir(
        parents=True,
        exist_ok=True,
    )

    # Stage every source to a unique temporary file first so
    # renumbering cannot collide with itself.
    staged = []

    try:
        for (
            title_row,
            file_row,
            old_episode,
            new_episode,
            source,
            final,
        ) in planned:

            temp = (
                source.parent
                / (
                    f".arm-correct-job-{job_id}-"
                    f"{file_row['id']}"
                    f"{source.suffix}"
                )
            )

            if temp.exists():
                raise RuntimeError(
                    f"Temporary file already exists:\n{temp}"
                )

            source.rename(temp)

            staged.append(
                (
                    title_row,
                    file_row,
                    old_episode,
                    new_episode,
                    source,
                    temp,
                    final,
                )
            )

        # Re-check destinations after staging.
        for item in staged:
            final = item[-1]

            if final.exists():
                raise RuntimeError(
                    f"Destination appeared during correction:\n{final}"
                )

        conn.execute("BEGIN IMMEDIATE")

        for (
            title_row,
            file_row,
            old_episode,
            new_episode,
            source,
            temp,
            final,
        ) in staged:

            final.parent.mkdir(
                parents=True,
                exist_ok=True,
            )

            temp.rename(final)

            conn.execute(
                """
                UPDATE job_titles
                SET
                    season_number = ?,
                    episode_number = ?
                WHERE id = ?
                """,
                (
                    season,
                    new_episode,
                    title_row["id"],
                ),
            )

            conn.execute(
                """
                UPDATE job_files
                SET
                    season_number = ?,
                    episode_number = ?,
                    library_file = ?,
                    updated_at = ?
                WHERE id = ?
                """,
                (
                    season,
                    new_episode,
                    str(final),
                    now(),
                    file_row["id"],
                ),
            )

        conn.execute(
            """
            UPDATE jobs
            SET
                title = ?,
                destination = ?,
                error_code = NULL,
                error_message = NULL,
                updated_at = ?
            WHERE id = ?
            """,
            (
                new_show,
                str(new_show_dir),
                now(),
                job_id,
            ),
        )

        conn.execute(
            """
            INSERT INTO events(
                job_id,
                timestamp,
                level,
                event,
                message
            )
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                job_id,
                now(),
                "INFO",
                "TV_SHOW_CORRECTED",
                (
                    f"show={new_show}; "
                    f"season={season}; "
                    f"start_episode={start_episode}"
                ),
            ),
        )

        conn.commit()

    except Exception:
        conn.rollback()

        # Best-effort restoration of any staged/moved files.
        for (
            _,
            _,
            _,
            _,
            original,
            temp,
            final,
        ) in reversed(staged):

            try:
                if final.exists():
                    original.parent.mkdir(
                        parents=True,
                        exist_ok=True,
                    )
                    final.rename(original)

                elif temp.exists():
                    original.parent.mkdir(
                        parents=True,
                        exist_ok=True,
                    )
                    temp.rename(original)

            except Exception:
                pass

        raise

    # --------------------------------------------------------
    # Clean the obsolete Jellyfin show identity.
    # --------------------------------------------------------

    old_show_dirs = set()

    for _, _, _, _, source, _ in planned:
        # source:
        # .../TV Shows/<old show>/Season XX/file.mp4
        old_show_dirs.add(source.parent.parent)

    for old_show_dir in old_show_dirs:
        # Never clean the canonical destination itself.
        try:
            same_directory = (
                old_show_dir.resolve()
                == new_show_dir.resolve()
            )
        except FileNotFoundError:
            same_directory = False

        if same_directory:
            continue

        cleanup_old_jellyfin_tv_tree(
            old_show_dir
        )

    # Ask Jellyfin to forget stale entries and discover the
    # corrected paths immediately.
    jellyfin_refresh()

    print()
    print(
        f"Job {job_id} corrected to "
        f"{new_show} Season {season:02d}, "
        f"starting at E{start_episode:02d}."
    )


def menu(job_id):
    conn = connect()

    try:
        if not print_job(conn, job_id):
            return

        print()
        print("=" * 78)
        print("JOB CORRECTOR")
        print("=" * 78)
        print()
        print("  1  Movie -> TV / prepare rerip")
        print("  2  Inspect job")
        print("  3  Renumber TV episodes")
        print("  4  Correct TV show / episode start")
        print("  5  Delete completed job + owned media")
        print("  6  Resolve TV metadata / release job")
        print("  Q  Cancel")
        print()

        choice = input("Choice: ").strip().lower()

        if choice == "1":
            movie_to_tv(conn, job_id)

        elif choice == "2":
            print_job(conn, job_id)

            print()
            input("Press ENTER when finished reviewing this job...")

        elif choice == "3":
            renumber_tv(conn, job_id)

        elif choice == "4":
            correct_tv_show(conn, job_id)

        elif choice == "5":
            delete_job(conn, job_id)

        elif choice == "6":
            resolve_tv_metadata(conn, job_id)

        elif choice == "q":
            return

        else:
            print("Unknown selection.")

    finally:
        conn.close()


def main():
    delete_mode = False

    if len(sys.argv) == 3 and sys.argv[1] == "--delete":
        delete_mode = True
        job_arg = sys.argv[2]

    elif len(sys.argv) == 2:
        job_arg = sys.argv[1]

    else:
        print(
            "Usage:\n"
            "  sudo job_corrector.py JOB_ID\n"
            "  sudo job_corrector.py --delete JOB_ID"
        )
        raise SystemExit(2)

    try:
        job_id = int(job_arg)
    except ValueError:
        print("JOB_ID must be an integer.")
        raise SystemExit(2)

    try:
        if delete_mode:
            conn = connect()

            try:
                if not print_job(conn, job_id):
                    return

                delete_job(conn, job_id)

            finally:
                conn.close()

        else:
            menu(job_id)

    except KeyboardInterrupt:
        print("\nCancelled.")

    except Exception as exc:
        print()
        print(f"ERROR: {exc}")
        raise SystemExit(1)


if __name__ == "__main__":
    main()