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 (Lade Presets ...
+ ) : userPresets.length === 0 ? ( +Keine Presets vorhanden. Lege ein neues Preset an.
+ ) : ( +