diff --git a/README.md b/README.md index 63ceada..0d95ad6 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,15 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit Ma > **Neu seit letztem Release** > -> - **Cron-Job-System** – Skripte und Skript-Ketten zeitgesteuert ausführen; eigener Expression-Parser, Ausführungs-Logs, manuelle Auslösung, PushOver-Integration -> - **DVD-Erkennung verbessert** – robuste Media-Profil-Erkennung (Blu-ray / DVD / CD) aus UDF/ISO9660-Dateisystemtyp, Laufwerk-Modell und Disc-Label +> - **Profil-spezifische Einstellungen** – Separate Konfiguration für Blu-ray und DVD: eigene Pfade, HandBrake-Presets, Rip-Modi, Extra-Args, Dateinamen-Templates; automatische Auflösung anhand des erkannten Medientyps +> - **Cron-Job-System** – Skripte und Skript-Ketten zeitgesteuert ausführen; eigener Expression-Parser, Ausführungs-Logs, manuelle Auslösung, PushOver-Integration pro Job +> - **User-Presets** – benannte HandBrake-Preset-Sammlungen (Preset + Extra-Args) pro Medientyp anlegen und im Review-Panel auswählen +> - **DVD-/Blu-ray-Erkennung verbessert** – robuste Media-Profil-Erkennung aus UDF/ISO9660-Dateisystemtyp, Laufwerk-Modell und Disc-Label; Medientyp-Indikator in der UI > - **Pre-Encode-Ausführungen** – Skripte und Ketten können nun auch *vor* dem Encode-Schritt ausgeführt werden (zusätzlich zu Post-Encode) -> - **Sortierbare Skripte & Ketten** – Reihenfolge über Drag & Drop festlegen; wird persistent in der Datenbank gespeichert +> - **Sortierbare Skripte & Ketten** – Reihenfolge über Drag & Drop festlegen; wird persistent gespeichert +> - **Granulares PushOver** – je Event konfigurierbar (Metadaten bereit, Rip-Start, Encode-Start, Fertig, Fehler, Abbruch, Re-Encode) > - **`rip_successful`-Flag in Jobs** – separates Feld zur Nachverfolgung ob der Rip-Schritt abgeschlossen wurde (unabhängig vom Encode-Status) +> - **`handbrake_restart_delete_incomplete_output`** – unvollständige Ausgabe wird beim Encode-Neustart automatisch gelöscht --- diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index f620a43..a722334 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -100,12 +100,14 @@ router.post( const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds; const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate); + const selectedUserPresetId = req.body?.selectedUserPresetId ?? null; logger.info('post:confirm-encode', { reqId: req.reqId, jobId, selectedEncodeTitleId, selectedTrackSelectionProvided: Boolean(selectedTrackSelection), skipPipelineStateUpdate, + selectedUserPresetId, selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds) ? selectedPostEncodeScriptIds.length : 0 @@ -114,7 +116,8 @@ router.post( selectedEncodeTitleId, selectedTrackSelection, selectedPostEncodeScriptIds, - skipPipelineStateUpdate + skipPipelineStateUpdate, + selectedUserPresetId }); res.json({ job }); }) diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index f1eff68..074c372 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -7,6 +7,7 @@ const notificationService = require('../services/notificationService'); const pipelineService = require('../services/pipelineService'); const wsService = require('../services/websocketService'); const hardwareMonitorService = require('../services/hardwareMonitorService'); +const userPresetService = require('../services/userPresetService'); const logger = require('../services/logger').child('SETTINGS_ROUTE'); const router = express.Router(); @@ -304,6 +305,52 @@ router.put( }) ); +// ── User Presets ────────────────────────────────────────────────────────────── + +router.get( + '/user-presets', + asyncHandler(async (req, res) => { + const mediaType = req.query.media_type || null; + logger.debug('get:user-presets', { reqId: req.reqId, mediaType }); + const presets = await userPresetService.listPresets(mediaType); + res.json({ presets }); + }) +); + +router.post( + '/user-presets', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name }); + const preset = await userPresetService.createPreset(payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id }); + res.status(201).json({ preset }); + }) +); + +router.put( + '/user-presets/:id', + asyncHandler(async (req, res) => { + const presetId = Number(req.params.id); + const payload = req.body || {}; + logger.info('put:user-presets:update', { reqId: req.reqId, presetId }); + const preset = await userPresetService.updatePreset(presetId, payload); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id }); + res.json({ preset }); + }) +); + +router.delete( + '/user-presets/:id', + asyncHandler(async (req, res) => { + const presetId = Number(req.params.id); + logger.info('delete:user-presets', { reqId: req.reqId, presetId }); + const removed = await userPresetService.deletePreset(presetId); + wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id }); + res.json({ removed }); + }) +); + router.post( '/pushover/test', asyncHandler(async (req, res) => { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 77b8f2e..13daac0 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -17,6 +17,7 @@ const { ensureDir, sanitizeFileName, renderTemplate, findMediaFiles } = require( const { buildMediainfoReview } = require('../utils/encodePlan'); const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis'); const { errorToMeta } = require('../utils/errorMeta'); +const userPresetService = require('./userPresetService'); const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']); const REVIEW_REFRESH_SETTING_PREFIXES = [ @@ -5583,6 +5584,21 @@ class PipelineService extends EventEmitter { throw error; } + // Resolve user preset if provided + const rawUserPresetId = options?.selectedUserPresetId ?? null; + const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined + ? Number(rawUserPresetId) + : null; + let resolvedUserPreset = null; + if (Number.isFinite(userPresetId) && userPresetId > 0) { + resolvedUserPreset = await userPresetService.getPresetById(userPresetId); + if (!resolvedUserPreset) { + const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + } + const confirmedPlan = { ...planForConfirm, postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)), @@ -5598,7 +5614,15 @@ class PipelineService extends EventEmitter { postEncodeChainIds: selectedPostEncodeChainIds, preEncodeChainIds: selectedPreEncodeChainIds, reviewConfirmed: true, - reviewConfirmedAt: nowIso() + reviewConfirmedAt: nowIso(), + userPreset: resolvedUserPreset + ? { + id: resolvedUserPreset.id, + name: resolvedUserPreset.name, + handbrakePreset: resolvedUserPreset.handbrakePreset, + extraArgs: resolvedUserPreset.extraArgs + } + : null }; const inputPath = isPreRipMode ? null @@ -5622,6 +5646,7 @@ class PipelineService extends EventEmitter { + ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.` + ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.` + ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.` + + (resolvedUserPreset ? ` User-Preset: "${resolvedUserPreset.name}" (ID ${resolvedUserPreset.id}).` : '') ); if (!skipPipelineStateUpdate) { @@ -6683,7 +6708,8 @@ class PipelineService extends EventEmitter { trackSelection, titleId: handBrakeTitleId, mediaProfile, - settingsMap: settings + settingsMap: settings, + userPreset: encodePlan?.userPreset || null }); if (trackSelection) { await historyService.appendLog( diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 5555208..868b3aa 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -847,10 +847,20 @@ class SettingsService { if (selectedTitleId !== null) { baseArgs.push('-t', String(selectedTitleId)); } - if (map.handbrake_preset) { - baseArgs.push('-Z', map.handbrake_preset); + + // User preset overrides settings-derived preset and extra args + const userPreset = options?.userPreset || null; + const effectiveHandbrakePreset = userPreset !== null + ? (userPreset.handbrakePreset || null) + : (map.handbrake_preset || null); + const effectiveExtraArgs = userPreset !== null + ? (userPreset.extraArgs || '') + : (map.handbrake_extra_args || ''); + + if (effectiveHandbrakePreset) { + baseArgs.push('-Z', effectiveHandbrakePreset); } - const extra = splitArgs(map.handbrake_extra_args); + const extra = splitArgs(effectiveExtraArgs); const rawSelection = options?.trackSelection || null; const hasSelection = rawSelection && typeof rawSelection === 'object'; @@ -860,7 +870,8 @@ class SettingsService { args: [...baseArgs, ...extra], inputFile, outputFile, - selectedTitleId + selectedTitleId, + userPresetId: userPreset?.id || null }); return { cmd, args: [...baseArgs, ...extra] }; } diff --git a/backend/src/services/userPresetService.js b/backend/src/services/userPresetService.js new file mode 100644 index 0000000..a944ea8 --- /dev/null +++ b/backend/src/services/userPresetService.js @@ -0,0 +1,133 @@ +const { getDb } = require('../db/database'); +const logger = require('./logger').child('USER_PRESET'); + +const VALID_MEDIA_TYPES = new Set(['bluray', 'dvd', 'other', 'all']); + +function normalizeMediaType(value) { + const v = String(value || '').trim().toLowerCase(); + return VALID_MEDIA_TYPES.has(v) ? v : 'all'; +} + +function rowToPreset(row) { + if (!row) { + return null; + } + return { + id: row.id, + name: row.name, + mediaType: row.media_type, + handbrakePreset: row.handbrake_preset || null, + extraArgs: row.extra_args || null, + description: row.description || null, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +async function listPresets(mediaType = null) { + const db = await getDb(); + let rows; + if (mediaType && VALID_MEDIA_TYPES.has(mediaType)) { + rows = await db.all( + `SELECT * FROM user_presets WHERE media_type = ? OR media_type = 'all' ORDER BY name ASC`, + [mediaType] + ); + } else { + rows = await db.all(`SELECT * FROM user_presets ORDER BY media_type ASC, name ASC`); + } + return rows.map(rowToPreset); +} + +async function getPresetById(id) { + const db = await getDb(); + const row = await db.get(`SELECT * FROM user_presets WHERE id = ? LIMIT 1`, [id]); + return rowToPreset(row); +} + +async function createPreset(payload) { + const name = String(payload?.name || '').trim(); + if (!name) { + const error = new Error('Preset-Name darf nicht leer sein.'); + error.statusCode = 400; + throw error; + } + + const mediaType = normalizeMediaType(payload?.mediaType); + const handbrakePreset = String(payload?.handbrakePreset || '').trim() || null; + const extraArgs = String(payload?.extraArgs || '').trim() || null; + const description = String(payload?.description || '').trim() || null; + + const db = await getDb(); + const result = await db.run( + `INSERT INTO user_presets (name, media_type, handbrake_preset, extra_args, description) + VALUES (?, ?, ?, ?, ?)`, + [name, mediaType, handbrakePreset, extraArgs, description] + ); + + const preset = await getPresetById(result.lastID); + logger.info('create', { id: preset.id, name: preset.name, mediaType: preset.mediaType }); + return preset; +} + +async function updatePreset(id, payload) { + const db = await getDb(); + const existing = await getPresetById(id); + if (!existing) { + const error = new Error(`Preset ${id} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + const name = payload?.name !== undefined ? String(payload.name || '').trim() : existing.name; + if (!name) { + const error = new Error('Preset-Name darf nicht leer sein.'); + error.statusCode = 400; + throw error; + } + + const mediaType = payload?.mediaType !== undefined + ? normalizeMediaType(payload.mediaType) + : existing.mediaType; + const handbrakePreset = payload?.handbrakePreset !== undefined + ? (String(payload.handbrakePreset || '').trim() || null) + : existing.handbrakePreset; + const extraArgs = payload?.extraArgs !== undefined + ? (String(payload.extraArgs || '').trim() || null) + : existing.extraArgs; + const description = payload?.description !== undefined + ? (String(payload.description || '').trim() || null) + : existing.description; + + await db.run( + `UPDATE user_presets + SET name = ?, media_type = ?, handbrake_preset = ?, extra_args = ?, description = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [name, mediaType, handbrakePreset, extraArgs, description, id] + ); + + const updated = await getPresetById(id); + logger.info('update', { id: updated.id, name: updated.name }); + return updated; +} + +async function deletePreset(id) { + const db = await getDb(); + const existing = await getPresetById(id); + if (!existing) { + const error = new Error(`Preset ${id} nicht gefunden.`); + error.statusCode = 404; + throw error; + } + + await db.run(`DELETE FROM user_presets WHERE id = ?`, [id]); + logger.info('delete', { id: existing.id, name: existing.name }); + return existing; +} + +module.exports = { + listPresets, + getPresetById, + createPreset, + updatePreset, + deletePreset +}; diff --git a/bin/HandBrakeCLI b/bin/HandBrakeCLI new file mode 100755 index 0000000..8077fbc Binary files /dev/null and b/bin/HandBrakeCLI differ diff --git a/build-handbrake-nvdec.sh b/build-handbrake-nvdec.sh index dd8d520..32c18ae 100755 --- a/build-handbrake-nvdec.sh +++ b/build-handbrake-nvdec.sh @@ -1,167 +1,194 @@ #!/usr/bin/env bash -# ============================================================================= -# HandBrake mit NVDEC aus Quellcode bauen -# Ubuntu 22.04 / 24.04, Debian 11 / 12 -# -# Verwendung: -# sudo bash build-handbrake-nvdec.sh [--version 1.9.0] -# -# NVDEC benötigt zur Laufzeit den NVIDIA-Treiber (libnvcuvid.so). -# ============================================================================= set -euo pipefail -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m' -info() { echo -e "${BLUE}[INFO]${RESET} $*"; } -ok() { echo -e "${GREEN}[OK]${RESET} $*"; } -warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } -fatal() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; exit 1; } +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" +REPO_ROOT="$SCRIPT_DIR" +BIN_DIR="${REPO_ROOT}/bin" +OUTPUT_BIN="${BIN_DIR}/HandBrakeCLI" +OUTPUT_TMP="${BIN_DIR}/.HandBrakeCLI.build-tmp" +HANDBRAKE_VERSION="${1:-1.10.0}" +JOBS="${JOBS:-$(nproc)}" -HANDBRAKE_VERSION="1.9.0" +export LANG="${LANG:-C.UTF-8}" +export LC_ALL="${LC_ALL:-C.UTF-8}" -while [[ $# -gt 0 ]]; do - case "$1" in - --version) HANDBRAKE_VERSION="$2"; shift 2 ;; - -h|--help) echo "Verwendung: sudo bash $0 [--version X.Y.Z]"; exit 0 ;; - *) fatal "Unbekannte Option: $1" ;; - esac -done +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RESET='\033[0m' -[[ $EUID -eq 0 ]] || fatal "Bitte als root ausführen: sudo bash $0" +info() { echo -e "${BLUE}[INFO]${RESET} $*"; } +ok() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } +fatal() { error "$*"; exit 1; } -[[ -f /etc/os-release ]] && . /etc/os-release || fatal "OS nicht erkennbar" - -echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}" -echo -e "${BOLD} HandBrake ${HANDBRAKE_VERSION} mit NVDEC bauen${RESET}" -echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}\n" - -# -------------------------------------------------------------------------- -# 1. Build-Abhängigkeiten -# -------------------------------------------------------------------------- -info "Installiere Build-Abhängigkeiten..." -apt-get update -qq -apt-get install -y \ - autoconf automake build-essential cmake git \ - libass-dev libbz2-dev libdvdnav-dev libdvdread-dev \ - libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \ - libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \ - libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool \ - libturbojpeg0-dev libvorbis-dev libvpx-dev libx264-dev libxml2-dev \ - m4 meson nasm ninja-build patch pkg-config python3 tar zlib1g-dev \ - >/dev/null -ok "Build-Abhängigkeiten installiert" - -# -------------------------------------------------------------------------- -# 2. CUDA-Header für NVDEC -# -------------------------------------------------------------------------- -info "Prüfe CUDA-Header für NVDEC-Support..." -if dpkg -l 2>/dev/null | grep -q '^ii.*nvidia-cuda-toolkit'; then - ok "nvidia-cuda-toolkit bereits installiert" -else - info "Installiere nvidia-cuda-toolkit (für NVDEC-Header)..." - if apt-get install -y nvidia-cuda-toolkit >/dev/null 2>&1; then - ok "nvidia-cuda-toolkit installiert" +run_as_root() { + if [[ "${EUID}" -eq 0 ]]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" else - warn "nvidia-cuda-toolkit nicht in Standard-Repos – versuche NVIDIA CUDA Repo..." - local_ver="${VERSION_ID//./}" - cuda_deb="/tmp/cuda-keyring.deb" - if curl -fsSL \ - "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${local_ver}/x86_64/cuda-keyring_1.1-1_all.deb" \ - -o "$cuda_deb" 2>/dev/null; then - dpkg -i "$cuda_deb" - apt-get update -qq - # Minimale Header statt vollem Toolkit - apt-get install -y cuda-cudart-dev-12-8 >/dev/null 2>&1 && \ - ok "CUDA-Header installiert (cuda-cudart-dev-12-8)" || \ - warn "CUDA-Header-Installation fehlgeschlagen – NVDEC könnte im Build fehlen." - else - warn "NVIDIA CUDA Repo nicht erreichbar – NVDEC könnte im Build fehlen." + fatal "Root-Rechte erforderlich. Bitte als root ausführen oder sudo installieren." + fi +} + +apt_get() { + run_as_root env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ + apt-get -o Dpkg::Use-Pty=0 "$@" +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || fatal "Benötigter Befehl fehlt: $cmd" +} + +cleanup_stale_tmp_build_dirs() { + local stale_dirs=() + shopt -s nullglob + stale_dirs=(/tmp/handbrake-nvdec-build-*) + shopt -u nullglob + + if [[ ${#stale_dirs[@]} -gt 0 ]]; then + warn "Bereinige alte temporäre Build-Ordner in /tmp..." + run_as_root rm -rf "${stale_dirs[@]}" + fi +} + +repair_package_state() { + local audit_output="" + audit_output="$(run_as_root dpkg --audit || true)" + + if [[ -n "${audit_output//[[:space:]]/}" ]]; then + warn "Unvollständiger Paketstatus erkannt. Repariere dpkg/apt..." + run_as_root env DEBIAN_FRONTEND=noninteractive dpkg --configure -a + apt_get --fix-broken install -y + ok "Paketstatus repariert." + fi +} + +install_build_dependencies() { + repair_package_state + + info "Aktualisiere Paketlisten..." + apt_get update + + info "Installiere Build-Abhängigkeiten..." + apt_get install -y \ + autoconf automake build-essential cmake git \ + libass-dev libbz2-dev libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \ + libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \ + libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin \ + libturbojpeg0-dev libvorbis-dev libx264-dev libxml2-dev libvpx-dev \ + m4 make meson nasm ninja-build patch pkg-config tar zlib1g-dev \ + curl libssl-dev clang bzip2 ca-certificates wget libffmpeg-nvenc-dev + + if [[ ! -f /usr/include/ffnvcodec/dynlink_nvcuvid.h ]]; then + warn "NVDEC-Header (dynlink_nvcuvid.h) nicht gefunden. Versuche nvidia-cuda-toolkit als Fallback..." + if ! apt_get install -y nvidia-cuda-toolkit; then + fatal "NVDEC-Header fehlen und nvidia-cuda-toolkit konnte nicht installiert werden." + fi + if [[ ! -f /usr/include/ffnvcodec/dynlink_nvcuvid.h && ! -f /usr/include/nvcuvid.h ]]; then + fatal "NVDEC-Header weiterhin nicht vorhanden. Prüfe Repository-Konfiguration (universe/multiverse)." fi fi -fi +} -# -------------------------------------------------------------------------- -# 3. Alle vorhandenen HandBrake-Installationen entfernen -# -------------------------------------------------------------------------- -info "Entferne alle vorhandenen HandBrake-Installationen..." -apt-get remove -y handbrake-cli handbrake 2>/dev/null || true -snap remove handbrake-cli 2>/dev/null || true -rm -f /usr/bin/HandBrakeCLI \ - /usr/local/bin/HandBrakeCLI \ - /snap/bin/handbrake-cli \ - /snap/bin/HandBrakeCLI -while true; do - FOUND=$(command -v HandBrakeCLI 2>/dev/null || true) - [[ -z "$FOUND" ]] && break - warn "Entferne: $FOUND" - rm -f "$FOUND" -done -hash -r 2>/dev/null || true -ok "Alte HandBrake-Installation(en) entfernt" +download_source() { + local tarball="$1" + local url="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2" -# -------------------------------------------------------------------------- -# 4. Quellcode herunterladen -# -------------------------------------------------------------------------- -TMP_DIR=$(mktemp -d) -trap 'cd /; rm -rf "$TMP_DIR"' EXIT - -SRC_URL="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2" -TARBALL="${TMP_DIR}/handbrake-src.tar.bz2" - -info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..." -info "URL: ${SRC_URL}" -curl -fL --progress-bar "$SRC_URL" -o "$TARBALL" || \ - wget --progress=bar:force "$SRC_URL" -O "$TARBALL" || \ - fatal "Download fehlgeschlagen. Bitte Version prüfen: https://github.com/HandBrake/HandBrake/releases" - -info "Entpacke..." -tar xjf "$TARBALL" -C "$TMP_DIR" - -SRC_DIR="${TMP_DIR}/HandBrake-${HANDBRAKE_VERSION}" -[[ -d "$SRC_DIR" ]] || SRC_DIR=$(find "$TMP_DIR" -maxdepth 1 -type d -name "HandBrake*" | head -1) -[[ -d "$SRC_DIR" ]] || fatal "Quellverzeichnis nicht gefunden" - -# -------------------------------------------------------------------------- -# 5. Konfigurieren & Bauen -# -------------------------------------------------------------------------- -cd "$SRC_DIR" - -info "Konfiguriere HandBrake mit NVDEC (--enable-nvdec)..." -./configure \ - --launch-jobs="$(nproc)" \ - --enable-nvdec \ - --prefix=/usr/local \ - 2>&1 | tail -15 - -info "Baue HandBrake mit $(nproc) Threads – das dauert 10–30 Minuten..." -make --directory=build -j"$(nproc)" - -info "Installiere nach /usr/local/bin/..." -make --directory=build install - -# -------------------------------------------------------------------------- -# 6. Ergebnis prüfen -# -------------------------------------------------------------------------- -if command -v HandBrakeCLI &>/dev/null; then - VER=$(HandBrakeCLI --version 2>&1 | head -1) - ok "Erfolgreich installiert: ${VER}" - echo "" - - # NVDEC im Binary prüfen - if HandBrakeCLI --help 2>&1 | grep -qi "nvdec"; then - ok "NVDEC: im Binary vorhanden ✓" + info "Lade HandBrake ${HANDBRAKE_VERSION} Quellcode..." + if command -v curl >/dev/null 2>&1; then + curl -fL "$url" -o "$tarball" else - warn "NVDEC: nicht in --help gefunden (evtl. kein --enable-nvdec oder kein CUDA-Header)" + wget -O "$tarball" "$url" + fi +} + +main() { + if [[ ! -f /etc/os-release ]]; then + fatal "/etc/os-release fehlt. Nur Debian/Ubuntu/Proxmox werden unterstützt." + fi + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}" in + debian|ubuntu|linuxmint|pop) ;; + *) + warn "Ungetestetes Betriebssystem: ${PRETTY_NAME:-unknown}. Es wird trotzdem versucht fortzufahren." + ;; + esac + + require_cmd nproc + require_cmd tar + require_cmd dpkg + + cleanup_stale_tmp_build_dirs + install_build_dependencies + require_cmd make + + local tmp_dir tarball src_dir + tmp_dir="$(mktemp -d -p /tmp handbrake-nvdec-build-XXXXXX)" + tarball="${tmp_dir}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2" + src_dir="${tmp_dir}/HandBrake-${HANDBRAKE_VERSION}" + + cleanup() { + rm -rf "$tmp_dir" + rm -f "$OUTPUT_TMP" + } + trap cleanup EXIT INT TERM + + download_source "$tarball" + + info "Entpacke Quellcode..." + tar xjf "$tarball" -C "$tmp_dir" + [[ -d "$src_dir" ]] || fatal "Entpacktes Quellverzeichnis nicht gefunden: $src_dir" + + local configure_log + configure_log="${tmp_dir}/configure.log" + + info "Konfiguriere Build (NVDEC aktiviert)..." + ( + cd "$src_dir" + ./configure \ + --launch-jobs="$JOBS" \ + --enable-nvdec \ + --disable-gtk \ + --prefix=/usr/local >"$configure_log" 2>&1 + ) + + if ! rg -q 'Enable NVDEC:[[:space:]]+True' "$configure_log"; then + tail -n 80 "$configure_log" >&2 || true + fatal "Configure hat NVDEC nicht aktiviert (Enable NVDEC != True)." fi - # Laufzeit-Bibliothek prüfen - if ldconfig -p 2>/dev/null | grep -q libnvcuvid; then - ok "libnvcuvid: gefunden – NVDEC zur Laufzeit verfügbar ✓" - else - warn "libnvcuvid: NICHT gefunden" - warn "→ Bitte NVIDIA-Treiber installieren: apt-get install nvidia-driver-XXX" - warn " NVDEC ist im Binary vorhanden, funktioniert aber erst mit dem Treiber." + if ! rg -q 'Enable NVENC:[[:space:]]+True' "$configure_log"; then + tail -n 80 "$configure_log" >&2 || true + fatal "Configure hat NVENC nicht aktiviert (Enable NVENC != True)." fi -else - fatal "HandBrakeCLI nach dem Build nicht gefunden – Build fehlgeschlagen." -fi + + rg 'Enable NVENC|Enable NVDEC' "$configure_log" || true + + info "Baue HandBrakeCLI mit ${JOBS} Threads (das kann länger dauern)..." + make --directory="${src_dir}/build" -j"$JOBS" + + [[ -x "${src_dir}/build/HandBrakeCLI" ]] || fatal "Build erfolgreich, aber HandBrakeCLI wurde nicht gefunden." + + mkdir -p "$BIN_DIR" + install -m 0755 "${src_dir}/build/HandBrakeCLI" "$OUTPUT_TMP" + + if "$OUTPUT_TMP" --help 2>&1 | rg -qi "nvdec|nvenc"; then + ok "Hinweis: NVENC/NVDEC-Begriffe in --help gefunden." + else + warn "--help zeigt NVENC/NVDEC nicht explizit. Maßgeblich ist die Configure-Zusammenfassung (Enable NVENC/NVDEC: True)." + fi + + mv -f "$OUTPUT_TMP" "$OUTPUT_BIN" + + ok "Fertig: ${OUTPUT_BIN}" + "$OUTPUT_BIN" --version | head -1 + info "Aufgeräumt: Nur ${OUTPUT_BIN} bleibt im Repository als Build-Artefakt." +} + +main "$@" diff --git a/db/schema.sql b/db/schema.sql index 61b036a..594a09f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -132,6 +132,19 @@ CREATE TABLE cron_run_logs ( CREATE INDEX idx_cron_run_logs_job ON cron_run_logs(cron_job_id, id DESC); +CREATE TABLE user_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'all', + handbrake_preset TEXT, + extra_args TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_user_presets_media_type ON user_presets(media_type); + -- ============================================================================= -- Default Settings Seed -- ============================================================================= diff --git a/docs/api/settings.md b/docs/api/settings.md index 58f6194..57aa3a2 100644 --- a/docs/api/settings.md +++ b/docs/api/settings.md @@ -115,11 +115,11 @@ Sendet eine Test-Benachrichtigung über PushOver. ## Skript-Verwaltung -Post-Encode-Skripte werden über eigene Endpunkte unter `/api/settings/scripts` verwaltet. +Skripte werden über eigene Endpunkte unter `/api/settings/scripts` verwaltet. Jedes Skript hat eine `scriptBody`-Property (der Shell-Befehl oder mehrzeiliges Skript) und einen `orderIndex` für die Sortierung. ### GET /api/settings/scripts -Gibt alle konfigurierten Skripte zurück. +Gibt alle Skripte zurück, sortiert nach `orderIndex`. **Response:** @@ -127,11 +127,12 @@ Gibt alle konfigurierten Skripte zurück. { "scripts": [ { - "id": "script-abc123", + "id": 1, "name": "Zu Plex verschieben", - "command": "/home/michael/scripts/move-to-plex.sh", - "description": "Verschiebt die fertige Datei ins Plex-Verzeichnis", - "createdAt": "2024-01-15T10:00:00.000Z" + "scriptBody": "mv \"$RIPSTER_OUTPUT_PATH\" /mnt/plex/movies/", + "orderIndex": 1, + "createdAt": "2026-01-15T10:00:00.000Z", + "updatedAt": "2026-01-15T10:00:00.000Z" } ] } @@ -141,75 +142,46 @@ Gibt alle konfigurierten Skripte zurück. ### POST /api/settings/scripts -Legt ein neues Post-Encode-Skript an. +Legt ein neues Skript an. **Request:** ```json { "name": "Zu Plex verschieben", - "command": "/home/michael/scripts/move-to-plex.sh", - "description": "Verschiebt die fertige Datei ins Plex-Verzeichnis" + "scriptBody": "mv \"$RIPSTER_OUTPUT_PATH\" /mnt/plex/movies/" } ``` | Feld | Typ | Pflicht | Beschreibung | |------|-----|---------|-------------| -| `name` | string | ✅ | Anzeigename | -| `command` | string | ✅ | Shell-Befehl oder absoluter Skriptpfad | -| `description` | string | — | Optionale Beschreibung | +| `name` | string | ✅ | Anzeigename (eindeutig) | +| `scriptBody` | string | ✅ | Shell-Befehl oder mehrzeiliges Skript | -**Response:** - -```json -{ - "ok": true, - "script": { - "id": "script-abc123", - "name": "Zu Plex verschieben", - "command": "/home/michael/scripts/move-to-plex.sh" - } -} -``` +**Response:** `201 Created` – `{ "script": { ... } }` --- -### PUT /api/settings/scripts/:scriptId +### PUT /api/settings/scripts/:id -Aktualisiert ein vorhandenes Skript. - -**URL-Parameter:** `scriptId` - -**Request:** Gleiche Felder wie beim Anlegen (alle optional). - -```json -{ "name": "Zu Jellyfin verschieben", "command": "/home/michael/scripts/move-to-jellyfin.sh" } -``` - -**Response:** `{ "ok": true }` +Aktualisiert ein vorhandenes Skript. Alle Felder optional. --- -### DELETE /api/settings/scripts/:scriptId +### DELETE /api/settings/scripts/:id Löscht ein Skript. -**URL-Parameter:** `scriptId` - -**Response:** `{ "ok": true }` - -!!! warning "Referenzen in Jobs" - Wenn das Skript in laufenden oder abgeschlossenen Jobs referenziert wird, wird es trotzdem gelöscht. In zukünftigen Encode-Reviews erscheint es nicht mehr. +!!! warning "Referenzen" + Das Skript wird gelöscht, auch wenn es in Job-Historien referenziert ist. In zukünftigen Reviews erscheint es nicht mehr. --- -### POST /api/settings/scripts/:scriptId/test +### POST /api/settings/scripts/:id/test Führt ein Skript mit Platzhalter-Umgebungsvariablen aus (Testlauf). -**URL-Parameter:** `scriptId` - -**Response (Erfolg):** +**Response:** ```json { @@ -221,18 +193,6 @@ Führt ein Skript mit Platzhalter-Umgebungsvariablen aus (Testlauf). } ``` -**Response (Fehler):** - -```json -{ - "ok": false, - "exitCode": 1, - "stdout": "", - "stderr": "Datei nicht gefunden: /home/michael/scripts/move-to-plex.sh", - "durationMs": 12 -} -``` - **Platzhalter-Werte beim Testlauf:** | Variable | Testwert | @@ -246,28 +206,149 @@ Führt ein Skript mit Platzhalter-Umgebungsvariablen aus (Testlauf). --- +### POST /api/settings/scripts/reorder + +Ändert die Reihenfolge der Skripte (persistiert in `order_index`). + +**Request:** + +```json +{ "orderedScriptIds": [3, 1, 2] } +``` + +**Response:** `{ "scripts": [ ... ] }` – alle Skripte in neuer Reihenfolge. + +--- + +## Skript-Ketten-Verwaltung + +Skript-Ketten werden unter `/api/settings/script-chains` verwaltet. + +### GET /api/settings/script-chains + +Gibt alle Ketten zurück (inkl. Schritte). + +### POST /api/settings/script-chains + +Legt eine neue Kette an. + +```json +{ "name": "Nach Jellyfin deployen" } +``` + +### PUT /api/settings/script-chains/:id + +Aktualisiert eine Kette (Name, Schritte). + +### DELETE /api/settings/script-chains/:id + +Löscht eine Kette und alle ihre Schritte. + +### POST /api/settings/script-chains/:id/test + +Führt eine Kette mit Platzhalter-Umgebungsvariablen aus (Testlauf). + +**Response:** + +```json +{ + "result": { + "success": true, + "steps": [ + { "scriptId": 1, "scriptName": "Zu Plex verschieben", "success": true, "exitCode": 0 } + ] + } +} +``` + +### POST /api/settings/script-chains/reorder + +Ändert die Reihenfolge der Ketten (persistiert in `order_index`). + +**Request:** + +```json +{ "orderedChainIds": [2, 1, 3] } +``` + +--- + +## User-Presets + +Benannte HandBrake-Preset-Sammlungen, die im Encode-Review schnell angewendet werden können. Unter `/api/settings/user-presets` verwaltet. + +### GET /api/settings/user-presets + +Gibt alle User-Presets zurück. Optional gefiltert per Query-Parameter `mediaType`. + +**Query-Parameter:** + +| Parameter | Werte | Beschreibung | +|-----------|-------|-------------| +| `mediaType` | `bluray`, `dvd`, `other`, `all` | Filtert Presets nach Medientyp | + +**Response:** + +```json +{ + "presets": [ + { + "id": 1, + "name": "Blu-ray High Quality", + "mediaType": "bluray", + "handbrakePreset": "H.265 MKV 1080p30", + "extraArgs": "--encoder-preset slow", + "description": "Langsam, aber beste Qualität", + "createdAt": "2026-01-15T10:00:00.000Z", + "updatedAt": "2026-01-15T10:00:00.000Z" + } + ] +} +``` + +--- + +### POST /api/settings/user-presets + +Legt ein neues User-Preset an. + +**Request:** + +```json +{ + "name": "Blu-ray High Quality", + "mediaType": "bluray", + "handbrakePreset": "H.265 MKV 1080p30", + "extraArgs": "--encoder-preset slow", + "description": "Langsam, aber beste Qualität" +} +``` + +| Feld | Typ | Pflicht | Beschreibung | +|------|-----|---------|-------------| +| `name` | string | ✅ | Anzeigename | +| `mediaType` | string | — | `bluray`, `dvd`, `other`, `all` (Standard: `all`) | +| `handbrakePreset` | string | — | HandBrake-Preset-Name (`-Z`) | +| `extraArgs` | string | — | Zusatz-CLI-Argumente | +| `description` | string | — | Optionale Beschreibung | + +**Response:** `201 Created` – `{ "preset": { ... } }` + +--- + +### PUT /api/settings/user-presets/:id + +Aktualisiert ein User-Preset. Alle Felder optional. + +--- + +### DELETE /api/settings/user-presets/:id + +Löscht ein User-Preset. + +--- + ## Einstellungs-Schlüssel Referenz -Eine vollständige Liste aller Einstellungs-Schlüssel: - -| Schlüssel | Kategorie | Typ | Beschreibung | -|---------|----------|-----|-------------| -| `raw_dir` | paths | string | Raw-MKV Verzeichnis | -| `movie_dir` | paths | string | Ausgabe-Verzeichnis | -| `log_dir` | paths | string | Log-Verzeichnis | -| `makemkv_command` | tools | string | MakeMKV-Befehl | -| `handbrake_command` | tools | string | HandBrake-Befehl | -| `mediainfo_command` | tools | string | MediaInfo-Befehl | -| `handbrake_preset` | encoding | string | HandBrake-Preset-Name | -| `handbrake_extra_args` | encoding | string | Zusatz-Argumente | -| `output_extension` | encoding | string | Dateiendung (z.B. `mkv`) | -| `filename_template` | encoding | string | Dateiname-Template | -| `drive_mode` | drive | select | `auto` oder `explicit` | -| `drive_device` | drive | string | Geräte-Pfad | -| `disc_poll_interval_ms` | drive | number | Polling-Intervall (ms) | -| `makemkv_min_length_minutes` | makemkv | number | Min. Titellänge (Minuten) | -| `makemkv_backup_mode` | makemkv | boolean | Backup-Modus aktivieren | -| `omdb_api_key` | omdb | string | OMDb API-Key | -| `omdb_default_type` | omdb | select | Standard-Suchtyp | -| `pushover_user_key` | notifications | string | PushOver User-Key | -| `pushover_api_token` | notifications | string | PushOver API-Token | +Eine vollständige Übersicht aller Schlüssel: +[:octicons-arrow-right-24: Einstellungsreferenz](../configuration/settings-reference.md) diff --git a/docs/api/websocket.md b/docs/api/websocket.md index 714389f..cb52704 100644 --- a/docs/api/websocket.md +++ b/docs/api/websocket.md @@ -167,6 +167,52 @@ Fehler im Laufwerkserkennungsdienst. } ``` +### SETTINGS_SCRIPTS_UPDATED + +Wird gesendet, wenn ein Skript angelegt, aktualisiert, gelöscht oder umsortiert wurde. + +```json +{ + "type": "SETTINGS_SCRIPTS_UPDATED", + "payload": { + "action": "reordered", + "count": 3 + } +} +``` + +`action` ist `"created"`, `"updated"`, `"deleted"` oder `"reordered"`. + +### SETTINGS_SCRIPT_CHAINS_UPDATED + +Wird gesendet bei Änderungen an Skript-Ketten. + +```json +{ + "type": "SETTINGS_SCRIPT_CHAINS_UPDATED", + "payload": { + "action": "created", + "id": 2 + } +} +``` + +### USER_PRESETS_UPDATED + +Wird gesendet, wenn ein User-Preset angelegt, aktualisiert oder gelöscht wurde. + +```json +{ + "type": "USER_PRESETS_UPDATED", + "payload": { + "action": "created", + "id": 1 + } +} +``` + +`action` ist `"created"`, `"updated"` oder `"deleted"`. + ### CRON_JOBS_UPDATED Wird gesendet, wenn ein Cron-Job angelegt, aktualisiert oder gelöscht wurde. diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 8ac6259..d0a63b2 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -46,7 +46,7 @@ flowchart LR WUD -->|selectMetadata(selectedPlaylist)| MIC MIC -->|Tracks analysiert| RTE[READY_TO\nENCODE] RTE -->|confirmEncodeReview() + startPreparedJob()| ENC[ENCODING] - ENC -->|HandBrake + Post-Skripte fertig| FIN([FINISHED]) + ENC -->|Pre-Encode → HandBrake → Post-Encode fertig| FIN([FINISHED]) ENC -->|Abbruch| CAN([CANCELLED]) ENC -->|Fehler| ERR([ERROR]) RIP -->|Fehler| ERR @@ -84,10 +84,18 @@ Der Service pollt das Laufwerk im konfigurierten Intervall (`disc_poll_interval_ ```js // Ereignisse -emit('discInserted', { path: '/dev/sr0' }) +emit('discInserted', { path: '/dev/sr0', mediaProfile: 'bluray', ... }) emit('discRemoved', { path: '/dev/sr0' }) ``` +### Media-Profil-Erkennung + +Das erkannte Gerät enthält ein `mediaProfile`-Feld (`"bluray"`, `"dvd"`, `"other"` oder `null`). Die Erkennung nutzt eine Heuristik aus drei Quellen (absteigend nach Priorität): + +1. Explizit gesetztes `media_profile` aus den Settings +2. Disc-Label und Laufwerks-Modell (Regex gegen bekannte Begriffe) +3. Dateisystemtyp: `udf` → bevorzugt DVD, kombiniert mit Modell; `iso9660/cdfs` → DVD oder CD + --- ## processRunner.js @@ -170,20 +178,50 @@ Verwaltet alle Anwendungseinstellungen. ### Features - **Schema-getriebene Validierung**: Jede Einstellung hat Typ, Grenzen und Pflichtfeld-Flag -- **Kategorisierung**: Einstellungen sind in Kategorien gruppiert (Paths, Tools, Encoding, ...) +- **Kategorisierung**: Einstellungen sind in Kategorien gruppiert (Pfade, Tools, Metadaten, …) - **Persistenz**: Werte in SQLite, Schema ebenfalls in SQLite -- **Defaults**: `defaultSettings.js` definiert Standardwerte +- **Profil-Auflösung**: `resolveEffectiveToolSettings(settingsMap, mediaProfile)` wählt automatisch die profil-spezifischen Werte (`_bluray`/`_dvd`) und fällt auf den globalen Wert zurück + +### Profil-Auflösung + +```js +// Löst alle profil-spezifischen Keys auf und gibt einen effektiven Einstellungs-Map zurück +const effective = await settingsService.getEffectiveSettingsMap('bluray'); +// effective.handbrake_preset → Wert aus handbrake_preset_bluray (falls gesetzt) +// effective.raw_dir → Wert aus raw_dir_bluray (kein Fallback bei Pfaden) +``` ### Einstellungs-Kategorien -| Kategorie | Einstellungen | -|-----------|--------------| -| `Pfade` | `raw_dir`, `movie_dir`, `log_dir` | +| Kategorie | Ausgewählte Schlüssel | +|-----------|----------------------| +| `Pfade` | `raw_dir[_bluray/_dvd/_other]`, `movie_dir[_bluray/_dvd/_other]`, `log_dir` | | `Laufwerk` | `drive_mode`, `drive_device`, `disc_poll_interval_ms`, `makemkv_source_index` | | `Monitoring` | `hardware_monitoring_enabled`, `hardware_monitoring_interval_ms` | | `Tools` | `makemkv_command`, `handbrake_command`, `mediainfo_command`, `pipeline_max_parallel_jobs` | +| `Tools – Blu-ray` | `handbrake_preset_bluray`, `makemkv_rip_mode_bluray`, … | +| `Tools – DVD` | `handbrake_preset_dvd`, `makemkv_rip_mode_dvd`, … | | `Metadaten` | `omdb_api_key`, `omdb_default_type` | -| `Benachrichtigungen` | `pushover_user_key`, `pushover_api_token` | +| `Benachrichtigungen` | `pushover_enabled`, `pushover_token`, `pushover_notify_*` | + +--- + +## userPresetService.js + +Verwaltet benannte HandBrake-Preset-Sammlungen pro Medientyp. + +### Methoden + +| Methode | Beschreibung | +|---------|-------------| +| `listPresets(mediaType?)` | Alle Presets; optional nach Medientyp filtern (`bluray`/`dvd`/`other`/`all`) | +| `getPresetById(id)` | Einzelnes Preset | +| `createPreset(payload)` | Neues Preset anlegen | +| `updatePreset(id, payload)` | Preset aktualisieren | +| `deletePreset(id)` | Preset löschen | + +!!! info "mediaType = 'all'" + Presets mit `mediaType = 'all'` erscheinen bei Filterung nach jedem Medientyp. --- @@ -204,6 +242,33 @@ Datenbankoperationen für Job-Historie. --- +## cronService.js + +Eingebautes Cron-System ohne externe Abhängigkeiten. + +### Features + +- **Eigener Expression-Parser**: Unterstützt alle Standard-5-Felder-Cron-Ausdrücke (`* /n`, Bereiche, Listen) +- **Skripte und Ketten**: Cron-Jobs können ein Skript (`sourceType: "script"`) oder eine Kette (`sourceType: "chain"`) ausführen +- **Log-Rotation**: Max. 50 Logs pro Job, Ausgabe auf 100.000 Zeichen begrenzt +- **PushOver-Integration**: Optionale Benachrichtigung nach jeder Ausführung +- **Manuelle Auslösung**: `triggerJobManually(id)` – läuft unabhängig vom Zeitplan + +### Methoden + +| Methode | Beschreibung | +|---------|-------------| +| `listJobs()` | Alle Cron-Jobs | +| `createJob(payload)` | Neuen Job anlegen | +| `updateJob(id, payload)` | Job aktualisieren | +| `deleteJob(id)` | Job löschen | +| `getJobLogs(id, limit)` | Ausführungs-Logs | +| `triggerJobManually(id)` | Sofortige Ausführung | +| `validateExpression(expr)` | Ausdruck validieren | +| `getNextRunTime(expr)` | Nächsten Ausführungszeitpunkt berechnen | + +--- + ## notificationService.js PushOver-Push-Benachrichtigungen. diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 9bc4afb..ac29785 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -14,6 +14,7 @@ pipeline_state -- Aktueller Pipeline-Zustand (Singleton) scripts -- Shell-Skripte für Pre-/Post-Encode-Ausführung script_chains -- Geordnete Ketten aus mehreren Skripten script_chain_steps -- Einzelschritte einer Skript-Kette +user_presets -- Benannte HandBrake-Preset-Sammlungen pro Medientyp cron_jobs -- Zeitgesteuerte Aufgaben (eigener Cron-Parser) cron_run_logs -- Ausführungs-Protokolle für Cron-Jobs ``` @@ -161,6 +162,28 @@ CREATE TABLE script_chain_steps ( --- +## Tabelle: user_presets + +Speichert benannte HandBrake-Preset-Sammlungen pro Medientyp. + +```sql +CREATE TABLE user_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'all', -- 'bluray', 'dvd', 'other', 'all' + handbrake_preset TEXT, + extra_args TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +!!! info "Medientyp-Filter" + `GET /api/settings/user-presets?mediaType=bluray` gibt Presets mit `media_type = 'bluray'` **und** `media_type = 'all'` zurück. + +--- + ## Tabellen: cron_jobs & cron_run_logs Speichern den Zeitplan und die Ausführungs-Historie des eingebauten Cron-Systems. diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md index 318ac23..8f4dd8c 100644 --- a/docs/configuration/settings-reference.md +++ b/docs/configuration/settings-reference.md @@ -1,138 +1,180 @@ # Einstellungsreferenz -Vollständige Übersicht aller Ripster-Einstellungen. Alle Einstellungen werden über die Web-Oberfläche unter **Einstellungen** verwaltet. +Vollständige Übersicht aller Ripster-Einstellungen. Alle Einstellungen werden über die Web-Oberfläche unter **Einstellungen** verwaltet und in SQLite gespeichert. --- -## Kategorie: Pfade (paths) +## Profil-System -| Schlüssel | Typ | Standard | Pflicht | Beschreibung | -|---------|-----|---------|---------|-------------| -| `raw_dir` | string | — | ✅ | Verzeichnis für rohe MKV-Dateien nach dem Ripping | -| `movie_dir` | string | — | ✅ | Ausgabeverzeichnis für encodierte Filme | -| `log_dir` | string | `./logs` | — | Verzeichnis für Log-Dateien | +Ripster erkennt den Medientyp einer eingelegten Disc (Blu-ray / DVD / CD) und wählt automatisch die passenden profil-spezifischen Einstellungen. Für viele Schlüssel gibt es zusätzlich zur globalen Einstellung eine Variante pro Profil: -!!! example "Beispielkonfiguration" - ``` - raw_dir = /mnt/nas/raw - movie_dir = /mnt/nas/movies - log_dir = /var/log/ripster - ``` +| Profil | Erkennungsmerkmale | +|--------|--------------------| +| `bluray` | UDF-Dateisystem, Laufwerk-Modell enthält „Blu-ray", Disc-Label wie BDMV | +| `dvd` | ISO9660/UDF, Laufwerk-Modell enthält „DVD", VIDEO_TS-Struktur | +| `other` | Alles andere (CD, unbekannt) | + +**Auflösungsreihenfolge für profil-spezifische Einstellungen:** + +1. Profil-spezifischer Wert (`_bluray` / `_dvd`) – wenn gesetzt, hat dieser Vorrang +2. Alternativ-Profil als Fallback (Blu-ray → DVD-Wert als Fallback und umgekehrt) + +Pfad-Einstellungen (`raw_dir`, `movie_dir`) und Besitz-Einstellungen (`raw_dir_owner`, `movie_dir_owner`) werden **ausschließlich** aus dem passenden Profil bezogen – kein Cross-Profil-Fallback. --- -## Kategorie: Tools (tools) +## Kategorie: Pfade + +| Schlüssel | Typ | Pflicht | Beschreibung | +|-----------|-----|---------|-------------| +| `raw_dir` | string | ✅ | Verzeichnis für rohe MKV-Dateien (Fallback wenn kein Profil-Wert) | +| `raw_dir_bluray` | string | — | Raw-Verzeichnis für Blu-rays | +| `raw_dir_dvd` | string | — | Raw-Verzeichnis für DVDs | +| `raw_dir_other` | string | — | Raw-Verzeichnis für sonstige Medien | +| `raw_dir_owner` | string | — | Besitzer für Raw-Verzeichnis (`user:group`, Fallback) | +| `raw_dir_bluray_owner` | string | — | Besitzer für Raw-Verzeichnis (Blu-ray) | +| `raw_dir_dvd_owner` | string | — | Besitzer für Raw-Verzeichnis (DVD) | +| `raw_dir_other_owner` | string | — | Besitzer für Raw-Verzeichnis (Sonstiges) | +| `movie_dir` | string | ✅ | Ausgabeverzeichnis für Filme (Fallback) | +| `movie_dir_bluray` | string | — | Ausgabeverzeichnis für Blu-rays | +| `movie_dir_dvd` | string | — | Ausgabeverzeichnis für DVDs | +| `movie_dir_other` | string | — | Ausgabeverzeichnis für sonstige Medien | +| `movie_dir_owner` | string | — | Besitzer für Ausgabeverzeichnis (Fallback) | +| `movie_dir_bluray_owner` | string | — | Besitzer für Ausgabeverzeichnis (Blu-ray) | +| `movie_dir_dvd_owner` | string | — | Besitzer für Ausgabeverzeichnis (DVD) | +| `movie_dir_other_owner` | string | — | Besitzer für Ausgabeverzeichnis (Sonstiges) | +| `log_dir` | string | — | Verzeichnis für Log-Dateien (Standard: `./logs`) | + +--- + +## Kategorie: Laufwerk | Schlüssel | Typ | Standard | Beschreibung | -|---------|-----|---------|-------------| +|-----------|-----|---------|-------------| +| `drive_mode` | select | `auto` | `auto` = automatisch erkennen, `explicit` = festes Gerät | +| `drive_device` | string | `/dev/sr0` | Geräte-Pfad (nur bei `explicit`) | +| `disc_poll_interval_ms` | number | `4000` | Polling-Intervall in Millisekunden (1000–60000) | +| `makemkv_source_index` | number | `0` | Laufwerk-Index für MakeMKV (bei mehreren Laufwerken) | + +--- + +## Kategorie: Tools (global) + +| Schlüssel | Typ | Standard | Beschreibung | +|-----------|-----|---------|-------------| | `makemkv_command` | string | `makemkvcon` | Befehl oder absoluter Pfad zu MakeMKV | | `handbrake_command` | string | `HandBrakeCLI` | Befehl oder absoluter Pfad zu HandBrake | | `mediainfo_command` | string | `mediainfo` | Befehl oder absoluter Pfad zu MediaInfo | +| `makemkv_min_length_minutes` | number | `15` | Mindest-Titellänge in Minuten (0–999) | +| `pipeline_max_parallel_jobs` | number | `1` | Maximale Anzahl parallel laufender Jobs (1–12) | +| `handbrake_restart_delete_incomplete_output` | boolean | `true` | Unvollständige Ausgabedatei beim Encode-Neustart löschen | -!!! tip "Absolute Pfade verwenden" - Falls die Tools nicht im `PATH` des Systems sind: - ``` - makemkv_command = /usr/local/bin/makemkvcon - handbrake_command = /usr/local/bin/HandBrakeCLI - mediainfo_command = /usr/bin/mediainfo - ``` - ---- - -## Kategorie: Encoding (encoding) +### Kategorie: Tools – Blu-ray | Schlüssel | Typ | Standard | Beschreibung | -|---------|-----|---------|-------------| -| `handbrake_preset` | string | `H.265 MKV 1080p30` | Name des HandBrake-Presets | -| `handbrake_extra_args` | string | _(leer)_ | Zusätzliche HandBrake CLI-Argumente | -| `output_extension` | string | `mkv` | Dateiendung der Ausgabedatei | -| `filename_template` | string | `{title} ({year})` | Template für den Dateinamen | +|-----------|-----|---------|-------------| +| `makemkv_rip_mode_bluray` | select | `backup` | Rip-Modus: `mkv` oder `backup` | +| `makemkv_analyze_extra_args_bluray` | string | — | Zusatz-CLI-Parameter für Analyse (Blu-ray) | +| `makemkv_rip_extra_args_bluray` | string | — | Zusatz-CLI-Parameter für Rip (Blu-ray) | +| `mediainfo_extra_args_bluray` | string | — | Zusatz-CLI-Parameter für mediainfo (Blu-ray) | +| `handbrake_preset_bluray` | string | `H.264 MKV 1080p30` | HandBrake-Preset für Blu-rays | +| `handbrake_extra_args_bluray` | string | — | Zusatz-CLI-Argumente für HandBrake (Blu-ray) | +| `output_extension_bluray` | select | `mkv` | Ausgabeformat: `mkv` oder `mp4` | +| `filename_template_bluray` | string | `${title} (${year})` | Dateiname-Template (Blu-ray) | +| `output_folder_template_bluray` | string | — | Ordnername-Template (Blu-ray, leer = Dateiname-Template) | -### Verfügbare HandBrake-Presets +### Kategorie: Tools – DVD -Eine vollständige Liste der verfügbaren Presets: +| Schlüssel | Typ | Standard | Beschreibung | +|-----------|-----|---------|-------------| +| `makemkv_rip_mode_dvd` | select | `mkv` | Rip-Modus: `mkv` oder `backup` | +| `makemkv_analyze_extra_args_dvd` | string | — | Zusatz-CLI-Parameter für Analyse (DVD) | +| `makemkv_rip_extra_args_dvd` | string | — | Zusatz-CLI-Parameter für Rip (DVD) | +| `mediainfo_extra_args_dvd` | string | — | Zusatz-CLI-Parameter für mediainfo (DVD) | +| `handbrake_preset_dvd` | string | `H.264 MKV 480p30` | HandBrake-Preset für DVDs | +| `handbrake_extra_args_dvd` | string | — | Zusatz-CLI-Argumente für HandBrake (DVD) | +| `output_extension_dvd` | select | `mkv` | Ausgabeformat: `mkv` oder `mp4` | +| `filename_template_dvd` | string | `${title} (${year})` | Dateiname-Template (DVD) | +| `output_folder_template_dvd` | string | — | Ordnername-Template (DVD, leer = Dateiname-Template) | -```bash -HandBrakeCLI --preset-list -``` +### Globale Fallback-Einstellungen für Encode -Häufig verwendete Presets: +Diese Werte werden verwendet, wenn kein profil-spezifischer Wert konfiguriert ist: -| Preset | Beschreibung | -|--------|-------------| -| `H.265 MKV 1080p30` | H.265/HEVC, Full-HD, 30fps | -| `H.265 MKV 720p30` | H.265/HEVC, HD, 30fps | -| `H.264 MKV 1080p30` | H.264/AVC, Full-HD, 30fps | -| `HQ 1080p30 Surround` | Hohe Qualität, Full-HD mit Surround | +| Schlüssel | Typ | Standard | Beschreibung | +|-----------|-----|---------|-------------| +| `handbrake_preset` | string | `H.265 MKV 1080p30` | Fallback HandBrake-Preset | +| `handbrake_extra_args` | string | — | Fallback Extra-Args | +| `makemkv_rip_mode` | select | `mkv` | Fallback Rip-Modus | +| `makemkv_analyze_extra_args` | string | — | Fallback Analyse-Args | +| `makemkv_rip_extra_args` | string | — | Fallback Rip-Args | +| `mediainfo_extra_args` | string | — | Fallback MediaInfo-Args | +| `output_extension` | select | `mkv` | Fallback Ausgabeformat | +| `filename_template` | string | `${title} (${year})` | Fallback Dateiname-Template | +| `output_folder_template` | string | — | Fallback Ordnername-Template | -### Dateiname-Template-Platzhalter +### Template-Platzhalter | Platzhalter | Beispiel | |------------|---------| -| `{title}` | `Inception` | -| `{year}` | `2010` | -| `{imdb_id}` | `tt1375666` | -| `{type}` | `movie` | +| `${title}` | `Inception` | +| `${year}` | `2010` | +| `${imdbId}` | `tt1375666` | --- -## Kategorie: Laufwerk (drive) - -| Schlüssel | Typ | Standard | Optionen | Beschreibung | -|---------|-----|---------|---------|-------------| -| `drive_mode` | select | `auto` | `auto`, `explicit` | Laufwerk-Erkennungsmodus | -| `drive_device` | string | `/dev/sr0` | — | Geräte-Pfad (nur bei `explicit`) | -| `disc_poll_interval_ms` | number | `4000` | 1000–60000 | Polling-Intervall in Millisekunden | - -**`drive_mode` Optionen:** - -| Modus | Beschreibung | -|------|-------------| -| `auto` | Ripster erkennt das Laufwerk automatisch | -| `explicit` | Verwendet das in `drive_device` konfigurierte Gerät | - ---- - -## Kategorie: MakeMKV (makemkv) - -| Schlüssel | Typ | Standard | Min | Max | Beschreibung | -|---------|-----|---------|-----|-----|-------------| -| `makemkv_min_length_minutes` | number | `15` | `0` | `999` | Mindest-Titellänge in Minuten | -| `makemkv_backup_mode` | boolean | `false` | — | — | Backup-Modus statt MKV-Modus | - -**`makemkv_min_length_minutes`:** Titel kürzer als dieser Wert werden von MakeMKV ignoriert. Verhindert das Rippen von Menü-Schleifen und kurzen Extra-Clips. - -**`makemkv_backup_mode`:** Im Backup-Modus erstellt MakeMKV eine vollständige Disc-Kopie mit Menüs. Im Standard-Modus werden direkt MKV-Dateien erstellt. - ---- - -## Kategorie: OMDb (omdb) - -| Schlüssel | Typ | Standard | Pflicht | Beschreibung | -|---------|-----|---------|---------|-------------| -| `omdb_api_key` | string | — | ✅ | API-Key von [omdbapi.com](https://www.omdbapi.com/) | -| `omdb_default_type` | select | `movie` | — | Standard-Suchtyp: `movie` oder `series` | - ---- - -## Kategorie: Benachrichtigungen (notifications) +## Kategorie: Metadaten | Schlüssel | Typ | Standard | Beschreibung | -|---------|-----|---------|-------------| -| `pushover_user_key` | string | — | PushOver User-Key | -| `pushover_api_token` | string | — | PushOver API-Token | +|-----------|-----|---------|-------------| +| `omdb_api_key` | string | — | API-Key von [omdbapi.com](https://www.omdbapi.com/) | +| `omdb_default_type` | select | `movie` | Vorauswahl für OMDb-Suche: `movie`, `series`, `episode` | -Beide Felder müssen konfiguriert sein, um PushOver-Benachrichtigungen zu aktivieren. Die Verbindung kann mit dem **Test-Button** in den Einstellungen geprüft werden. +--- + +## Kategorie: Benachrichtigungen (PushOver) + +| Schlüssel | Typ | Standard | Beschreibung | +|-----------|-----|---------|-------------| +| `pushover_enabled` | boolean | `false` | Master-Schalter für PushOver | +| `pushover_token` | string | — | Application-Token | +| `pushover_user` | string | — | User-Key | +| `pushover_device` | string | — | Optionales Ziel-Device | +| `pushover_title_prefix` | string | `Ripster` | Präfix im Benachrichtigungstitel | +| `pushover_priority` | number | `0` | Priorität (-2 bis 2) | +| `pushover_timeout_ms` | number | `7000` | HTTP-Timeout für PushOver-Requests (ms) | + +### Granulare Event-Schalter + +| Schlüssel | Standard | Beschreibung | +|-----------|---------|-------------| +| `pushover_notify_metadata_ready` | `true` | Bei Metadaten-Auswahl benachrichtigen | +| `pushover_notify_rip_started` | `true` | Bei MakeMKV-Rip-Start | +| `pushover_notify_encoding_started` | `true` | Bei HandBrake-Start | +| `pushover_notify_job_finished` | `true` | Bei erfolgreichem Abschluss | +| `pushover_notify_job_error` | `true` | Bei Fehler | +| `pushover_notify_job_cancelled` | `true` | Bei manuellem Abbruch | +| `pushover_notify_reencode_started` | `true` | Bei Re-Encode-Start | +| `pushover_notify_reencode_finished` | `true` | Bei erfolgreichem Re-Encode | + +--- + +## Kategorie: Monitoring + +| Schlüssel | Typ | Standard | Beschreibung | +|-----------|-----|---------|-------------| +| `hardware_monitoring_enabled` | boolean | `false` | Hardware-Monitoring aktivieren (CPU, RAM, Temp.) | +| `hardware_monitoring_interval_ms` | number | `5000` | Monitoring-Polling-Intervall (ms) | --- ## Standard-Einstellungen zurücksetzen -Über die Datenbank können Einstellungen auf Standardwerte zurückgesetzt werden: +Einen einzelnen Wert über die Datenbank zurücksetzen: ```bash sqlite3 backend/data/ripster.db \ - "DELETE FROM settings_values WHERE key = 'handbrake_preset';" + "DELETE FROM settings_values WHERE key = 'handbrake_preset_bluray';" ``` -Beim nächsten Laden der Einstellungen wird der Standardwert verwendet. +Beim nächsten Laden wird der Standardwert aus `settings_schema.default_value` verwendet. diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6d8676b..36a83da 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -266,6 +266,29 @@ export const api = { return request(`/history/${jobId}${suffix}`); }, + // ── User Presets ─────────────────────────────────────────────────────────── + getUserPresets(mediaType = null) { + const suffix = mediaType ? `?media_type=${encodeURIComponent(mediaType)}` : ''; + return request(`/settings/user-presets${suffix}`); + }, + createUserPreset(payload = {}) { + return request('/settings/user-presets', { + method: 'POST', + body: JSON.stringify(payload) + }); + }, + updateUserPreset(id, payload = {}) { + return request(`/settings/user-presets/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + }, + deleteUserPreset(id) { + return request(`/settings/user-presets/${encodeURIComponent(id)}`, { + method: 'DELETE' + }); + }, + // ── Cron Jobs ────────────────────────────────────────────────────────────── getCronJobs() { return request('/crons'); diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index 2042c70..5c6bcce 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -174,7 +174,8 @@ function buildHandBrakeCommandPreview({ title, selectedAudioTrackIds, selectedSubtitleTrackIds, - commandOutputPath = null + commandOutputPath = null, + presetOverride = null }) { const inputPath = String(title?.filePath || review?.encodeInputPath || '').trim(); const handBrakeCmd = String( @@ -182,8 +183,12 @@ function buildHandBrakeCommandPreview({ || review?.selectors?.handBrakeCommand || 'HandBrakeCLI' ).trim() || 'HandBrakeCLI'; - const preset = String(review?.selectors?.preset || '').trim(); - const extraArgs = String(review?.selectors?.extraArgs || '').trim(); + const preset = presetOverride !== null + ? String(presetOverride.handbrakePreset || '').trim() + : String(review?.selectors?.preset || '').trim(); + const extraArgs = presetOverride !== null + ? String(presetOverride.extraArgs || '').trim() + : String(review?.selectors?.extraArgs || '').trim(); const rawMappedTitleId = Number(review?.handBrakeTitleId); const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0 ? Math.trunc(rawMappedTitleId) @@ -697,7 +702,10 @@ export default function MediaInfoReviewPanel({ onAddPostEncodeItem = null, onChangePostEncodeItem = null, onRemovePostEncodeItem = null, - onReorderPostEncodeItem = null + onReorderPostEncodeItem = null, + userPresets = [], + selectedUserPresetId = null, + onUserPresetChange = null }) { if (!review) { return

Keine Mediainfo-Daten vorhanden.

; @@ -711,6 +719,18 @@ export default function MediaInfoReviewPanel({ const playlistRecommendation = review.playlistRecommendation || null; const rawPreset = String(review.selectors?.preset || '').trim(); const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)'; + + // User preset resolution + const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : []; + const selectedUserPreset = selectedUserPresetId + ? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null + : null; + const effectivePresetOverride = selectedUserPreset + ? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' } + : null; + const hasUserPresets = normalizedUserPresets.length > 0; + const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection; + const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : []) .map((item) => ({ id: normalizeScriptId(item?.id), @@ -740,10 +760,56 @@ export default function MediaInfoReviewPanel({ return (
+ {allowUserPresetSelection && ( +
+ + ({ + label: `${p.name}${p.mediaType !== 'all' ? ` [${p.mediaType === 'bluray' ? 'Blu-ray' : p.mediaType === 'dvd' ? 'DVD' : 'Sonstiges'}]` : ''}`, + value: Number(p.id) + })) + ]} + onChange={(e) => onUserPresetChange(e.value)} + placeholder="(Einstellungen-Fallback)" + style={{ width: '100%' }} + /> + {selectedUserPreset && ( +
+ {selectedUserPreset.handbrakePreset + ? -Z {selectedUserPreset.handbrakePreset} + : (kein Preset-Name)} + {selectedUserPreset.extraArgs && ( + Args: {selectedUserPreset.extraArgs} + )} + {selectedUserPreset.description && ( + {selectedUserPreset.description} + )} +
+ )} +
+ )} +
-
Preset: {presetLabel}
-
Extra Args: {review.selectors?.extraArgs || '(keine)'}
-
Preset-Profil: {review.selectors?.presetProfileSource || '-'}
+
+ Preset:{' '} + {effectivePresetOverride + ? (effectivePresetOverride.handbrakePreset || '(kein Preset)') + : presetLabel} + {effectivePresetOverride && (User-Preset: {selectedUserPreset?.name})} +
+
+ Extra Args:{' '} + {effectivePresetOverride + ? (effectivePresetOverride.extraArgs || '(keine)') + : (review.selectors?.extraArgs || '(keine)')} + {effectivePresetOverride && !selectedUserPreset?.extraArgs && (aus User-Preset)} +
+
Preset-Profil: {effectivePresetOverride ? 'user-preset' : (review.selectors?.presetProfileSource || '-')}
MIN_LENGTH_MINUTES: {review.minLengthMinutes}
Encode Input: {encodeInputTitle?.fileName || '-'}
Audio Auswahl: {review.selectors?.audio?.mode || '-'}
@@ -1090,7 +1156,8 @@ export default function MediaInfoReviewPanel({ title, selectedAudioTrackIds, selectedSubtitleTrackIds, - commandOutputPath + commandOutputPath, + presetOverride: effectivePresetOverride }); return (
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index f3d655c..c72b60d 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -226,6 +226,7 @@ export default function PipelineStatusCard({ const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed); const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase(); const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip); + const jobMediaProfile = String(pipeline?.context?.mediaProfile || '').trim().toLowerCase() || null; const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null); const [selectedPlaylistId, setSelectedPlaylistId] = useState(null); const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({}); @@ -236,16 +237,19 @@ export default function PipelineStatusCard({ // Unified ordered lists: [{type: 'script'|'chain', id: number}] const [preEncodeItems, setPreEncodeItems] = useState([]); const [postEncodeItems, setPostEncodeItems] = useState([]); + const [userPresets, setUserPresets] = useState([]); + const [selectedUserPresetId, setSelectedUserPresetId] = useState(null); useEffect(() => { let cancelled = false; const loadSettings = async () => { try { - const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse] = await Promise.allSettled([ + const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse, userPresetsResponse] = await Promise.allSettled([ api.getSettings(), api.getHandBrakePresets(), api.getScripts(), - api.getScriptChains() + api.getScriptChains(), + api.getUserPresets() ]); if (!cancelled) { const categories = settingsResponse.status === 'fulfilled' @@ -269,6 +273,10 @@ export default function PipelineStatusCard({ ? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []) : []; setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name }))); + const allUserPresets = userPresetsResponse.status === 'fulfilled' + ? (Array.isArray(userPresetsResponse.value?.presets) ? userPresetsResponse.value.presets : []) + : []; + setUserPresets(allUserPresets); } } catch (_error) { if (!cancelled) { @@ -276,6 +284,7 @@ export default function PipelineStatusCard({ setPresetDisplayMap({}); setScriptCatalog([]); setChainCatalog([]); + setUserPresets([]); } } }; @@ -298,6 +307,7 @@ export default function PipelineStatusCard({ ...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })), ...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id })) ]); + setSelectedUserPresetId(null); }, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]); useEffect(() => { @@ -323,6 +333,14 @@ export default function PipelineStatusCard({ const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired); const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId)); const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle; + + // Filter user presets by job media profile ('all' presets always shown) + const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => { + if (!jobMediaProfile) { + return true; + } + return p.mediaType === 'all' || p.mediaType === jobMediaProfile; + }); const canStartReadyJob = isPreRipReview ? Boolean(retryJobId) : Boolean(retryJobId && encodeInputPath); @@ -575,7 +593,8 @@ export default function PipelineStatusCard({ selectedPostEncodeScriptIds: selectedPostScriptIds, selectedPreEncodeScriptIds: selectedPreScriptIds, selectedPostEncodeChainIds: selectedPostChainIds, - selectedPreEncodeChainIds: selectedPreChainIds + selectedPreEncodeChainIds: selectedPreChainIds, + selectedUserPresetId: selectedUserPresetId || null }); }} loading={busy} @@ -786,6 +805,9 @@ export default function PipelineStatusCard({ availableChains={chainCatalog} preEncodeItems={preEncodeItems} postEncodeItems={postEncodeItems} + userPresets={filteredUserPresets} + selectedUserPresetId={selectedUserPresetId} + onUserPresetChange={(presetId) => setSelectedUserPresetId(presetId)} allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked} onAddPreEncodeItem={(itemType) => { setPreEncodeItems((prev) => { diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 6c5c2d8..33bd516 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -792,6 +792,7 @@ export default function DashboardPage({ selectedPreEncodeScriptIds: startOptions.selectedPreEncodeScriptIds ?? [], selectedPostEncodeChainIds: startOptions.selectedPostEncodeChainIds ?? [], selectedPreEncodeChainIds: startOptions.selectedPreEncodeChainIds ?? [], + selectedUserPresetId: startOptions.selectedUserPresetId ?? null, skipPipelineStateUpdate: true }); } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 98c9329..f822757 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -156,6 +156,21 @@ export default function SettingsPage() { const [chainEditorErrors, setChainEditorErrors] = useState({}); const [chainDragSource, setChainDragSource] = useState(null); + // User presets state + const [userPresets, setUserPresets] = useState([]); + const [userPresetsLoading, setUserPresetsLoading] = useState(false); + const [userPresetSaving, setUserPresetSaving] = useState(false); + const [userPresetEditor, setUserPresetEditor] = useState({ + open: false, + id: null, + name: '', + mediaType: 'all', + handbrakePreset: '', + extraArgs: '', + description: '' + }); + const [userPresetErrors, setUserPresetErrors] = useState({}); + const toastRef = useRef(null); const loadScripts = async ({ silent = false } = {}) => { @@ -195,6 +210,91 @@ export default function SettingsPage() { } }; + const loadUserPresets = async ({ silent = false } = {}) => { + if (!silent) { + setUserPresetsLoading(true); + } + try { + const response = await api.getUserPresets(); + setUserPresets(Array.isArray(response?.presets) ? response.presets : []); + } catch (error) { + if (!silent) { + toastRef.current?.show({ severity: 'error', summary: 'User-Presets', detail: error.message }); + } + } finally { + if (!silent) { + setUserPresetsLoading(false); + } + } + }; + + const openNewUserPreset = () => { + setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' }); + setUserPresetErrors({}); + }; + + const openEditUserPreset = (preset) => { + setUserPresetEditor({ + open: true, + id: preset.id, + name: preset.name || '', + mediaType: preset.mediaType || 'all', + handbrakePreset: preset.handbrakePreset || '', + extraArgs: preset.extraArgs || '', + description: preset.description || '' + }); + setUserPresetErrors({}); + }; + + const closeUserPresetEditor = () => { + setUserPresetEditor((prev) => ({ ...prev, open: false })); + setUserPresetErrors({}); + }; + + const handleSaveUserPreset = async () => { + const errors = {}; + if (!userPresetEditor.name.trim()) { + errors.name = 'Name ist erforderlich.'; + } + if (Object.keys(errors).length > 0) { + setUserPresetErrors(errors); + return; + } + setUserPresetSaving(true); + try { + const payload = { + name: userPresetEditor.name.trim(), + mediaType: userPresetEditor.mediaType, + handbrakePreset: userPresetEditor.handbrakePreset.trim(), + extraArgs: userPresetEditor.extraArgs.trim(), + description: userPresetEditor.description.trim() + }; + if (userPresetEditor.id) { + await api.updateUserPreset(userPresetEditor.id, payload); + toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset aktualisiert.' }); + } else { + await api.createUserPreset(payload); + toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset erstellt.' }); + } + closeUserPresetEditor(); + await loadUserPresets({ silent: true }); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Preset speichern', detail: error.message }); + } finally { + setUserPresetSaving(false); + } + }; + + const handleDeleteUserPreset = async (presetId) => { + try { + await api.deleteUserPreset(presetId); + toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset gelöscht.' }); + await loadUserPresets({ silent: true }); + } catch (error) { + toastRef.current?.show({ severity: 'error', summary: 'Preset löschen', detail: error.message }); + } + }; + const load = async () => { setLoading(true); try { @@ -250,6 +350,7 @@ export default function SettingsPage() { useEffect(() => { load(); + loadUserPresets(); }, []); const dirtyKeys = useMemo(() => { @@ -1379,6 +1480,173 @@ export default function SettingsPage() { + +
+
+ + + Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen. + Sie sind medienbezogen (Blu-ray, DVD, Sonstiges oder Universell) und können vor dem Encode + in der Mediainfo-Prüfung ausgewählt werden. Kein Preset gewählt = Fallback aus Einstellungen. + + + {userPresetsLoading ? ( +

Lade Presets ...

+ ) : userPresets.length === 0 ? ( +

Keine Presets vorhanden. Lege ein neues Preset an.

+ ) : ( +
+ {userPresets.map((preset) => ( +
+
+
+ #{preset.id} – {preset.name} + + {preset.mediaType === 'bluray' ? 'Blu-ray' + : preset.mediaType === 'dvd' ? 'DVD' + : preset.mediaType === 'other' ? 'Sonstiges' + : 'Universell'} + +
+ {preset.description && {preset.description}} +
+ {preset.handbrakePreset + ? Preset: {preset.handbrakePreset} + : (kein Preset-Name)} + {preset.extraArgs && ( + Args: {preset.extraArgs} + )} +
+
+
+
+
+ ))} +
+ )} + + +
+
+ + setUserPresetEditor((prev) => ({ ...prev, name: e.target.value }))} + placeholder="z.B. Blu-ray HQ" + style={{ width: '100%' }} + /> + {userPresetErrors.name && {userPresetErrors.name}} +
+ +
+ + +
+ +
+ + setUserPresetEditor((prev) => ({ ...prev, handbrakePreset: e.target.value }))} + placeholder="z.B. H.264 MKV 1080p30 (leer = kein Preset)" + style={{ width: '100%' }} + /> +
+ +
+ + setUserPresetEditor((prev) => ({ ...prev, extraArgs: e.target.value }))} + placeholder="z.B. -q 22 --encoder x264" + style={{ width: '100%' }} + /> +
+ +
+ + setUserPresetEditor((prev) => ({ ...prev, description: e.target.value }))} + rows={3} + autoResize + placeholder="Kurzbeschreibung für dieses Preset" + style={{ width: '100%' }} + /> +
+ +
+
+
+
+
+ diff --git a/install-dev.sh b/install-dev.sh index 8412f5e..0e833ce 100755 --- a/install-dev.sh +++ b/install-dev.sh @@ -17,6 +17,9 @@ # --no-handbrake HandBrake-Installation überspringen # --build-handbrake HandBrake aus Quellcode mit NVDEC-Unterstützung bauen # --handbrake-version HandBrake-Version für Source-Build (Standard: 1.9.0) +# --handbrake-update-policy +# Bei NVDEC-Self-Build: bei neuer Git-Release behalten, +# nachfragen oder direkt neu bauen (Standard: keep) # --no-nginx Nginx-Einrichtung überspringen (Frontend läuft dann auf Port 5173) # --reinstall Vorhandene Installation ersetzen (Daten bleiben erhalten) # -h, --help Diese Hilfe anzeigen @@ -44,10 +47,14 @@ FRONTEND_HOST="" # wird automatisch ermittelt, wenn leer SKIP_MAKEMKV=false SKIP_HANDBRAKE=false BUILD_HANDBRAKE_NVDEC=false +HANDBRAKE_MODE_SELECTED=false HANDBRAKE_VERSION="1.9.0" +HANDBRAKE_UPDATE_POLICY="keep" +HANDBRAKE_UPDATE_POLICY_SELECTED=false SKIP_NGINX=false REINSTALL=false SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HANDBRAKE_SELFBUILD_MARKER="/usr/local/share/ripster/handbrake-selfbuild.env" # --- Argumente parsen --------------------------------------------------------- while [[ $# -gt 0 ]]; do @@ -58,8 +65,14 @@ while [[ $# -gt 0 ]]; do --host) FRONTEND_HOST="$2"; shift 2 ;; --no-makemkv) SKIP_MAKEMKV=true; shift ;; --no-handbrake) SKIP_HANDBRAKE=true; shift ;; - --build-handbrake) BUILD_HANDBRAKE_NVDEC=true; shift ;; + --build-handbrake) BUILD_HANDBRAKE_NVDEC=true; HANDBRAKE_MODE_SELECTED=true; shift ;; --handbrake-version) HANDBRAKE_VERSION="$2"; shift 2 ;; + --handbrake-update-policy) + case "$2" in + keep|prompt|build) HANDBRAKE_UPDATE_POLICY="$2"; HANDBRAKE_UPDATE_POLICY_SELECTED=true ;; + *) fatal "Ungültige --handbrake-update-policy: $2 (erlaubt: keep|prompt|build)" ;; + esac + shift 2 ;; --no-nginx) SKIP_NGINX=true; shift ;; --reinstall) REINSTALL=true; shift ;; -h|--help) @@ -183,46 +196,99 @@ install_makemkv() { warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053" } -remove_all_handbrake() { - info "Entferne alle vorhandenen HandBrake-Installationen..." +handbrake_has_nvdec() { + command_exists HandBrakeCLI || return 1 + HandBrakeCLI --help 2>&1 | grep -qi "nvdec" +} + +handbrake_installed_version() { + command_exists HandBrakeCLI || return 1 + HandBrakeCLI --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+){1,3}' | head -1 +} + +handbrake_latest_git_version() { + local latest="" + latest=$(curl -fsSL --max-time 10 "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" 2>/dev/null \ + | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | head -1 \ + | sed -E 's/.*"([^"]+)".*/\1/' \ + | sed 's/^v//') + [[ -n "$latest" ]] || return 1 + [[ "$latest" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]] || return 1 + printf '%s\n' "$latest" +} + +handbrake_is_self_built() { + local hb_path="${1:-$(command -v HandBrakeCLI 2>/dev/null || true)}" + local resolved_path="" + [[ -n "$hb_path" ]] || return 1 + [[ "$hb_path" == "/usr/local/bin/HandBrakeCLI" ]] || return 1 + [[ -f "$HANDBRAKE_SELFBUILD_MARKER" ]] && return 0 + resolved_path=$(readlink -f "$hb_path" 2>/dev/null || true) + dpkg -S "$hb_path" >/dev/null 2>&1 && return 1 + [[ -n "$resolved_path" ]] && dpkg -S "$resolved_path" >/dev/null 2>&1 && return 1 + return 0 +} + +remove_non_selfbuilt_handbrake() { + info "Entferne nicht-selbst-gebaute HandBrake-Installationen..." apt-get remove -y handbrake-cli handbrake 2>/dev/null || true snap remove handbrake-cli 2>/dev/null || true + rm -f /usr/bin/HandBrakeCLI \ - /usr/local/bin/HandBrakeCLI \ /snap/bin/handbrake-cli \ /snap/bin/HandBrakeCLI - while true; do - local found - found=$(command -v HandBrakeCLI 2>/dev/null || true) - [[ -z "$found" ]] && break - warn "Entferne: $found" - rm -f "$found" - done + + if [[ -e /usr/local/bin/HandBrakeCLI ]] && ! handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then + warn "Entferne fremdes /usr/local/bin/HandBrakeCLI" + rm -f /usr/local/bin/HandBrakeCLI + fi + + hash -r 2>/dev/null || true + ok "Bereinigung abgeschlossen" +} + +remove_selfbuilt_handbrake() { + if [[ -e /usr/local/bin/HandBrakeCLI ]] && handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then + warn "Entferne selbst gebautes /usr/local/bin/HandBrakeCLI" + rm -f /usr/local/bin/HandBrakeCLI + fi + rm -f "$HANDBRAKE_SELFBUILD_MARKER" hash -r 2>/dev/null || true - ok "Alte HandBrake-Installation(en) entfernt" } build_handbrake_nvdec() { header "HandBrake ${HANDBRAKE_VERSION} mit NVDEC aus Quellcode bauen" - local tmp_dir - tmp_dir=$(mktemp -d) + local cache_dir="/var/cache/ripster/handbrake" local src_url="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2" - local tarball="${tmp_dir}/handbrake-src.tar.bz2" + local tarball="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2" + local src_dir="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}" - # Alte Installationen vollständig entfernen - remove_all_handbrake + if [[ -t 0 && -d "$cache_dir" ]]; then + local clear_cache_answer="" + warn "Build-Cache gefunden: $cache_dir" + warn "Wenn du ihn löschst, startet der Source-Build wieder komplett von vorne." + read -r -p "Build-Cache jetzt löschen (sudo rm -rf $cache_dir)? [y/N] " clear_cache_answer + case "${clear_cache_answer,,}" in + y|yes|j|ja) + rm -rf "$cache_dir" + info "Build-Cache gelöscht." + ;; + esac + fi + mkdir -p "$cache_dir" # Build-Abhängigkeiten info "Installiere Build-Abhängigkeiten..." apt-get install -y \ - autoconf automake build-essential cmake git \ + autoconf automake build-essential clang cmake git \ libass-dev libbz2-dev libdvdnav-dev libdvdread-dev \ libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \ libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \ - libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin \ + libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin libva-dev \ libturbojpeg0-dev libvorbis-dev libvpx-dev libx264-dev libxml2-dev \ - m4 meson nasm ninja-build patch pkg-config python3 tar zlib1g-dev \ + m4 meson nasm ninja-build patch pkg-config python3 tar yasm zlib1g-dev \ >/dev/null # CUDA Toolkit für NVDEC-Header @@ -242,35 +308,109 @@ build_handbrake_nvdec() { fi ok "Build-Abhängigkeiten installiert" - # Quellcode herunterladen - info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..." - curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \ - wget -q "$src_url" -O "$tarball" || \ - fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})" + if [[ ! -d "$src_dir" ]]; then + if [[ ! -f "$tarball" ]]; then + info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..." + curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \ + wget -q "$src_url" -O "$tarball" || \ + fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})" + else + info "Nutze vorhandenes HandBrake-Source-Archiv: $tarball" + fi - info "Entpacke Quellcode..." - tar xjf "$tarball" -C "$tmp_dir" - local src_dir="${tmp_dir}/HandBrake-${HANDBRAKE_VERSION}" - [[ -d "$src_dir" ]] || src_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1) - [[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $tmp_dir" + info "Entpacke Quellcode..." + tar xjf "$tarball" -C "$cache_dir" + [[ -d "$src_dir" ]] || src_dir=$(find "$cache_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1) + [[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $cache_dir" + else + info "Nutze vorhandenen HandBrake-Source-Baum: $src_dir" + fi cd "$src_dir" - info "Konfiguriere HandBrake mit NVDEC..." - ./configure --launch-jobs="$(nproc)" --enable-nvdec --prefix=/usr/local 2>&1 | tail -10 + local configure_log="${src_dir}/build/configure-ripster.log" + local configure_stamp="${src_dir}/build/.ripster-config" + local configure_args="--enable-nvdec --disable-gtk --prefix=/usr/local" + local need_configure="false" + local configure_cmd=( + ./configure + --launch-jobs="$(nproc)" + --enable-nvdec + --disable-gtk + --prefix=/usr/local + ) - info "Baue HandBrake ($(nproc) Threads – bitte warten)..." - make --directory=build -j"$(nproc)" + if [[ ! -f "$src_dir/build/GNUmakefile" ]]; then + need_configure="true" + elif [[ ! -f "$configure_stamp" ]]; then + need_configure="true" + elif ! grep -qx "args=${configure_args}" "$configure_stamp"; then + need_configure="true" + fi - info "Installiere HandBrake nach /usr/local/bin..." - make --directory=build install + if [[ "$need_configure" == "true" ]]; then + if [[ -d "$src_dir/build" ]]; then + configure_cmd=(./configure --force "${configure_cmd[@]:1}") + fi + + if [[ -f "$src_dir/build/GNUmakefile" ]]; then + info "Vorhandener Build gefunden – aktualisiere Konfiguration (CLI-only)." + else + info "Konfiguriere HandBrake mit NVDEC (CLI-only)..." + fi + + if ! "${configure_cmd[@]}" >"$configure_log" 2>&1; then + tail -n 50 "$configure_log" >&2 || true + fatal "HandBrake-Konfiguration fehlgeschlagen. Vollständiges Log: $configure_log" + fi + tail -n 10 "$configure_log" + + mkdir -p "${src_dir}/build" + cat > "$configure_stamp" </dev/null || true + if ! command_exists HandBrakeCLI; then + warn "HandBrakeCLI nach direkter Installation nicht gefunden – setze Build fort." + make --directory=build -j"$(nproc)" + make --directory=build install + fi + else + info "Baue HandBrake ($(nproc) Threads – bitte warten)..." + make --directory=build -j"$(nproc)" + info "Installiere HandBrake nach /usr/local/bin..." + make --directory=build install + fi cd / - rm -rf "$tmp_dir" + hash -r 2>/dev/null || true if command_exists HandBrakeCLI; then local ver ver=$(HandBrakeCLI --version 2>&1 | head -1) + handbrake_has_nvdec || fatal "HandBrakeCLI ist installiert, aber ohne NVDEC-Unterstützung." + + mkdir -p "$(dirname "$HANDBRAKE_SELFBUILD_MARKER")" + cat > "$HANDBRAKE_SELFBUILD_MARKER" </dev/null | grep -q libnvcuvid; then ok "libnvcuvid gefunden – NVDEC ist zur Laufzeit verfügbar." @@ -292,80 +432,108 @@ has_nvidia_gpu() { install_handbrake() { header "HandBrake CLI installieren" - # NVIDIA-GPU vorhanden? → immer NVDEC-Build erzwingen - if has_nvidia_gpu; then - info "NVIDIA-GPU erkannt – HandBrake wird mit NVDEC aus Quellcode gebaut." - BUILD_HANDBRAKE_NVDEC=true + local hb_path + local current_mode="none" + hb_path=$(command -v HandBrakeCLI 2>/dev/null || true) + if [[ -n "$hb_path" ]]; then + if handbrake_has_nvdec; then + current_mode="nvdec" + else + current_mode="standard" + fi fi - # --build-handbrake oder NVIDIA erkannt: aus Quellcode mit NVDEC bauen if [[ "$BUILD_HANDBRAKE_NVDEC" == true ]]; then + info "Installmodus: Source-Build mit NVDEC-Support" + if has_nvidia_gpu; then + info "NVIDIA-GPU erkannt – NVDEC-Build wird verwendet." + fi + + if handbrake_has_nvdec; then + if handbrake_is_self_built "$hb_path"; then + local installed_ver latest_ver answer + installed_ver=$(handbrake_installed_version || true) + latest_ver=$(handbrake_latest_git_version || true) + + if [[ -n "$installed_ver" && -n "$latest_ver" ]] && dpkg --compare-versions "$latest_ver" gt "$installed_ver"; then + case "$HANDBRAKE_UPDATE_POLICY" in + keep) + warn "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – behalte aktuelle Installation." + return + ;; + prompt) + if [[ -t 0 ]]; then + read -r -p "Neue HandBrake-Version ${latest_ver} verfügbar (installiert ${installed_ver}). Neu bauen? [y/N] " answer + case "${answer,,}" in + y|yes|j|ja) + info "Aktualisiere auf HandBrake ${latest_ver}." + HANDBRAKE_VERSION="$latest_ver" + ;; + *) + info "Behalte bestehende Installation (${installed_ver})." + return + ;; + esac + else + warn "Neuere HandBrake-Version verfügbar (${latest_ver}), aber kein TTY für Rückfrage – behalte aktuelle Installation." + return + fi + ;; + build) + info "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – aktualisiere." + HANDBRAKE_VERSION="$latest_ver" + ;; + esac + else + ok "Selbst gebautes HandBrakeCLI mit NVDEC bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)" + return + fi + fi + warn "HandBrakeCLI mit NVDEC gefunden (${hb_path}), aber nicht als Selbst-Build erkannt." + fi + + if [[ -n "$hb_path" ]] && ! handbrake_has_nvdec; then + warn "HandBrakeCLI ohne NVDEC gefunden (${hb_path}) – wird ersetzt durch Selbst-Build." + elif [[ -z "$hb_path" ]]; then + info "Kein HandBrakeCLI gefunden – baue aus Quellcode." + fi + + remove_non_selfbuilt_handbrake build_handbrake_nvdec return fi - # Bereits installiert → nichts tun + info "Installmodus: Standard (APT, ohne NVDEC-Zwang)" + if [[ "$current_mode" == "standard" ]] && ! handbrake_is_self_built "$hb_path"; then + ok "HandBrakeCLI bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)" + return + fi + + if [[ "$current_mode" == "nvdec" ]]; then + warn "Umschalten von NVDEC-Build auf Standard-Installation." + fi + + remove_selfbuilt_handbrake + remove_non_selfbuilt_handbrake + + info "Installiere HandBrakeCLI aus den Standard-Repositories..." + apt_update + apt-get install -y handbrake-cli >/dev/null + hash -r 2>/dev/null || true + if command_exists HandBrakeCLI; then - local ver - ver=$(HandBrakeCLI --version 2>&1 | head -1) - ok "HandBrakeCLI bereits installiert: ${ver}" + ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)" return fi - # Strategie 1: direkt aus den Distro-Repos (Ubuntu Universe / Debian) - info "Versuche HandBrake CLI aus den Standard-Repos..." - if apt-get install -y handbrake-cli 2>/dev/null; then - ok "HandBrakeCLI installiert (Standard-Repos)" + if command_exists handbrake-cli; then + ln -sf "$(command -v handbrake-cli)" /usr/local/bin/HandBrakeCLI + hash -r 2>/dev/null || true + ok "HandBrakeCLI Alias angelegt auf: $(command -v handbrake-cli)" return fi - case "$ID" in - ubuntu) - local codename="${VERSION_CODENAME:-jammy}" - local ppa_sources="/etc/apt/sources.list.d/handbrake.list" - local ppa_key="/etc/apt/keyrings/handbrake.gpg" - - info "Füge HandBrake PPA manuell hinzu (${codename})..." - mkdir -p /etc/apt/keyrings - - if curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x8771ADB0816950D8" \ - | gpg --dearmor -o "$ppa_key" 2>/dev/null; then - cat > "$ppa_sources" </dev/null && \ - { ok "HandBrakeCLI installiert (PPA)"; return; } || \ - { warn "PPA-Installation fehlgeschlagen, räume auf..."; - rm -f "$ppa_key" "$ppa_sources"; } - else - warn "PPA-Key konnte nicht geladen werden." - fi - - if command_exists snap; then - info "Versuche HandBrake via snap..." - if snap install handbrake-cli 2>/dev/null; then - ok "HandBrakeCLI installiert (snap)" - return - fi - fi - ;; - - debian) - info "Versuche HandBrake CLI über Debian Backports..." - if ! find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "backports" {} \; 2>/dev/null | grep -q .; then - echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" \ - > /etc/apt/sources.list.d/backports.list - apt_update - fi - apt-get install -y -t "${VERSION_CODENAME}-backports" handbrake-cli 2>/dev/null && \ - { ok "HandBrakeCLI installiert (Backports)"; return; } - ;; - esac - - warn "HandBrake CLI konnte nicht automatisch installiert werden." - warn "Für einen NVDEC-Build: sudo bash install-dev.sh --no-makemkv --no-nginx --build-handbrake" - warn "Oder manuell: https://handbrake.fr/downloads2.php" + fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden." } # --- apt-Hilfsfunktionen ------------------------------------------------------ diff --git a/install.sh b/install.sh index 95ce09c..868fecc 100755 --- a/install.sh +++ b/install.sh @@ -20,8 +20,6 @@ # --host Hostname/IP für die Weboberfläche (Standard: Maschinen-IP) # --no-makemkv MakeMKV-Installation überspringen # --no-handbrake HandBrake-Installation überspringen -# --build-handbrake HandBrake aus Quellcode mit NVDEC-Unterstützung bauen -# --handbrake-version HandBrake-Version für Source-Build (Standard: 1.9.0) # --no-nginx Nginx-Einrichtung überspringen # --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten) # -h, --help Diese Hilfe anzeigen @@ -29,6 +27,8 @@ set -euo pipefail REPO_URL="https://github.com/Mboehmlaender/ripster.git" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)" +BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI" # --- Farben ------------------------------------------------------------------- RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' @@ -51,8 +51,7 @@ BACKEND_PORT="3001" FRONTEND_HOST="" SKIP_MAKEMKV=false SKIP_HANDBRAKE=false -BUILD_HANDBRAKE_NVDEC=false -HANDBRAKE_VERSION="1.9.0" +HANDBRAKE_INSTALL_MODE="" SKIP_NGINX=false REINSTALL=false @@ -66,8 +65,6 @@ while [[ $# -gt 0 ]]; do --host) FRONTEND_HOST="$2"; shift 2 ;; --no-makemkv) SKIP_MAKEMKV=true; shift ;; --no-handbrake) SKIP_HANDBRAKE=true; shift ;; - --build-handbrake) BUILD_HANDBRAKE_NVDEC=true; shift ;; - --handbrake-version) HANDBRAKE_VERSION="$2"; shift 2 ;; --no-nginx) SKIP_NGINX=true; shift ;; --reinstall) REINSTALL=true; shift ;; -h|--help) @@ -188,201 +185,81 @@ install_makemkv() { warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053" } -remove_all_handbrake() { - info "Entferne alle vorhandenen HandBrake-Installationen..." - # apt - apt-get remove -y handbrake-cli handbrake 2>/dev/null || true - # snap - snap remove handbrake-cli 2>/dev/null || true - # bekannte Binär-Pfade - rm -f /usr/bin/HandBrakeCLI \ - /usr/local/bin/HandBrakeCLI \ - /snap/bin/handbrake-cli \ - /snap/bin/HandBrakeCLI - # alle weiteren Fundstellen über PATH - while true; do - local found - found=$(command -v HandBrakeCLI 2>/dev/null || true) - [[ -z "$found" ]] && break - warn "Entferne: $found" - rm -f "$found" - done - hash -r 2>/dev/null || true - ok "Alte HandBrake-Installation(en) entfernt" +select_handbrake_mode() { + [[ "$SKIP_HANDBRAKE" == true ]] && return + + local mode_answer="" + echo "" + echo "Install HandBrake:" + echo "" + echo "1. Standard version (apt install handbrake-cli)" + echo "2. GPU version with NVDEC (use bundled binary)" + + if [[ -t 0 ]]; then + read -r -p "Select option [1/2]: " mode_answer + elif [[ -r /dev/tty ]]; then + read -r -p "Select option [1/2]: " mode_answer /dev/null - - # CUDA Toolkit für NVDEC-Header - info "Installiere CUDA Toolkit (für NVDEC-Header)..." - if ! dpkg -l 2>/dev/null | grep -q '^ii.*nvidia-cuda-toolkit'; then - apt-get install -y nvidia-cuda-toolkit >/dev/null 2>&1 || { - warn "nvidia-cuda-toolkit nicht verfügbar – versuche Fallback-Header..." - local cuda_keyring="/tmp/cuda-keyring.deb" - local ubuntu_ver="${VERSION_ID//./}" - curl -fsSL "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${ubuntu_ver}/x86_64/cuda-keyring_1.1-1_all.deb" \ - -o "$cuda_keyring" 2>/dev/null && \ - dpkg -i "$cuda_keyring" 2>/dev/null && \ - apt-get update -qq && \ - apt-get install -y cuda-cudart-dev-12-8 >/dev/null 2>&1 || \ - warn "CUDA-Header konnten nicht installiert werden – NVDEC wird möglicherweise nicht verfügbar sein." - } - fi - ok "Build-Abhängigkeiten installiert" - - # Quellcode herunterladen - info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..." - curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \ - wget -q "$src_url" -O "$tarball" || \ - fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})" - - info "Entpacke Quellcode..." - tar xjf "$tarball" -C "$tmp_dir" - local src_dir="${tmp_dir}/HandBrake-${HANDBRAKE_VERSION}" - [[ -d "$src_dir" ]] || src_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1) - [[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $tmp_dir" - - cd "$src_dir" - - # Konfigurieren mit NVDEC - info "Konfiguriere HandBrake mit NVDEC..." - ./configure --launch-jobs="$(nproc)" --enable-nvdec --prefix=/usr/local 2>&1 | tail -10 - - # Bauen (dauert je nach Hardware 10–30 Min) - info "Baue HandBrake ($(nproc) Threads – bitte warten)..." - make --directory=build -j"$(nproc)" - - info "Installiere HandBrake nach /usr/local/bin..." - make --directory=build install - - cd / - rm -rf "$tmp_dir" +install_handbrake_standard() { + info "Installiere HandBrakeCLI aus den Standard-Repositories..." + info "Aktualisiere Paketlisten..." + apt_update + apt-get install -y handbrake-cli + hash -r 2>/dev/null || true if command_exists HandBrakeCLI; then - local ver - ver=$(HandBrakeCLI --version 2>&1 | head -1) - ok "HandBrakeCLI mit NVDEC installiert: ${ver}" - # NVDEC-Verfügbarkeit zur Laufzeit hängt vom NVIDIA-Treiber ab. - # Prüfe ob libnvcuvid vorhanden: - if ldconfig -p 2>/dev/null | grep -q libnvcuvid; then - ok "libnvcuvid gefunden – NVDEC ist zur Laufzeit verfügbar." - else - warn "libnvcuvid NICHT gefunden. NVDEC benötigt den installierten NVIDIA-Treiber (nvidia-driver-XXX)." - warn "Stelle sicher, dass der NVIDIA-Treiber auf dem System installiert ist." - fi - else - fatal "HandBrakeCLI nach dem Build nicht gefunden – Build fehlgeschlagen." + ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)" + return fi + + if command_exists handbrake-cli; then + ok "handbrake-cli installiert: $(handbrake-cli --version 2>&1 | head -1)" + return + fi + + fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden." } -handbrake_has_nvdec() { - command_exists HandBrakeCLI || return 1 - HandBrakeCLI --help 2>&1 | grep -qi "nvdec" +install_handbrake_gpu_bundled() { + info "Installiere gebündeltes HandBrakeCLI mit NVDEC..." + + if [[ ! -f "$BUNDLED_HANDBRAKE_CLI" ]]; then + fatal "Bundled Binary fehlt: ./bin/HandBrakeCLI (aufgelöst zu: $BUNDLED_HANDBRAKE_CLI)" + fi + + install -m 0755 "$BUNDLED_HANDBRAKE_CLI" /usr/local/bin/HandBrakeCLI + hash -r 2>/dev/null || true + + ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI" + if command_exists HandBrakeCLI; then + ok "HandBrakeCLI Version: $(HandBrakeCLI --version 2>&1 | head -1)" + fi } install_handbrake() { header "HandBrake CLI installieren" - # Bereits installiert MIT NVDEC → nichts tun - if handbrake_has_nvdec; then - ok "HandBrakeCLI mit NVDEC bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)" - return + if [[ -z "$HANDBRAKE_INSTALL_MODE" ]]; then + HANDBRAKE_INSTALL_MODE="standard" fi - # Installiert OHNE NVDEC → entfernen und NVDEC-Build erzwingen - if command_exists HandBrakeCLI; then - warn "HandBrakeCLI ohne NVDEC gefunden – wird ersetzt durch NVDEC-Build." - BUILD_HANDBRAKE_NVDEC=true - fi - - # --build-handbrake-Flag oder kein NVDEC in vorhandener Installation - if [[ "$BUILD_HANDBRAKE_NVDEC" == true ]]; then - build_handbrake_nvdec - return - fi - - # Strategie 1: direkt aus den Distro-Repos (Ubuntu Universe / Debian) - info "Versuche HandBrake CLI aus den Standard-Repos..." - if apt-get install -y handbrake-cli 2>/dev/null; then - # Nach apt-Install: NVDEC prüfen – falls nicht, sofort NVDEC-Build - if handbrake_has_nvdec; then - ok "HandBrakeCLI installiert (Standard-Repos, mit NVDEC)" - else - warn "Installiertes HandBrakeCLI hat kein NVDEC – ersetze durch NVDEC-Build." - build_handbrake_nvdec - fi - return - fi - - case "$ID" in - ubuntu) - local codename="${VERSION_CODENAME:-jammy}" - local ppa_sources="/etc/apt/sources.list.d/handbrake.list" - local ppa_key="/etc/apt/keyrings/handbrake.gpg" - - info "Füge HandBrake PPA manuell hinzu (${codename})..." - mkdir -p /etc/apt/keyrings - - if curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x8771ADB0816950D8" \ - | gpg --dearmor -o "$ppa_key" 2>/dev/null; then - cat > "$ppa_sources" </dev/null && \ - { ok "HandBrakeCLI installiert (PPA)"; return; } || \ - { warn "PPA-Installation fehlgeschlagen, räume auf..."; - rm -f "$ppa_key" "$ppa_sources"; } - else - warn "PPA-Key konnte nicht geladen werden." - fi - - # Strategie 3 (Ubuntu): snap - if command_exists snap; then - info "Versuche HandBrake via snap..." - if snap install handbrake-cli 2>/dev/null; then - ok "HandBrakeCLI installiert (snap)" - return - fi - fi - ;; - - debian) - info "Versuche HandBrake CLI über Debian Backports..." - if ! find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "backports" {} \; 2>/dev/null | grep -q .; then - echo "deb http://deb.debian.org/debian ${VERSION_CODENAME}-backports main" \ - > /etc/apt/sources.list.d/backports.list - apt_update - fi - apt-get install -y -t "${VERSION_CODENAME}-backports" handbrake-cli 2>/dev/null && \ - { ok "HandBrakeCLI installiert (Backports)"; return; } - ;; + case "$HANDBRAKE_INSTALL_MODE" in + standard) install_handbrake_standard ;; + gpu) install_handbrake_gpu_bundled ;; + *) fatal "Unbekannter HandBrake-Modus: $HANDBRAKE_INSTALL_MODE" ;; esac - - warn "HandBrake CLI konnte nicht automatisch installiert werden." - warn "Für einen NVDEC-Build: sudo bash install.sh --no-makemkv --no-nginx --build-handbrake" - warn "Oder manuell: https://handbrake.fr/downloads2.php" } # --- apt-Hilfsfunktionen ------------------------------------------------------ @@ -463,6 +340,9 @@ EOF fi } +# --- HandBrake-Installmodus auswählen ---------------------------------------- +select_handbrake_mode + # --- Systemabhängigkeiten ----------------------------------------------------- header "Systemabhängigkeiten installieren" diff --git a/kill.sh b/kill.sh deleted file mode 100755 index be1a79c..0000000 --- a/kill.sh +++ /dev/null @@ -1,133 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PID_FILE="$ROOT_DIR/start.pid" -WAIT_SECONDS=8 - -declare -A PID_SET=() - -is_running() { - local pid="$1" - kill -0 "$pid" 2>/dev/null -} - -is_pid_under_root() { - local pid="$1" - local cwd - - cwd="$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)" - [[ -n "$cwd" && "$cwd" == "$ROOT_DIR"* ]] -} - -add_pid() { - local pid="$1" - [[ "$pid" =~ ^[0-9]+$ ]] || return 0 - (( pid > 1 )) || return 0 - (( pid == $$ )) && return 0 - is_running "$pid" || return 0 - - PID_SET["$pid"]=1 -} - -collect_descendants() { - local parent="$1" - local child - - while read -r child; do - [[ -n "$child" ]] || continue - add_pid "$child" - collect_descendants "$child" - done < <(pgrep -P "$parent" 2>/dev/null || true) -} - -collect_from_pid_file() { - local pid - - [[ -f "$PID_FILE" ]] || return 0 - pid="$(tr -d '[:space:]' < "$PID_FILE" || true)" - [[ "$pid" =~ ^[0-9]+$ ]] || return 0 - is_running "$pid" || return 0 - - add_pid "$pid" - collect_descendants "$pid" -} - -collect_by_process_scan() { - local pid cmd - - while read -r pid cmd; do - [[ -n "$pid" ]] || continue - is_pid_under_root "$pid" || continue - - case "$cmd" in - *concurrently*|*npm\ run\ dev*|*nodemon*|*vite*|*backend/src/index.js*) - add_pid "$pid" - collect_descendants "$pid" - ;; - esac - done < <(ps -eo pid=,cmd=) -} - -collect_all_targets() { - collect_from_pid_file - collect_by_process_scan -} - -terminate_pids() { - local signal="$1" - local pid - - for pid in "${!PID_SET[@]}"; do - is_running "$pid" || continue - kill "-$signal" "$pid" 2>/dev/null || true - done -} - -count_running_targets() { - local pid count=0 - - for pid in "${!PID_SET[@]}"; do - if is_running "$pid"; then - count=$((count + 1)) - fi - done - - echo "$count" -} - -main() { - local running_count elapsed - - collect_all_targets - - if [[ ${#PID_SET[@]} -eq 0 ]]; then - echo "Keine laufenden Ripster-Prozesse gefunden." - rm -f "$PID_FILE" - exit 0 - fi - - echo "Beende ${#PID_SET[@]} Ripster-Prozess(e) ..." - terminate_pids TERM - - elapsed=0 - while (( elapsed < WAIT_SECONDS )); do - running_count="$(count_running_targets)" - if [[ "$running_count" -eq 0 ]]; then - break - fi - sleep 1 - elapsed=$((elapsed + 1)) - done - - running_count="$(count_running_targets)" - if [[ "$running_count" -gt 0 ]]; then - echo "Noch $running_count Prozess(e) aktiv, sende SIGKILL ..." - terminate_pids KILL - fi - - rm -f "$PID_FILE" - echo "Ripster-Prozesse wurden beendet." -} - -main "$@"