Initial commit mit MkDocs-Dokumentation
This commit is contained in:
66
.github/workflows/docs.yml
vendored
Normal file
66
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Deploy Docs to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- 'mkdocs.yml'
|
||||||
|
- '.github/workflows/docs.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Documentation
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Für git-dates Plugin
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
|
||||||
|
- name: Cache pip packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
key: mkdocs-material-${{ runner.os }}-${{ hashFiles('requirements-docs.txt') }}
|
||||||
|
path: ~/.cache/pip
|
||||||
|
restore-keys: |
|
||||||
|
mkdocs-material-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install MkDocs and dependencies
|
||||||
|
run: pip install -r requirements-docs.txt
|
||||||
|
|
||||||
|
- name: Build MkDocs site
|
||||||
|
run: mkdocs build --strict --verbose
|
||||||
|
|
||||||
|
- name: Upload Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ----------------------------
|
||||||
|
# Dependencies
|
||||||
|
# ----------------------------
|
||||||
|
node_modules/
|
||||||
|
backend/node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Build artifacts / caches
|
||||||
|
# ----------------------------
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
backend/dist/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Runtime state / PIDs / temp
|
||||||
|
# ----------------------------
|
||||||
|
start.pid
|
||||||
|
*.pid
|
||||||
|
*.pid.lock
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Databases and generated data
|
||||||
|
# ----------------------------
|
||||||
|
backend/data/
|
||||||
|
!backend/data/.gitkeep
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Logs/Debug
|
||||||
|
# ----------------------------
|
||||||
|
backend/logs/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
job*-hbscan.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
debug/
|
||||||
|
# ----------------------------
|
||||||
|
# Env / secrets (keep examples)
|
||||||
|
# ----------------------------
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
backend/.env
|
||||||
|
backend/.env.*
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.sample
|
||||||
|
!backend/.env.example
|
||||||
|
!backend/.env.sample
|
||||||
|
!frontend/.env.example
|
||||||
|
!frontend/.env.sample
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# IDE / OS files
|
||||||
|
# ----------------------------
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
168
README.md
168
README.md
@@ -1 +1,167 @@
|
|||||||
# ripster
|
# Ripster
|
||||||
|
|
||||||
|
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit MakeMKV + HandBrake inklusive Metadaten-Auswahl, Titel-/Spurprüfung und Job-Historie.
|
||||||
|
|
||||||
|
## Was Ripster kann
|
||||||
|
|
||||||
|
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
|
||||||
|
- Metadaten-Suche und Zuordnung über OMDb
|
||||||
|
- MakeMKV-Analyse und Rip (MKV oder Backup-Modus)
|
||||||
|
- HandBrake-Encode mit Preset + Extra-Args + Track-Override
|
||||||
|
- Manuelle Playlist-/Titel-Auswahl bei komplexen Blu-rays
|
||||||
|
- Historie mit Re-Encode, Löschfunktionen und Detailansicht
|
||||||
|
- Dateibasierte Logs (Backend + Job-Prozesslogs)
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- Backend: Node.js, Express, SQLite, WebSocket (`ws`)
|
||||||
|
- Frontend: React, Vite, PrimeReact
|
||||||
|
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Linux-System mit optischem Laufwerk (oder gemountete Quelle)
|
||||||
|
- Node.js `>= 20.19.0` (siehe [.nvmrc](.nvmrc))
|
||||||
|
- Installierte CLI-Tools im `PATH`:
|
||||||
|
- `makemkvcon`
|
||||||
|
- `HandBrakeCLI`
|
||||||
|
- `mediainfo`
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` erledigt:
|
||||||
|
|
||||||
|
1. Node-Version prüfen/umschalten (inkl. `nvm`/`node@20`-Fallback)
|
||||||
|
2. Dependencies installieren (Root, Backend, Frontend)
|
||||||
|
3. Dev-Umgebung starten (`backend` + `frontend`)
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:5173`
|
||||||
|
- Backend API: `http://localhost:3001/api`
|
||||||
|
|
||||||
|
Stoppen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./kill.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manueller Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm --prefix backend install
|
||||||
|
npm --prefix frontend install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzeln starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:backend
|
||||||
|
npm run dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend (ohne Dev-Mode):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### 1) UI-Settings (empfohlen)
|
||||||
|
|
||||||
|
Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert:
|
||||||
|
|
||||||
|
- Pfade: `raw_dir`, `movie_dir`, `log_dir`
|
||||||
|
- Tools: `makemkv_command`, `handbrake_command`, `mediainfo_command`
|
||||||
|
- Encode: `handbrake_preset`, `handbrake_extra_args`, `output_extension`, `filename_template`
|
||||||
|
- Laufwerk/Scan: `drive_mode`, `drive_device`, Polling
|
||||||
|
- Benachrichtigungen: PushOver
|
||||||
|
|
||||||
|
### 2) Umgebungsvariablen
|
||||||
|
|
||||||
|
Backend (`backend/src/config.js`):
|
||||||
|
|
||||||
|
- `PORT` (Default: `3001`)
|
||||||
|
- `DB_PATH` (Default: `backend/data/ripster.db`)
|
||||||
|
- `LOG_DIR` (Fallback-Logpfad, Default: `backend/logs`)
|
||||||
|
- `CORS_ORIGIN` (Default: `*`)
|
||||||
|
- `LOG_LEVEL` (`debug|info|warn|error`, Default: `info`)
|
||||||
|
|
||||||
|
Frontend (Vite):
|
||||||
|
|
||||||
|
- `VITE_API_BASE` (Default: `/api`)
|
||||||
|
- `VITE_WS_URL` (optional, überschreibt automatische WS-URL)
|
||||||
|
- `VITE_PUBLIC_ORIGIN`, `VITE_ALLOWED_HOSTS`, `VITE_HMR_*` (Remote-Dev/HMR)
|
||||||
|
|
||||||
|
## Logs und Daten
|
||||||
|
|
||||||
|
Log-Ziel ist primär der in den Settings gepflegte `log_dir`.
|
||||||
|
|
||||||
|
- Backend-Logs: `<log_dir>/backend/backend-latest.log` und Tagesdateien
|
||||||
|
- Job-Logs: `<log_dir>/job-<id>.process.log`
|
||||||
|
- DB: `backend/data/ripster.db` (inkl. Job-/Settings-Daten)
|
||||||
|
|
||||||
|
Hinweis: Beim DB-Init wird das Schema gegen die Soll-Struktur abgeglichen und migriert.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
ripster/
|
||||||
|
backend/
|
||||||
|
src/
|
||||||
|
routes/
|
||||||
|
services/
|
||||||
|
db/
|
||||||
|
utils/
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
pages/
|
||||||
|
components/
|
||||||
|
api/
|
||||||
|
start.sh
|
||||||
|
kill.sh
|
||||||
|
deploy-ripster.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Überblick
|
||||||
|
|
||||||
|
- `GET /api/pipeline/state`
|
||||||
|
- `POST /api/pipeline/analyze`
|
||||||
|
- `POST /api/pipeline/start/:jobId`
|
||||||
|
- `POST /api/pipeline/confirm-encode/:jobId`
|
||||||
|
- `GET /api/history`
|
||||||
|
- `GET /api/history/:id`
|
||||||
|
- `GET /api/settings`
|
||||||
|
- `PUT /api/settings`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- WebSocket verbindet nicht:
|
||||||
|
- prüfen, ob Frontend über Vite-Proxy läuft (`/ws` -> Backend)
|
||||||
|
- bei Reverse-Proxy `VITE_PUBLIC_ORIGIN`/HMR korrekt setzen
|
||||||
|
- Keine Disc erkannt:
|
||||||
|
- `drive_mode=explicit` testen und `drive_device` setzen (z. B. `/dev/sr0`)
|
||||||
|
- HandBrake/MakeMKV Fehler:
|
||||||
|
- CLI-Binaries im `PATH` prüfen
|
||||||
|
- Preset-Name exakt wie in `HandBrakeCLI -z` hinterlegen
|
||||||
|
- Startfehler wegen Schema:
|
||||||
|
- sicherstellen, dass die erwartete Schema-Datei vorhanden ist (`db/schema.sql`)
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Keine echten Tokens/Passwörter ins Repository committen.
|
||||||
|
- Lokale Secrets in `.env` oder in Settings pflegen, aber nicht versionieren.
|
||||||
|
|
||||||
|
|||||||
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
PORT=3001
|
||||||
|
DB_PATH=./data/ripster.db
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_LEVEL=debug
|
||||||
2509
backend/package-lock.json
generated
Normal file
2509
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
backend/package.json
Normal file
21
backend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ripster-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"dev": "nodemon src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/config.js
Normal file
13
backend/src/config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db');
|
||||||
|
const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||||||
|
dbPath: path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath),
|
||||||
|
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||||
|
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info'
|
||||||
|
};
|
||||||
603
backend/src/db/database.js
Normal file
603
backend/src/db/database.js
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3');
|
||||||
|
const { open } = require('sqlite');
|
||||||
|
const { dbPath } = require('../config');
|
||||||
|
const { defaultSchema } = require('./defaultSettings');
|
||||||
|
const logger = require('../services/logger').child('DB');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
const { setLogRootDir, getJobLogDir } = require('../services/logPathService');
|
||||||
|
|
||||||
|
const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql');
|
||||||
|
|
||||||
|
let dbInstance;
|
||||||
|
|
||||||
|
function nowFileStamp() {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSqliteCorruptionError(error) {
|
||||||
|
if (!error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(error.code || '').toUpperCase();
|
||||||
|
const msg = String(error.message || '').toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
code === 'SQLITE_CORRUPT' ||
|
||||||
|
msg.includes('database disk image is malformed') ||
|
||||||
|
msg.includes('file is not a database')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveIfExists(sourcePath, targetPath) {
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fs.renameSync(sourcePath, targetPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quarantineCorruptDatabaseFiles() {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
const base = path.basename(dbPath);
|
||||||
|
const stamp = nowFileStamp();
|
||||||
|
const archiveDir = path.join(dir, 'corrupt-backups');
|
||||||
|
|
||||||
|
fs.mkdirSync(archiveDir, { recursive: true });
|
||||||
|
|
||||||
|
const moved = [];
|
||||||
|
const candidates = [
|
||||||
|
dbPath,
|
||||||
|
`${dbPath}-wal`,
|
||||||
|
`${dbPath}-shm`
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sourcePath of candidates) {
|
||||||
|
const fileName = path.basename(sourcePath);
|
||||||
|
const targetPath = path.join(archiveDir, `${fileName}.${stamp}.corrupt`);
|
||||||
|
if (moveIfExists(sourcePath, targetPath)) {
|
||||||
|
moved.push({
|
||||||
|
from: sourcePath,
|
||||||
|
to: targetPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('recovery:quarantine-complete', {
|
||||||
|
dbPath,
|
||||||
|
base,
|
||||||
|
movedCount: moved.length,
|
||||||
|
moved
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdentifier(identifier) {
|
||||||
|
return `"${String(identifier || '').replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSqlType(value) {
|
||||||
|
return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDefault(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTableShape(current = [], desired = []) {
|
||||||
|
if (current.length !== desired.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
const left = current[i];
|
||||||
|
const right = desired[i];
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.name || '') !== String(right.name || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeSqlType(left.type) !== normalizeSqlType(right.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.notnull || 0) !== Number(right.notnull || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.pk || 0) !== Number(right.pk || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeDefault(left.dflt_value) !== normalizeDefault(right.dflt_value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameForeignKeys(current = [], desired = []) {
|
||||||
|
if (current.length !== desired.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
const left = current[i];
|
||||||
|
const right = desired[i];
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.id || 0) !== Number(right.id || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(left.seq || 0) !== Number(right.seq || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.table || '') !== String(right.table || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.from || '') !== String(right.from || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.to || '') !== String(right.to || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.on_update || '') !== String(right.on_update || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.on_delete || '') !== String(right.on_delete || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (String(left.match || '') !== String(right.match || '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(db, tableName) {
|
||||||
|
const row = await db.get(
|
||||||
|
`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTableInfo(db, tableName) {
|
||||||
|
return db.all(`PRAGMA table_info(${quoteIdentifier(tableName)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getForeignKeyInfo(db, tableName) {
|
||||||
|
return db.all(`PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readConfiguredLogDirSetting(db) {
|
||||||
|
const hasSchemaTable = await tableExists(db, 'settings_schema');
|
||||||
|
const hasValuesTable = await tableExists(db, 'settings_values');
|
||||||
|
if (!hasSchemaTable || !hasValuesTable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await db.get(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COALESCE(v.value, s.default_value, '') AS value
|
||||||
|
FROM settings_schema s
|
||||||
|
LEFT JOIN settings_values v ON v.key = s.key
|
||||||
|
WHERE s.key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
['log_dir']
|
||||||
|
);
|
||||||
|
const value = String(row?.value || '').trim();
|
||||||
|
return value || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('log-root:read-setting-failed', {
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureRuntimeLogRootFromSettings(db, options = {}) {
|
||||||
|
const ensure = Boolean(options.ensure);
|
||||||
|
const configured = await readConfiguredLogDirSetting(db);
|
||||||
|
let resolved = setLogRootDir(configured);
|
||||||
|
if (ensure) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(resolved, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackResolved = setLogRootDir(null);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(fallbackResolved, { recursive: true });
|
||||||
|
} catch (_fallbackError) {
|
||||||
|
// ignored: logger itself is hardened and may still write to console only
|
||||||
|
}
|
||||||
|
logger.warn('log-root:ensure-failed', {
|
||||||
|
configured: configured || null,
|
||||||
|
resolved,
|
||||||
|
fallbackResolved,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
resolved = fallbackResolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured,
|
||||||
|
resolved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchemaModel() {
|
||||||
|
if (!fs.existsSync(schemaFilePath)) {
|
||||||
|
const error = new Error(`Schema-Datei fehlt: ${schemaFilePath}`);
|
||||||
|
error.code = 'SCHEMA_FILE_MISSING';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
|
||||||
|
const memDb = await open({
|
||||||
|
filename: ':memory:',
|
||||||
|
driver: sqlite3.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await memDb.exec(schemaSql);
|
||||||
|
const tables = await memDb.all(`
|
||||||
|
SELECT name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const indexes = await memDb.all(`
|
||||||
|
SELECT name, tbl_name AS tableName, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'index'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND sql IS NOT NULL
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const tableInfos = {};
|
||||||
|
const tableForeignKeys = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
tableInfos[table.name] = await getTableInfo(memDb, table.name);
|
||||||
|
tableForeignKeys[table.name] = await getForeignKeyInfo(memDb, table.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaSql,
|
||||||
|
tables,
|
||||||
|
indexes,
|
||||||
|
tableInfos,
|
||||||
|
tableForeignKeys
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await memDb.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildTable(db, tableName, createSql) {
|
||||||
|
const oldName = `${tableName}__old_${Date.now()}`;
|
||||||
|
const tableNameQuoted = quoteIdentifier(tableName);
|
||||||
|
const oldNameQuoted = quoteIdentifier(oldName);
|
||||||
|
const beforeInfo = await getTableInfo(db, tableName);
|
||||||
|
|
||||||
|
await db.exec(`ALTER TABLE ${tableNameQuoted} RENAME TO ${oldNameQuoted}`);
|
||||||
|
await db.exec(createSql);
|
||||||
|
|
||||||
|
const afterInfo = await getTableInfo(db, tableName);
|
||||||
|
const beforeColumns = new Set(beforeInfo.map((column) => String(column.name)));
|
||||||
|
const commonColumns = afterInfo
|
||||||
|
.map((column) => String(column.name))
|
||||||
|
.filter((name) => beforeColumns.has(name));
|
||||||
|
|
||||||
|
if (commonColumns.length > 0) {
|
||||||
|
const columnList = commonColumns.map((name) => quoteIdentifier(name)).join(', ');
|
||||||
|
await db.exec(`
|
||||||
|
INSERT INTO ${tableNameQuoted} (${columnList})
|
||||||
|
SELECT ${columnList}
|
||||||
|
FROM ${oldNameQuoted}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.exec(`DROP TABLE ${oldNameQuoted}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSchemaToModel(db, model) {
|
||||||
|
const desiredTables = Array.isArray(model?.tables) ? model.tables : [];
|
||||||
|
const desiredIndexes = Array.isArray(model?.indexes) ? model.indexes : [];
|
||||||
|
const desiredTableInfo = model?.tableInfos && typeof model.tableInfos === 'object'
|
||||||
|
? model.tableInfos
|
||||||
|
: {};
|
||||||
|
const desiredTableForeignKeys = model?.tableForeignKeys && typeof model.tableForeignKeys === 'object'
|
||||||
|
? model.tableForeignKeys
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const currentTables = await db.all(`
|
||||||
|
SELECT name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const currentByName = new Map(currentTables.map((table) => [table.name, table]));
|
||||||
|
const desiredTableNameSet = new Set(desiredTables.map((table) => table.name));
|
||||||
|
|
||||||
|
for (const table of desiredTables) {
|
||||||
|
const tableName = String(table.name || '');
|
||||||
|
const createSql = String(table.sql || '').trim();
|
||||||
|
if (!tableName || !createSql) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentByName.has(tableName)) {
|
||||||
|
await db.exec(createSql);
|
||||||
|
logger.info('schema:create-table', { table: tableName });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentInfo = await getTableInfo(db, tableName);
|
||||||
|
const wantedInfo = Array.isArray(desiredTableInfo[tableName]) ? desiredTableInfo[tableName] : [];
|
||||||
|
const currentFks = await getForeignKeyInfo(db, tableName);
|
||||||
|
const wantedFks = Array.isArray(desiredTableForeignKeys[tableName]) ? desiredTableForeignKeys[tableName] : [];
|
||||||
|
const shapeMatches = sameTableShape(currentInfo, wantedInfo);
|
||||||
|
const foreignKeysMatch = sameForeignKeys(currentFks, wantedFks);
|
||||||
|
if (!shapeMatches || !foreignKeysMatch) {
|
||||||
|
await rebuildTable(db, tableName, createSql);
|
||||||
|
logger.warn('schema:rebuild-table', {
|
||||||
|
table: tableName,
|
||||||
|
reason: !shapeMatches ? 'shape-mismatch' : 'foreign-key-mismatch'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of currentTables) {
|
||||||
|
if (desiredTableNameSet.has(table.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(table.name)}`);
|
||||||
|
logger.warn('schema:drop-table', { table: table.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndexes = await db.all(`
|
||||||
|
SELECT name, tbl_name AS tableName, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'index'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND sql IS NOT NULL
|
||||||
|
ORDER BY rowid ASC
|
||||||
|
`);
|
||||||
|
const desiredIndexNameSet = new Set(desiredIndexes.map((index) => index.name));
|
||||||
|
|
||||||
|
for (const index of currentIndexes) {
|
||||||
|
if (desiredIndexNameSet.has(index.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(index.name)}`);
|
||||||
|
logger.warn('schema:drop-index', { index: index.name, table: index.tableName });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index of desiredIndexes) {
|
||||||
|
let sql = String(index.sql || '').trim();
|
||||||
|
if (!sql) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^CREATE\s+UNIQUE\s+INDEX\s+/i.test(sql)) {
|
||||||
|
sql = sql.replace(/^CREATE\s+UNIQUE\s+INDEX\s+/i, 'CREATE UNIQUE INDEX IF NOT EXISTS ');
|
||||||
|
} else if (/^CREATE\s+INDEX\s+/i.test(sql)) {
|
||||||
|
sql = sql.replace(/^CREATE\s+INDEX\s+/i, 'CREATE INDEX IF NOT EXISTS ');
|
||||||
|
}
|
||||||
|
await db.exec(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportLegacyJobLogsToFiles(db) {
|
||||||
|
const hasJobLogsTable = await tableExists(db, 'job_logs');
|
||||||
|
if (!hasJobLogsTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.all(`
|
||||||
|
SELECT job_id, source, message, timestamp
|
||||||
|
FROM job_logs
|
||||||
|
ORDER BY job_id ASC, id ASC
|
||||||
|
`);
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
logger.info('legacy-job-logs:export:skip-empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = getJobLogDir();
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
const streams = new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const row of rows) {
|
||||||
|
const jobId = Number(row?.job_id);
|
||||||
|
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(Math.trunc(jobId));
|
||||||
|
if (!streams.has(key)) {
|
||||||
|
const filePath = path.join(targetDir, `job-${key}.process.log`);
|
||||||
|
const stream = fs.createWriteStream(filePath, {
|
||||||
|
flags: 'w',
|
||||||
|
encoding: 'utf-8'
|
||||||
|
});
|
||||||
|
streams.set(key, stream);
|
||||||
|
}
|
||||||
|
const line = `[${String(row?.timestamp || '')}] [${String(row?.source || 'SYSTEM')}] ${String(row?.message || '')}\n`;
|
||||||
|
streams.get(key).write(line);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await Promise.all(
|
||||||
|
[...streams.values()].map(
|
||||||
|
(stream) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
stream.end(resolve);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('legacy-job-logs:exported', {
|
||||||
|
lines: rows.length,
|
||||||
|
jobs: streams.size,
|
||||||
|
targetDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySchemaModel(db, model) {
|
||||||
|
await db.exec('PRAGMA foreign_keys = OFF;');
|
||||||
|
await db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
await syncSchemaToModel(db, model);
|
||||||
|
await db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAndPrepareDatabase() {
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
logger.info('init:open', { dbPath });
|
||||||
|
|
||||||
|
dbInstance = await open({
|
||||||
|
filename: dbPath,
|
||||||
|
driver: sqlite3.Database
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbInstance.exec('PRAGMA journal_mode = WAL;');
|
||||||
|
await dbInstance.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
const initialLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||||
|
logger.info('log-root:initialized', {
|
||||||
|
configured: initialLogRoot.configured || null,
|
||||||
|
resolved: initialLogRoot.resolved
|
||||||
|
});
|
||||||
|
await exportLegacyJobLogsToFiles(dbInstance);
|
||||||
|
const schemaModel = await loadSchemaModel();
|
||||||
|
await applySchemaModel(dbInstance, schemaModel);
|
||||||
|
|
||||||
|
await seedDefaultSettings(dbInstance);
|
||||||
|
await removeDeprecatedSettings(dbInstance);
|
||||||
|
await ensurePipelineStateRow(dbInstance);
|
||||||
|
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||||
|
logger.info('log-root:synced', {
|
||||||
|
configured: syncedLogRoot.configured || null,
|
||||||
|
resolved: syncedLogRoot.resolved
|
||||||
|
});
|
||||||
|
logger.info('init:done');
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDatabase({ allowRecovery = true } = {}) {
|
||||||
|
if (dbInstance) {
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await openAndPrepareDatabase();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('init:failed', { error: errorToMeta(error), allowRecovery });
|
||||||
|
|
||||||
|
if (dbInstance) {
|
||||||
|
try {
|
||||||
|
await dbInstance.close();
|
||||||
|
} catch (_closeError) {
|
||||||
|
// ignore close errors during failed init
|
||||||
|
}
|
||||||
|
dbInstance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowRecovery && isSqliteCorruptionError(error)) {
|
||||||
|
logger.warn('recovery:corrupt-db-detected', { dbPath });
|
||||||
|
quarantineCorruptDatabaseFiles();
|
||||||
|
return initDatabase({ allowRecovery: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaultSettings(db) {
|
||||||
|
let seeded = 0;
|
||||||
|
for (const item of defaultSchema) {
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_schema
|
||||||
|
(key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
category = excluded.category,
|
||||||
|
label = excluded.label,
|
||||||
|
type = excluded.type,
|
||||||
|
required = excluded.required,
|
||||||
|
description = excluded.description,
|
||||||
|
default_value = COALESCE(settings_schema.default_value, excluded.default_value),
|
||||||
|
options_json = excluded.options_json,
|
||||||
|
validation_json = excluded.validation_json,
|
||||||
|
order_index = excluded.order_index,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
item.key,
|
||||||
|
item.category,
|
||||||
|
item.label,
|
||||||
|
item.type,
|
||||||
|
item.required,
|
||||||
|
item.description || null,
|
||||||
|
item.defaultValue || null,
|
||||||
|
JSON.stringify(item.options || []),
|
||||||
|
JSON.stringify(item.validation || {}),
|
||||||
|
item.orderIndex || 0
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_values (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO NOTHING
|
||||||
|
`,
|
||||||
|
[item.key, item.defaultValue || null]
|
||||||
|
);
|
||||||
|
seeded += 1;
|
||||||
|
}
|
||||||
|
logger.info('seed:settings', { count: seeded });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePipelineStateRow(db) {
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO pipeline_state (id, state, active_job_id, progress, eta, status_text, context_json)
|
||||||
|
VALUES (1, 'IDLE', NULL, 0, NULL, NULL, '{}')
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDeprecatedSettings(db) {
|
||||||
|
const deprecatedKeys = ['pushover_notify_disc_detected'];
|
||||||
|
for (const key of deprecatedKeys) {
|
||||||
|
const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||||
|
if (result?.changes > 0) {
|
||||||
|
logger.info('migrate:remove-deprecated-setting', { key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDb() {
|
||||||
|
return initDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initDatabase,
|
||||||
|
getDb
|
||||||
|
};
|
||||||
463
backend/src/db/defaultSettings.js
Normal file
463
backend/src/db/defaultSettings.js
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
const defaultSchema = [
|
||||||
|
{
|
||||||
|
key: 'drive_mode',
|
||||||
|
category: 'Laufwerk',
|
||||||
|
label: 'Laufwerksmodus',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'Auto-Discovery oder explizites Device.',
|
||||||
|
defaultValue: 'auto',
|
||||||
|
options: [
|
||||||
|
{ label: 'Auto Discovery', value: 'auto' },
|
||||||
|
{ label: 'Explizites Device', value: 'explicit' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'drive_device',
|
||||||
|
category: 'Laufwerk',
|
||||||
|
label: 'Device Pfad',
|
||||||
|
type: 'path',
|
||||||
|
required: 0,
|
||||||
|
description: 'Nur für expliziten Modus, z.B. /dev/sr0.',
|
||||||
|
defaultValue: '/dev/sr0',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_source_index',
|
||||||
|
category: 'Laufwerk',
|
||||||
|
label: 'MakeMKV Source Index',
|
||||||
|
type: 'number',
|
||||||
|
required: 1,
|
||||||
|
description: 'Disc Index im Auto-Modus.',
|
||||||
|
defaultValue: '0',
|
||||||
|
options: [],
|
||||||
|
validation: { min: 0, max: 20 },
|
||||||
|
orderIndex: 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'disc_poll_interval_ms',
|
||||||
|
category: 'Laufwerk',
|
||||||
|
label: 'Polling Intervall (ms)',
|
||||||
|
type: 'number',
|
||||||
|
required: 1,
|
||||||
|
description: 'Intervall für Disk-Erkennung.',
|
||||||
|
defaultValue: '4000',
|
||||||
|
options: [],
|
||||||
|
validation: { min: 1000, max: 60000 },
|
||||||
|
orderIndex: 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'raw_dir',
|
||||||
|
category: 'Pfade',
|
||||||
|
label: 'Raw Ausgabeordner',
|
||||||
|
type: 'path',
|
||||||
|
required: 1,
|
||||||
|
description: 'Zwischenablage für MakeMKV Rip.',
|
||||||
|
defaultValue: '/mnt/arm-storage/media/raw',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'movie_dir',
|
||||||
|
category: 'Pfade',
|
||||||
|
label: 'Film Ausgabeordner',
|
||||||
|
type: 'path',
|
||||||
|
required: 1,
|
||||||
|
description: 'Finale HandBrake Ausgabe.',
|
||||||
|
defaultValue: '/mnt/arm-storage/media/movies',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'log_dir',
|
||||||
|
category: 'Pfade',
|
||||||
|
label: 'Log Ordner',
|
||||||
|
type: 'path',
|
||||||
|
required: 1,
|
||||||
|
description: 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.',
|
||||||
|
defaultValue: '/mnt/arm-storage/logs',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_command',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Kommando',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Pfad oder Befehl für makemkvcon.',
|
||||||
|
defaultValue: 'makemkvcon',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_registration_key',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Key',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Optionaler Registrierungsschlüssel. Wird vor Analyze/Rip automatisch per "makemkvcon reg" gesetzt.',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 202
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mediainfo_command',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Mediainfo Kommando',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Pfad oder Befehl für mediainfo.',
|
||||||
|
defaultValue: 'mediainfo',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 205
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mediainfo_extra_args',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Mediainfo Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für mediainfo.',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 206
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_min_length_minutes',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Minimale Titellänge (Minuten)',
|
||||||
|
type: 'number',
|
||||||
|
required: 1,
|
||||||
|
description: 'Filtert kurze Titel beim Rip.',
|
||||||
|
defaultValue: '60',
|
||||||
|
options: [],
|
||||||
|
validation: { min: 1, max: 1000 },
|
||||||
|
orderIndex: 210
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_rip_mode',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Rip Modus',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.',
|
||||||
|
defaultValue: 'backup',
|
||||||
|
options: [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'Backup', value: 'backup' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 212
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_analyze_extra_args',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Analyze Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für Analyze.',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_rip_extra_args',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Rip Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für Rip.',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 230
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_command',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Kommando',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Pfad oder Befehl für HandBrakeCLI.',
|
||||||
|
defaultValue: 'HandBrakeCLI',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_preset',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Preset',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Preset Name für -Z.',
|
||||||
|
defaultValue: 'H.264 MKV 1080p30',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 310
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_extra_args',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Argumente.',
|
||||||
|
defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'output_extension',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Ausgabeformat',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'Dateiendung für finale Datei.',
|
||||||
|
defaultValue: 'mkv',
|
||||||
|
options: [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'MP4', value: 'mp4' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 330
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'filename_template',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Dateiname Template',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId}.',
|
||||||
|
defaultValue: '${title} (${year})',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 340
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'omdb_api_key',
|
||||||
|
category: 'Metadaten',
|
||||||
|
label: 'OMDb API Key',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'API Key für Metadatensuche.',
|
||||||
|
defaultValue: '186322c4',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'omdb_default_type',
|
||||||
|
category: 'Metadaten',
|
||||||
|
label: 'OMDb Typ',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'Vorauswahl für Suche.',
|
||||||
|
defaultValue: 'movie',
|
||||||
|
options: [
|
||||||
|
{ label: 'Movie', value: 'movie' },
|
||||||
|
{ label: 'Series', value: 'series' },
|
||||||
|
{ label: 'Episode', value: 'episode' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 410
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_enabled',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver aktiviert',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Master-Schalter für PushOver Versand.',
|
||||||
|
defaultValue: 'false',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_token',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver Token',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Application Token für PushOver.',
|
||||||
|
defaultValue: 'a476diddeew53w8fi4kv88n6ghbfqq',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 510
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_user',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver User',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'User-Key für PushOver.',
|
||||||
|
defaultValue: 'u47227hupodan28a629az1k43644jg',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 520
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_device',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver Device (optional)',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Optionales Ziel-Device in PushOver.',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 530
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_title_prefix',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver Titel-Präfix',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Prefix im PushOver Titel.',
|
||||||
|
defaultValue: 'Ripster',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 540
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_priority',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver Priority',
|
||||||
|
type: 'number',
|
||||||
|
required: 1,
|
||||||
|
description: 'Priorität -2 bis 2.',
|
||||||
|
defaultValue: '0',
|
||||||
|
options: [],
|
||||||
|
validation: { min: -2, max: 2 },
|
||||||
|
orderIndex: 550
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_timeout_ms',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'PushOver Timeout (ms)',
|
||||||
|
type: 'number',
|
||||||
|
required: 1,
|
||||||
|
description: 'HTTP Timeout für PushOver Requests.',
|
||||||
|
defaultValue: '7000',
|
||||||
|
options: [],
|
||||||
|
validation: { min: 1000, max: 60000 },
|
||||||
|
orderIndex: 560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_metadata_ready',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Metadaten-Auswahl senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet wenn Metadaten zur Auswahl bereitstehen.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 570
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_rip_started',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Rip-Start senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet beim Start des MakeMKV-Rips.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 580
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_encoding_started',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Encode-Start senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet beim Start von HandBrake.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 590
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_job_finished',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Erfolg senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet bei erfolgreich abgeschlossenem Job.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_job_error',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Fehler senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet bei Fehlern in der Pipeline.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 610
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_job_cancelled',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Abbruch senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet wenn Job manuell abgebrochen wurde.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 620
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_reencode_started',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Re-Encode Start senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet beim Start von RAW Re-Encode.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 630
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pushover_notify_reencode_finished',
|
||||||
|
category: 'Benachrichtigungen',
|
||||||
|
label: 'Bei Re-Encode Erfolg senden',
|
||||||
|
type: 'boolean',
|
||||||
|
required: 1,
|
||||||
|
description: 'Sendet bei erfolgreichem RAW Re-Encode.',
|
||||||
|
defaultValue: 'true',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 640
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
defaultSchema
|
||||||
|
};
|
||||||
95
backend/src/index.js
Normal file
95
backend/src/index.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { port, corsOrigin } = require('./config');
|
||||||
|
const { initDatabase } = require('./db/database');
|
||||||
|
const errorHandler = require('./middleware/errorHandler');
|
||||||
|
const requestLogger = require('./middleware/requestLogger');
|
||||||
|
const settingsRoutes = require('./routes/settingsRoutes');
|
||||||
|
const pipelineRoutes = require('./routes/pipelineRoutes');
|
||||||
|
const historyRoutes = require('./routes/historyRoutes');
|
||||||
|
const wsService = require('./services/websocketService');
|
||||||
|
const pipelineService = require('./services/pipelineService');
|
||||||
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
|
const logger = require('./services/logger').child('BOOT');
|
||||||
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
logger.info('backend:start:init');
|
||||||
|
await initDatabase();
|
||||||
|
await pipelineService.init();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors({ origin: corsOrigin }));
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ ok: true, now: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
app.use('/api/pipeline', pipelineRoutes);
|
||||||
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
wsService.init(server);
|
||||||
|
|
||||||
|
diskDetectionService.on('discInserted', (device) => {
|
||||||
|
logger.info('disk:inserted:event', { device });
|
||||||
|
pipelineService.onDiscInserted(device).catch((error) => {
|
||||||
|
logger.error('pipeline:onDiscInserted:failed', { error: errorToMeta(error), device });
|
||||||
|
wsService.broadcast('PIPELINE_ERROR', { message: error.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
diskDetectionService.on('discRemoved', (device) => {
|
||||||
|
logger.info('disk:removed:event', { device });
|
||||||
|
pipelineService.onDiscRemoved(device).catch((error) => {
|
||||||
|
logger.error('pipeline:onDiscRemoved:failed', { error: errorToMeta(error), device });
|
||||||
|
wsService.broadcast('PIPELINE_ERROR', { message: error.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
diskDetectionService.on('error', (error) => {
|
||||||
|
logger.error('diskDetection:error:event', { error: errorToMeta(error) });
|
||||||
|
wsService.broadcast('DISK_DETECTION_ERROR', { message: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
diskDetectionService.start();
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
logger.info('backend:listening', { port });
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
logger.warn('backend:shutdown:received');
|
||||||
|
diskDetectionService.stop();
|
||||||
|
server.close(() => {
|
||||||
|
logger.warn('backend:shutdown:completed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('process:uncaughtException', { error: errorToMeta(error) });
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error('process:unhandledRejection', {
|
||||||
|
reason: reason instanceof Error ? errorToMeta(reason) : String(reason)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((error) => {
|
||||||
|
logger.error('backend:start:failed', { error: errorToMeta(error) });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
5
backend/src/middleware/asyncHandler.js
Normal file
5
backend/src/middleware/asyncHandler.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = function asyncHandler(fn) {
|
||||||
|
return function wrapped(req, res, next) {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
23
backend/src/middleware/errorHandler.js
Normal file
23
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const logger = require('../services/logger').child('ERROR_HANDLER');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
module.exports = function errorHandler(error, req, res, next) {
|
||||||
|
const statusCode = error.statusCode || 500;
|
||||||
|
|
||||||
|
logger.error('request:error', {
|
||||||
|
reqId: req?.reqId,
|
||||||
|
method: req?.method,
|
||||||
|
url: req?.originalUrl,
|
||||||
|
statusCode,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: {
|
||||||
|
message: error.message || 'Interner Fehler',
|
||||||
|
statusCode,
|
||||||
|
reqId: req?.reqId,
|
||||||
|
details: Array.isArray(error.details) ? error.details : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
53
backend/src/middleware/requestLogger.js
Normal file
53
backend/src/middleware/requestLogger.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
const logger = require('../services/logger').child('HTTP');
|
||||||
|
|
||||||
|
function truncate(value, maxLen = 1500) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
str = value;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
str = JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
str = '[unserializable-body]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (str.length <= maxLen) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function requestLogger(req, res, next) {
|
||||||
|
const reqId = randomUUID();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
req.reqId = reqId;
|
||||||
|
|
||||||
|
logger.info('request:start', {
|
||||||
|
reqId,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
ip: req.ip,
|
||||||
|
query: req.query,
|
||||||
|
body: truncate(req.body)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
logger.warn('request:aborted', {
|
||||||
|
reqId,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
154
backend/src/routes/historyRoutes.js
Normal file
154
backend/src/routes/historyRoutes.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const historyService = require('../services/historyService');
|
||||||
|
const pipelineService = require('../services/pipelineService');
|
||||||
|
const logger = require('../services/logger').child('HISTORY_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('get:jobs', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobs = await historyService.getJobs({
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ jobs });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/database',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('get:database', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await historyService.getDatabaseRows({
|
||||||
|
status: req.query.status,
|
||||||
|
search: req.query.search
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ rows });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/orphan-raw',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('get:orphan-raw', { reqId: req.reqId });
|
||||||
|
const result = await historyService.getOrphanRawFolders();
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/orphan-raw/import',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const rawPath = String(req.body?.rawPath || '').trim();
|
||||||
|
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
|
||||||
|
const job = await historyService.importOrphanRawFolder(rawPath);
|
||||||
|
const uiReset = await pipelineService.resetFrontendState('history_orphan_import');
|
||||||
|
res.json({ job, uiReset });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/omdb/assign',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('post:job:omdb:assign', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
imdbId: payload?.imdbId || null,
|
||||||
|
hasTitle: Boolean(payload?.title),
|
||||||
|
hasYear: Boolean(payload?.year)
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await historyService.assignOmdbMetadata(id, payload);
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/delete-files',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = String(req.body?.target || 'both');
|
||||||
|
|
||||||
|
logger.warn('post:delete-files', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
target
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await historyService.deleteJobFiles(id, target);
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/delete',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = String(req.body?.target || 'none');
|
||||||
|
|
||||||
|
logger.warn('post:delete-job', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
target
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await historyService.deleteJob(id, target);
|
||||||
|
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
||||||
|
res.json({ ...result, uiReset });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase());
|
||||||
|
const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase());
|
||||||
|
const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase());
|
||||||
|
const parsedTail = Number(req.query.logTailLines);
|
||||||
|
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
|
||||||
|
? Math.trunc(parsedTail)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
logger.info('get:job-detail', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
includeLiveLog,
|
||||||
|
includeLogs,
|
||||||
|
includeAllLogs,
|
||||||
|
logTailLines
|
||||||
|
});
|
||||||
|
const job = await historyService.getJobWithLogs(id, {
|
||||||
|
includeLiveLog,
|
||||||
|
includeLogs,
|
||||||
|
includeAllLogs,
|
||||||
|
logTailLines
|
||||||
|
});
|
||||||
|
if (!job) {
|
||||||
|
const error = new Error('Job nicht gefunden.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
160
backend/src/routes/pipelineRoutes.js
Normal file
160
backend/src/routes/pipelineRoutes.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const pipelineService = require('../services/pipelineService');
|
||||||
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
|
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/state',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:state', { reqId: req.reqId });
|
||||||
|
res.json({ pipeline: pipelineService.getSnapshot() });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/analyze',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('post:analyze', { reqId: req.reqId });
|
||||||
|
const result = await pipelineService.analyzeDisc();
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/rescan-disc',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.info('post:rescan-disc', { reqId: req.reqId });
|
||||||
|
const result = await diskDetectionService.rescanAndEmit();
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/omdb/search',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const query = req.query.q || '';
|
||||||
|
logger.info('get:omdb:search', { reqId: req.reqId, query });
|
||||||
|
const results = await pipelineService.searchOmdb(String(query));
|
||||||
|
res.json({ results });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/select-metadata',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist } = req.body;
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
const error = new Error('jobId fehlt.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('post:select-metadata', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
jobId,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
imdbId,
|
||||||
|
poster,
|
||||||
|
fromOmdb,
|
||||||
|
selectedPlaylist
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await pipelineService.selectMetadata({
|
||||||
|
jobId: Number(jobId),
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
imdbId,
|
||||||
|
poster,
|
||||||
|
fromOmdb,
|
||||||
|
selectedPlaylist
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/start/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:start-job', { reqId: req.reqId, jobId });
|
||||||
|
const result = await pipelineService.startPreparedJob(jobId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/confirm-encode/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||||
|
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||||
|
logger.info('post:confirm-encode', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
jobId,
|
||||||
|
selectedEncodeTitleId,
|
||||||
|
selectedTrackSelectionProvided: Boolean(selectedTrackSelection)
|
||||||
|
});
|
||||||
|
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||||
|
selectedEncodeTitleId,
|
||||||
|
selectedTrackSelection
|
||||||
|
});
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/cancel',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.warn('post:cancel', { reqId: req.reqId });
|
||||||
|
await pipelineService.cancel();
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/retry/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:retry', { reqId: req.reqId, jobId });
|
||||||
|
await pipelineService.retry(jobId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/resume-ready/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:resume-ready', { reqId: req.reqId, jobId });
|
||||||
|
const job = await pipelineService.resumeReadyToEncodeJob(jobId);
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/reencode/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:reencode', { reqId: req.reqId, jobId });
|
||||||
|
const result = await pipelineService.reencodeFromRaw(jobId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/restart-encode/:jobId',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const jobId = Number(req.params.jobId);
|
||||||
|
logger.info('post:restart-encode', { reqId: req.reqId, jobId });
|
||||||
|
const result = await pipelineService.restartEncodeWithLastSettings(jobId);
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
128
backend/src/routes/settingsRoutes.js
Normal file
128
backend/src/routes/settingsRoutes.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
|
const settingsService = require('../services/settingsService');
|
||||||
|
const notificationService = require('../services/notificationService');
|
||||||
|
const pipelineService = require('../services/pipelineService');
|
||||||
|
const wsService = require('../services/websocketService');
|
||||||
|
const logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function isSensitiveSettingKey(key) {
|
||||||
|
const normalized = String(key || '').trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(token|password|secret|api_key|registration_key|pushover_user)/i.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
logger.debug('get:settings', { reqId: req.reqId });
|
||||||
|
const categories = await settingsService.getCategorizedSettings();
|
||||||
|
res.json({ categories });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/:key',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { key } = req.params;
|
||||||
|
const { value } = req.body;
|
||||||
|
|
||||||
|
logger.info('put:setting', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
key,
|
||||||
|
value: isSensitiveSettingKey(key) ? '[redacted]' : value
|
||||||
|
});
|
||||||
|
const updated = await settingsService.setSettingValue(key, value);
|
||||||
|
let reviewRefresh = null;
|
||||||
|
try {
|
||||||
|
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave([key]);
|
||||||
|
if (reviewRefresh?.triggered) {
|
||||||
|
logger.info('put:setting:review-refresh-started', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
key,
|
||||||
|
jobId: reviewRefresh.jobId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('put:setting:review-refresh-failed', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
key,
|
||||||
|
error: {
|
||||||
|
name: error?.name,
|
||||||
|
message: error?.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reviewRefresh = {
|
||||||
|
triggered: false,
|
||||||
|
reason: 'refresh_error',
|
||||||
|
message: error?.message || 'unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||||
|
|
||||||
|
res.json({ setting: updated, reviewRefresh });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { settings } = req.body || {};
|
||||||
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
||||||
|
const error = new Error('settings fehlt oder ist ungültig.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('put:settings:bulk', { reqId: req.reqId, count: Object.keys(settings).length });
|
||||||
|
const changes = await settingsService.setSettingsBulk(settings);
|
||||||
|
let reviewRefresh = null;
|
||||||
|
try {
|
||||||
|
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave(changes.map((item) => item.key));
|
||||||
|
if (reviewRefresh?.triggered) {
|
||||||
|
logger.info('put:settings:bulk:review-refresh-started', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
jobId: reviewRefresh.jobId,
|
||||||
|
relevantKeys: reviewRefresh.relevantKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('put:settings:bulk:review-refresh-failed', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
error: {
|
||||||
|
name: error?.name,
|
||||||
|
message: error?.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reviewRefresh = {
|
||||||
|
triggered: false,
|
||||||
|
reason: 'refresh_error',
|
||||||
|
message: error?.message || 'unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
|
||||||
|
|
||||||
|
res.json({ changes, reviewRefresh });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/pushover/test',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const title = req.body?.title;
|
||||||
|
const message = req.body?.message;
|
||||||
|
logger.info('post:pushover:test', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
hasTitle: Boolean(title),
|
||||||
|
hasMessage: Boolean(message)
|
||||||
|
});
|
||||||
|
const result = await notificationService.sendTest({ title, message });
|
||||||
|
res.json({ result });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
385
backend/src/services/diskDetectionService.js
Normal file
385
backend/src/services/diskDetectionService.js
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const settingsService = require('./settingsService');
|
||||||
|
const logger = require('./logger').child('DISK');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function flattenDevices(nodes, acc = []) {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
acc.push(node);
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
flattenDevices(node.children, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignature(info) {
|
||||||
|
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiskDetectionService extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.running = false;
|
||||||
|
this.timer = null;
|
||||||
|
this.lastDetected = null;
|
||||||
|
this.lastPresent = false;
|
||||||
|
this.deviceLocks = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = true;
|
||||||
|
logger.info('start');
|
||||||
|
this.scheduleNext(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false;
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
logger.info('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext(delayMs) {
|
||||||
|
if (!this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timer = setTimeout(async () => {
|
||||||
|
let nextDelay = 4000;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const map = await settingsService.getSettingsMap();
|
||||||
|
nextDelay = Number(map.disc_poll_interval_ms || 4000);
|
||||||
|
logger.debug('poll:tick', {
|
||||||
|
driveMode: map.drive_mode,
|
||||||
|
driveDevice: map.drive_device,
|
||||||
|
nextDelay
|
||||||
|
});
|
||||||
|
const detected = await this.detectDisc(map);
|
||||||
|
this.applyDetectionResult(detected, { forceInsertEvent: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('poll:error', { error: errorToMeta(error) });
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleNext(nextDelay);
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rescanAndEmit() {
|
||||||
|
try {
|
||||||
|
const map = await settingsService.getSettingsMap();
|
||||||
|
logger.info('rescan:requested', {
|
||||||
|
driveMode: map.drive_mode,
|
||||||
|
driveDevice: map.drive_device
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = await this.detectDisc(map);
|
||||||
|
const result = this.applyDetectionResult(detected, { forceInsertEvent: true });
|
||||||
|
|
||||||
|
logger.info('rescan:done', {
|
||||||
|
present: result.present,
|
||||||
|
emitted: result.emitted,
|
||||||
|
changed: result.changed,
|
||||||
|
detected: result.device || null
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('rescan:error', { error: errorToMeta(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeDevicePath(devicePath) {
|
||||||
|
return String(devicePath || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
lockDevice(devicePath, owner = null) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.deviceLocks.get(normalized) || {
|
||||||
|
count: 0,
|
||||||
|
owners: []
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.count += 1;
|
||||||
|
if (owner) {
|
||||||
|
entry.owners.push(owner);
|
||||||
|
}
|
||||||
|
this.deviceLocks.set(normalized, entry);
|
||||||
|
|
||||||
|
logger.info('lock:add', {
|
||||||
|
devicePath: normalized,
|
||||||
|
count: entry.count,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
devicePath: normalized,
|
||||||
|
owner
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockDevice(devicePath, owner = null) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.deviceLocks.get(normalized);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count = Math.max(0, entry.count - 1);
|
||||||
|
if (entry.count === 0) {
|
||||||
|
this.deviceLocks.delete(normalized);
|
||||||
|
logger.info('lock:remove', {
|
||||||
|
devicePath: normalized,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deviceLocks.set(normalized, entry);
|
||||||
|
logger.info('lock:decrement', {
|
||||||
|
devicePath: normalized,
|
||||||
|
count: entry.count,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeviceLocked(devicePath) {
|
||||||
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.deviceLocks.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveLocks() {
|
||||||
|
return Array.from(this.deviceLocks.entries()).map(([path, info]) => ({
|
||||||
|
path,
|
||||||
|
count: info.count,
|
||||||
|
owners: info.owners
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDetectionResult(detected, { forceInsertEvent = false } = {}) {
|
||||||
|
const isPresent = Boolean(detected);
|
||||||
|
const changed =
|
||||||
|
isPresent &&
|
||||||
|
(!this.lastDetected || buildSignature(this.lastDetected) !== buildSignature(detected));
|
||||||
|
|
||||||
|
if (isPresent) {
|
||||||
|
const shouldEmitInserted = forceInsertEvent || !this.lastPresent || changed;
|
||||||
|
this.lastDetected = detected;
|
||||||
|
this.lastPresent = true;
|
||||||
|
|
||||||
|
if (shouldEmitInserted) {
|
||||||
|
logger.info('disc:inserted', { detected, forceInsertEvent, changed });
|
||||||
|
this.emit('discInserted', detected);
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
changed,
|
||||||
|
emitted: 'discInserted',
|
||||||
|
device: detected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
changed,
|
||||||
|
emitted: 'none',
|
||||||
|
device: detected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPresent && this.lastPresent) {
|
||||||
|
const removed = this.lastDetected;
|
||||||
|
this.lastDetected = null;
|
||||||
|
this.lastPresent = false;
|
||||||
|
logger.info('disc:removed', { removed });
|
||||||
|
this.emit('discRemoved', removed);
|
||||||
|
return {
|
||||||
|
present: false,
|
||||||
|
changed: true,
|
||||||
|
emitted: 'discRemoved',
|
||||||
|
device: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
present: false,
|
||||||
|
changed: false,
|
||||||
|
emitted: 'none',
|
||||||
|
device: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectDisc(settingsMap) {
|
||||||
|
if (settingsMap.drive_mode === 'explicit') {
|
||||||
|
return this.detectExplicit(settingsMap.drive_device);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.detectAuto();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectExplicit(devicePath) {
|
||||||
|
if (this.isDeviceLocked(devicePath)) {
|
||||||
|
logger.debug('detect:explicit:locked', {
|
||||||
|
devicePath,
|
||||||
|
activeLocks: this.getActiveLocks()
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!devicePath || !fs.existsSync(devicePath)) {
|
||||||
|
logger.debug('detect:explicit:not-found', { devicePath });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMedia = await this.checkMediaPresent(devicePath);
|
||||||
|
if (!hasMedia) {
|
||||||
|
logger.debug('detect:explicit:no-media', { devicePath });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const discLabel = await this.getDiscLabel(devicePath);
|
||||||
|
|
||||||
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||||
|
|
||||||
|
const detected = {
|
||||||
|
mode: 'explicit',
|
||||||
|
path: devicePath,
|
||||||
|
name: match.name || devicePath.split('/').pop(),
|
||||||
|
model: match.model || 'Unknown',
|
||||||
|
label: match.label || null,
|
||||||
|
discLabel: discLabel || null,
|
||||||
|
mountpoint: match.mountpoint || null,
|
||||||
|
fstype: match.fstype || null,
|
||||||
|
index: this.guessDiscIndex(match.name || devicePath)
|
||||||
|
};
|
||||||
|
logger.debug('detect:explicit:success', { detected });
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectAuto() {
|
||||||
|
const details = await this.getBlockDeviceInfo();
|
||||||
|
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||||
|
|
||||||
|
for (const item of romCandidates) {
|
||||||
|
const path = item.path || (item.name ? `/dev/${item.name}` : null);
|
||||||
|
if (!path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDeviceLocked(path)) {
|
||||||
|
logger.debug('detect:auto:skip-locked', {
|
||||||
|
path,
|
||||||
|
activeLocks: this.getActiveLocks()
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMedia = await this.checkMediaPresent(path);
|
||||||
|
if (!hasMedia) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const discLabel = await this.getDiscLabel(path);
|
||||||
|
|
||||||
|
const detected = {
|
||||||
|
mode: 'auto',
|
||||||
|
path,
|
||||||
|
name: item.name,
|
||||||
|
model: item.model || 'Optical Drive',
|
||||||
|
label: item.label || null,
|
||||||
|
discLabel: discLabel || null,
|
||||||
|
mountpoint: item.mountpoint || null,
|
||||||
|
fstype: item.fstype || null,
|
||||||
|
index: this.guessDiscIndex(item.name)
|
||||||
|
};
|
||||||
|
logger.debug('detect:auto:success', { detected });
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('detect:auto:none');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlockDeviceInfo() {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('lsblk', [
|
||||||
|
'-J',
|
||||||
|
'-o',
|
||||||
|
'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL'
|
||||||
|
]);
|
||||||
|
const parsed = JSON.parse(stdout);
|
||||||
|
const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
type: entry.type,
|
||||||
|
mountpoint: entry.mountpoint,
|
||||||
|
fstype: entry.fstype,
|
||||||
|
label: entry.label,
|
||||||
|
model: entry.model
|
||||||
|
}));
|
||||||
|
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
||||||
|
return devices;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('lsblk:failed', { error: errorToMeta(error) });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkMediaPresent(devicePath) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]);
|
||||||
|
const has = stdout.trim().length > 0;
|
||||||
|
logger.debug('blkid:result', { devicePath, hasMedia: has, type: stdout.trim() });
|
||||||
|
return has;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('blkid:no-media-or-fail', { devicePath, error: errorToMeta(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiscLabel(devicePath) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'LABEL', devicePath]);
|
||||||
|
const label = stdout.trim();
|
||||||
|
logger.debug('blkid:label', { devicePath, discLabel: label || null });
|
||||||
|
return label || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('blkid:no-label', { devicePath, error: errorToMeta(error) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guessDiscIndex(name) {
|
||||||
|
if (!name) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = String(name).match(/(\d+)$/);
|
||||||
|
return match ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new DiskDetectionService();
|
||||||
1098
backend/src/services/historyService.js
Normal file
1098
backend/src/services/historyService.js
Normal file
File diff suppressed because it is too large
Load Diff
46
backend/src/services/logPathService.js
Normal file
46
backend/src/services/logPathService.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { logDir: fallbackLogDir } = require('../config');
|
||||||
|
|
||||||
|
function normalizeDir(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallbackLogRootDir() {
|
||||||
|
return path.resolve(fallbackLogDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogRootDir(value) {
|
||||||
|
return normalizeDir(value) || getFallbackLogRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeLogRootDir = getFallbackLogRootDir();
|
||||||
|
|
||||||
|
function setLogRootDir(value) {
|
||||||
|
runtimeLogRootDir = resolveLogRootDir(value);
|
||||||
|
return runtimeLogRootDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogRootDir() {
|
||||||
|
return runtimeLogRootDir || getFallbackLogRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendLogDir() {
|
||||||
|
return path.join(getLogRootDir(), 'backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobLogDir() {
|
||||||
|
return getLogRootDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getFallbackLogRootDir,
|
||||||
|
resolveLogRootDir,
|
||||||
|
setLogRootDir,
|
||||||
|
getLogRootDir,
|
||||||
|
getBackendLogDir,
|
||||||
|
getJobLogDir
|
||||||
|
};
|
||||||
151
backend/src/services/logger.js
Normal file
151
backend/src/services/logger.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { logLevel } = require('../config');
|
||||||
|
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
|
||||||
|
|
||||||
|
const LEVELS = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
|
||||||
|
|
||||||
|
function ensureLogDir(logDirPath) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(logDirPath, { recursive: true });
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWritableBackendLogDir() {
|
||||||
|
const preferred = getBackendLogDir();
|
||||||
|
if (ensureLogDir(preferred)) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = path.join(getFallbackLogRootDir(), 'backend');
|
||||||
|
if (fallback !== preferred && ensureLogDir(fallback)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDailyFileName() {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `backend-${y}-${m}-${day}.log`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (error) {
|
||||||
|
return JSON.stringify({ serializationError: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateString(value, maxLen = 3000) {
|
||||||
|
const str = String(value);
|
||||||
|
if (str.length <= maxLen) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMeta(meta) {
|
||||||
|
if (!meta || typeof meta !== 'object') {
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = Array.isArray(meta) ? [] : {};
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(meta)) {
|
||||||
|
if (val instanceof Error) {
|
||||||
|
out[key] = {
|
||||||
|
name: val.name,
|
||||||
|
message: val.message,
|
||||||
|
stack: val.stack
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
out[key] = truncateString(val, 5000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLine(line) {
|
||||||
|
const backendLogDir = resolveWritableBackendLogDir();
|
||||||
|
if (!backendLogDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const daily = path.join(backendLogDir, getDailyFileName());
|
||||||
|
const latest = path.join(backendLogDir, 'backend-latest.log');
|
||||||
|
|
||||||
|
fs.appendFile(daily, `${line}\n`, (_error) => null);
|
||||||
|
fs.appendFile(latest, `${line}\n`, (_error) => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level, scope, message, meta = null) {
|
||||||
|
const normLevel = String(level || 'info').toLowerCase();
|
||||||
|
const lvl = LEVELS[normLevel] || LEVELS.info;
|
||||||
|
if (lvl < ACTIVE_LEVEL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const payload = {
|
||||||
|
timestamp,
|
||||||
|
level: normLevel,
|
||||||
|
scope,
|
||||||
|
message,
|
||||||
|
meta: sanitizeMeta(meta)
|
||||||
|
};
|
||||||
|
|
||||||
|
const line = safeJson(payload);
|
||||||
|
writeLine(line);
|
||||||
|
|
||||||
|
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
|
||||||
|
if (normLevel === 'error') {
|
||||||
|
console.error(print, payload.meta ? payload.meta : '');
|
||||||
|
} else if (normLevel === 'warn') {
|
||||||
|
console.warn(print, payload.meta ? payload.meta : '');
|
||||||
|
} else {
|
||||||
|
console.log(print, payload.meta ? payload.meta : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function child(scope) {
|
||||||
|
return {
|
||||||
|
debug(message, meta) {
|
||||||
|
emit('debug', scope, message, meta);
|
||||||
|
},
|
||||||
|
info(message, meta) {
|
||||||
|
emit('info', scope, message, meta);
|
||||||
|
},
|
||||||
|
warn(message, meta) {
|
||||||
|
emit('warn', scope, message, meta);
|
||||||
|
},
|
||||||
|
error(message, meta) {
|
||||||
|
emit('error', scope, message, meta);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
child,
|
||||||
|
emit
|
||||||
|
};
|
||||||
165
backend/src/services/notificationService.js
Normal file
165
backend/src/services/notificationService.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
const settingsService = require('./settingsService');
|
||||||
|
const logger = require('./logger').child('PUSHOVER');
|
||||||
|
const { toBoolean } = require('../utils/validators');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
const PUSHOVER_API_URL = 'https://api.pushover.net/1/messages.json';
|
||||||
|
|
||||||
|
const EVENT_TOGGLE_KEYS = {
|
||||||
|
metadata_ready: 'pushover_notify_metadata_ready',
|
||||||
|
rip_started: 'pushover_notify_rip_started',
|
||||||
|
encoding_started: 'pushover_notify_encoding_started',
|
||||||
|
job_finished: 'pushover_notify_job_finished',
|
||||||
|
job_error: 'pushover_notify_job_error',
|
||||||
|
job_cancelled: 'pushover_notify_job_cancelled',
|
||||||
|
reencode_started: 'pushover_notify_reencode_started',
|
||||||
|
reencode_finished: 'pushover_notify_reencode_finished'
|
||||||
|
};
|
||||||
|
|
||||||
|
function truncate(value, maxLen = 1024) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (text.length <= maxLen) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `${text.slice(0, maxLen - 20)}...[truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriority(raw) {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (n < -2) {
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
if (n > 2) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
async notify(eventKey, payload = {}) {
|
||||||
|
const settings = await settingsService.getSettingsMap();
|
||||||
|
return this.notifyWithSettings(settings, eventKey, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTest({ title, message } = {}) {
|
||||||
|
return this.notify('test', {
|
||||||
|
title: title || 'Ripster Test',
|
||||||
|
message: message || 'PushOver Testnachricht von Ripster.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyWithSettings(settings, eventKey, payload = {}) {
|
||||||
|
const enabled = toBoolean(settings.pushover_enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
logger.debug('notify:skip:disabled', { eventKey });
|
||||||
|
return { sent: false, reason: 'disabled', eventKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleKey = EVENT_TOGGLE_KEYS[eventKey];
|
||||||
|
if (toggleKey && !toBoolean(settings[toggleKey])) {
|
||||||
|
logger.debug('notify:skip:event-disabled', { eventKey, toggleKey });
|
||||||
|
return { sent: false, reason: 'event-disabled', eventKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(settings.pushover_token || '').trim();
|
||||||
|
const user = String(settings.pushover_user || '').trim();
|
||||||
|
if (!token || !user) {
|
||||||
|
logger.warn('notify:skip:missing-credentials', {
|
||||||
|
eventKey,
|
||||||
|
hasToken: Boolean(token),
|
||||||
|
hasUser: Boolean(user)
|
||||||
|
});
|
||||||
|
return { sent: false, reason: 'missing-credentials', eventKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = String(settings.pushover_title_prefix || 'Ripster').trim();
|
||||||
|
const title = truncate(payload.title || `${prefix} - ${eventKey}`, 120);
|
||||||
|
const message = truncate(payload.message || eventKey, 1024);
|
||||||
|
const priority = normalizePriority(
|
||||||
|
payload.priority !== undefined ? payload.priority : settings.pushover_priority
|
||||||
|
);
|
||||||
|
const timeoutMs = Math.max(1000, Number(settings.pushover_timeout_ms || 7000));
|
||||||
|
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.set('token', token);
|
||||||
|
form.set('user', user);
|
||||||
|
form.set('title', title);
|
||||||
|
form.set('message', message);
|
||||||
|
form.set('priority', String(priority));
|
||||||
|
|
||||||
|
const device = String(settings.pushover_device || '').trim();
|
||||||
|
if (device) {
|
||||||
|
form.set('device', device);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.url) {
|
||||||
|
form.set('url', String(payload.url));
|
||||||
|
}
|
||||||
|
if (payload.urlTitle) {
|
||||||
|
form.set('url_title', String(payload.urlTitle));
|
||||||
|
}
|
||||||
|
if (payload.sound) {
|
||||||
|
form.set('sound', String(payload.sound));
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(PUSHOVER_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: form.toString(),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawText = await response.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = rawText ? JSON.parse(rawText) : null;
|
||||||
|
} catch (error) {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const messageText = data?.errors?.join(', ') || data?.error || rawText || `HTTP ${response.status}`;
|
||||||
|
const error = new Error(`PushOver HTTP ${response.status}: ${messageText}`);
|
||||||
|
error.statusCode = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.status !== 1) {
|
||||||
|
const messageText = data.errors?.join(', ') || data.error || 'Unbekannte PushOver Antwort.';
|
||||||
|
throw new Error(`PushOver Fehler: ${messageText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('notify:sent', {
|
||||||
|
eventKey,
|
||||||
|
title,
|
||||||
|
priority,
|
||||||
|
requestId: data?.request || null
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
sent: true,
|
||||||
|
eventKey,
|
||||||
|
requestId: data?.request || null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('notify:failed', {
|
||||||
|
eventKey,
|
||||||
|
title,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new NotificationService();
|
||||||
92
backend/src/services/omdbService.js
Normal file
92
backend/src/services/omdbService.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const settingsService = require('./settingsService');
|
||||||
|
const logger = require('./logger').child('OMDB');
|
||||||
|
|
||||||
|
class OmdbService {
|
||||||
|
async search(query) {
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
logger.info('search:start', { query });
|
||||||
|
|
||||||
|
const settings = await settingsService.getSettingsMap();
|
||||||
|
const apiKey = settings.omdb_api_key;
|
||||||
|
if (!apiKey) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = settings.omdb_default_type || 'movie';
|
||||||
|
const url = new URL('https://www.omdbapi.com/');
|
||||||
|
url.searchParams.set('apikey', apiKey);
|
||||||
|
url.searchParams.set('s', query.trim());
|
||||||
|
url.searchParams.set('type', type);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('search:http-failed', { query, status: response.status });
|
||||||
|
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.Response === 'False' || !Array.isArray(data.Search)) {
|
||||||
|
logger.warn('search:no-results', { query, response: data.Response, error: data.Error });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const results = data.Search.map((item) => ({
|
||||||
|
title: item.Title,
|
||||||
|
year: item.Year,
|
||||||
|
imdbId: item.imdbID,
|
||||||
|
type: item.Type,
|
||||||
|
poster: item.Poster
|
||||||
|
}));
|
||||||
|
logger.info('search:done', { query, count: results.length });
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchByImdbId(imdbId) {
|
||||||
|
const normalizedId = String(imdbId || '').trim().toLowerCase();
|
||||||
|
if (!/^tt\d{6,12}$/.test(normalizedId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
|
||||||
|
const settings = await settingsService.getSettingsMap();
|
||||||
|
const apiKey = settings.omdb_api_key;
|
||||||
|
if (!apiKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('https://www.omdbapi.com/');
|
||||||
|
url.searchParams.set('apikey', apiKey);
|
||||||
|
url.searchParams.set('i', normalizedId);
|
||||||
|
url.searchParams.set('plot', 'full');
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('fetchByImdbId:http-failed', { imdbId: normalizedId, status: response.status });
|
||||||
|
throw new Error(`OMDb Anfrage fehlgeschlagen (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.Response === 'False') {
|
||||||
|
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
|
||||||
|
const year = yearMatch ? Number(yearMatch[0]) : null;
|
||||||
|
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
title: data.Title || null,
|
||||||
|
year: Number.isFinite(year) ? year : null,
|
||||||
|
imdbId: String(data.imdbID || normalizedId),
|
||||||
|
type: data.Type || null,
|
||||||
|
poster,
|
||||||
|
raw: data
|
||||||
|
};
|
||||||
|
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new OmdbService();
|
||||||
5104
backend/src/services/pipelineService.js
Normal file
5104
backend/src/services/pipelineService.js
Normal file
File diff suppressed because it is too large
Load Diff
99
backend/src/services/processRunner.js
Normal file
99
backend/src/services/processRunner.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const logger = require('./logger').child('PROCESS');
|
||||||
|
const { errorToMeta } = require('../utils/errorMeta');
|
||||||
|
|
||||||
|
function streamLines(stream, onLine) {
|
||||||
|
let buffer = '';
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
const parts = buffer.split(/\r\n|\n|\r/);
|
||||||
|
buffer = parts.pop() ?? '';
|
||||||
|
|
||||||
|
for (const line of parts) {
|
||||||
|
if (line.length > 0) {
|
||||||
|
onLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
onLine(buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnTrackedProcess({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
onStdoutLine,
|
||||||
|
onStderrLine,
|
||||||
|
onStart,
|
||||||
|
context = {}
|
||||||
|
}) {
|
||||||
|
logger.info('spawn:start', { cmd, args, cwd, context });
|
||||||
|
|
||||||
|
const child = spawn(cmd, args, {
|
||||||
|
cwd,
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onStart) {
|
||||||
|
onStart(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.stdout && onStdoutLine) {
|
||||||
|
streamLines(child.stdout, onStdoutLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.stderr && onStderrLine) {
|
||||||
|
streamLines(child.stderr, onStderrLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
|
child.on('error', (error) => {
|
||||||
|
logger.error('spawn:error', { cmd, args, context, error: errorToMeta(error) });
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
logger.info('spawn:close', { cmd, args, code, signal, context });
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ code, signal });
|
||||||
|
} else {
|
||||||
|
const error = new Error(`Prozess ${cmd} beendet mit Code ${code ?? 'null'} (Signal ${signal ?? 'none'}).`);
|
||||||
|
error.code = code;
|
||||||
|
error.signal = signal;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (child.killed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid });
|
||||||
|
child.kill('SIGINT');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) {
|
||||||
|
logger.warn('spawn:cancel:force-kill', { cmd, args, context, pid: child.pid });
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
child,
|
||||||
|
promise,
|
||||||
|
cancel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
spawnTrackedProcess
|
||||||
|
};
|
||||||
710
backend/src/services/settingsService.js
Normal file
710
backend/src/services/settingsService.js
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
|
const logger = require('./logger').child('SETTINGS');
|
||||||
|
const {
|
||||||
|
parseJson,
|
||||||
|
normalizeValueByType,
|
||||||
|
serializeValueByType,
|
||||||
|
validateSetting
|
||||||
|
} = require('../utils/validators');
|
||||||
|
const { splitArgs } = require('../utils/commandLine');
|
||||||
|
const { setLogRootDir } = require('./logPathService');
|
||||||
|
|
||||||
|
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||||
|
const SENSITIVE_SETTING_KEYS = new Set([
|
||||||
|
'makemkv_registration_key',
|
||||||
|
'omdb_api_key',
|
||||||
|
'pushover_token',
|
||||||
|
'pushover_user'
|
||||||
|
]);
|
||||||
|
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
|
||||||
|
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
|
||||||
|
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
|
||||||
|
const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']);
|
||||||
|
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
|
|
||||||
|
function applyRuntimeLogDirSetting(rawValue) {
|
||||||
|
const resolved = setLogRootDir(rawValue);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(resolved, { recursive: true });
|
||||||
|
return resolved;
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackResolved = setLogRootDir(null);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(fallbackResolved, { recursive: true });
|
||||||
|
} catch (_fallbackError) {
|
||||||
|
// ignore fallback fs errors here; logger may still print to console
|
||||||
|
}
|
||||||
|
logger.warn('setting:log-dir:fallback', {
|
||||||
|
configured: String(rawValue || '').trim() || null,
|
||||||
|
resolved,
|
||||||
|
fallbackResolved,
|
||||||
|
error: error?.message || String(error)
|
||||||
|
});
|
||||||
|
return fallbackResolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackIds(rawList) {
|
||||||
|
const list = Array.isArray(rawList) ? rawList : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const value = Number(item);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = String(Math.trunc(value));
|
||||||
|
if (seen.has(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectionArgs(extraArgs) {
|
||||||
|
const args = Array.isArray(extraArgs) ? extraArgs : [];
|
||||||
|
const filtered = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const token = String(args[i] || '');
|
||||||
|
const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
|
||||||
|
|
||||||
|
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||||
|
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||||
|
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|
||||||
|
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
|
||||||
|
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||||
|
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||||
|
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
|
||||||
|
|
||||||
|
if (!skip) {
|
||||||
|
filtered.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) {
|
||||||
|
const nextToken = String(args[i + 1] || '');
|
||||||
|
if (nextToken && !nextToken.startsWith('-')) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenPresetList(input, output = []) {
|
||||||
|
const list = Array.isArray(input) ? input : [];
|
||||||
|
for (const entry of list) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(entry.ChildrenArray) && entry.ChildrenArray.length > 0) {
|
||||||
|
flattenPresetList(entry.ChildrenArray, output);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output.push(entry);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackPresetProfile(presetName, message = null) {
|
||||||
|
return {
|
||||||
|
source: 'fallback',
|
||||||
|
message,
|
||||||
|
presetName: presetName || null,
|
||||||
|
audioTrackSelectionBehavior: 'first',
|
||||||
|
audioLanguages: [],
|
||||||
|
audioEncoders: [],
|
||||||
|
audioCopyMask: DEFAULT_AUDIO_COPY_MASK,
|
||||||
|
audioFallback: 'av_aac',
|
||||||
|
subtitleTrackSelectionBehavior: 'none',
|
||||||
|
subtitleLanguages: [],
|
||||||
|
subtitleBurnBehavior: 'none'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsService {
|
||||||
|
async getSchemaRows() {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSettingsMap() {
|
||||||
|
const rows = await this.getFlatSettings();
|
||||||
|
const map = {};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
map[row.key] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlatSettings() {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.all(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.key,
|
||||||
|
s.category,
|
||||||
|
s.label,
|
||||||
|
s.type,
|
||||||
|
s.required,
|
||||||
|
s.description,
|
||||||
|
s.default_value,
|
||||||
|
s.options_json,
|
||||||
|
s.validation_json,
|
||||||
|
s.order_index,
|
||||||
|
v.value as current_value
|
||||||
|
FROM settings_schema s
|
||||||
|
LEFT JOIN settings_values v ON v.key = s.key
|
||||||
|
ORDER BY s.category ASC, s.order_index ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
key: row.key,
|
||||||
|
category: row.category,
|
||||||
|
label: row.label,
|
||||||
|
type: row.type,
|
||||||
|
required: Boolean(row.required),
|
||||||
|
description: row.description,
|
||||||
|
defaultValue: row.default_value,
|
||||||
|
options: parseJson(row.options_json, []),
|
||||||
|
validation: parseJson(row.validation_json, {}),
|
||||||
|
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
|
||||||
|
orderIndex: row.order_index
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCategorizedSettings() {
|
||||||
|
const flat = await this.getFlatSettings();
|
||||||
|
const byCategory = new Map();
|
||||||
|
|
||||||
|
for (const item of flat) {
|
||||||
|
if (!byCategory.has(item.category)) {
|
||||||
|
byCategory.set(item.category, []);
|
||||||
|
}
|
||||||
|
byCategory.get(item.category).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byCategory.entries()).map(([category, settings]) => ({
|
||||||
|
category,
|
||||||
|
settings
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSettingValue(key, rawValue) {
|
||||||
|
const db = await getDb();
|
||||||
|
const schema = await db.get('SELECT * FROM settings_schema WHERE key = ?', [key]);
|
||||||
|
if (!schema) {
|
||||||
|
const error = new Error(`Setting ${key} existiert nicht.`);
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateSetting(schema, rawValue);
|
||||||
|
if (!result.valid) {
|
||||||
|
const error = new Error(result.errors.join(' '));
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedValue = serializeValueByType(schema.type, result.normalized);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_values (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`,
|
||||||
|
[key, serializedValue]
|
||||||
|
);
|
||||||
|
logger.info('setting:updated', {
|
||||||
|
key,
|
||||||
|
value: SENSITIVE_SETTING_KEYS.has(String(key || '').trim().toLowerCase()) ? '[redacted]' : result.normalized
|
||||||
|
});
|
||||||
|
if (String(key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY) {
|
||||||
|
applyRuntimeLogDirSetting(result.normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: result.normalized
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSettingsBulk(rawPatch) {
|
||||||
|
if (!rawPatch || typeof rawPatch !== 'object' || Array.isArray(rawPatch)) {
|
||||||
|
const error = new Error('Ungültiger Payload. Erwartet wird ein Objekt mit key/value Paaren.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(rawPatch);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const schemaRows = await db.all('SELECT * FROM settings_schema');
|
||||||
|
const schemaByKey = new Map(schemaRows.map((row) => [row.key, row]));
|
||||||
|
const normalizedEntries = [];
|
||||||
|
const validationErrors = [];
|
||||||
|
|
||||||
|
for (const [key, rawValue] of entries) {
|
||||||
|
const schema = schemaByKey.get(key);
|
||||||
|
if (!schema) {
|
||||||
|
const error = new Error(`Setting ${key} existiert nicht.`);
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateSetting(schema, rawValue);
|
||||||
|
if (!result.valid) {
|
||||||
|
validationErrors.push({
|
||||||
|
key,
|
||||||
|
message: result.errors.join(' ')
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedEntries.push({
|
||||||
|
key,
|
||||||
|
value: result.normalized,
|
||||||
|
serializedValue: serializeValueByType(schema.type, result.normalized)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
const error = new Error('Mindestens ein Setting ist ungültig.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
error.details = validationErrors;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.exec('BEGIN');
|
||||||
|
for (const item of normalizedEntries) {
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_values (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`,
|
||||||
|
[item.key, item.serializedValue]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logDirChange = normalizedEntries.find(
|
||||||
|
(item) => String(item?.key || '').trim().toLowerCase() === LOG_DIR_SETTING_KEY
|
||||||
|
);
|
||||||
|
if (logDirChange) {
|
||||||
|
applyRuntimeLogDirSetting(logDirChange.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('settings:bulk-updated', { count: normalizedEntries.length });
|
||||||
|
return normalizedEntries.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
value: item.value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildMakeMKVAnalyzeConfig(deviceInfo = null) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.makemkv_command;
|
||||||
|
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo)];
|
||||||
|
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
|
||||||
|
return { cmd, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.makemkv_command;
|
||||||
|
const sourceArg = `file:${sourcePath}`;
|
||||||
|
const args = ['-r', 'info', sourceArg];
|
||||||
|
const titleIdRaw = Number(options?.titleId);
|
||||||
|
// "makemkvcon info" supports only <source>; title filtering is done in app parser.
|
||||||
|
logger.debug('cli:makemkv:analyze:path', {
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
sourcePath,
|
||||||
|
requestedTitleId: Number.isFinite(titleIdRaw) && titleIdRaw >= 0 ? Math.trunc(titleIdRaw) : null
|
||||||
|
});
|
||||||
|
return { cmd, args, sourceArg };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.makemkv_command;
|
||||||
|
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||||||
|
? 'backup'
|
||||||
|
: 'mkv';
|
||||||
|
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
||||||
|
const rawSelectedTitleId = Number(options?.selectedTitleId);
|
||||||
|
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
||||||
|
let extra = [];
|
||||||
|
let baseArgs = [];
|
||||||
|
|
||||||
|
if (ripMode === 'backup') {
|
||||||
|
if (parsedExtra.length > 0) {
|
||||||
|
logger.warn('cli:makemkv:rip:backup:ignored-extra-args', {
|
||||||
|
ignored: parsedExtra
|
||||||
|
});
|
||||||
|
}
|
||||||
|
baseArgs = [
|
||||||
|
'backup',
|
||||||
|
'--decrypt',
|
||||||
|
sourceArg,
|
||||||
|
rawJobDir
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
extra = parsedExtra;
|
||||||
|
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||||
|
const hasExplicitTitle = Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0;
|
||||||
|
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||||
|
if (hasExplicitTitle) {
|
||||||
|
baseArgs = [
|
||||||
|
'mkv',
|
||||||
|
sourceArg,
|
||||||
|
targetTitle,
|
||||||
|
rawJobDir
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
baseArgs = [
|
||||||
|
'--minlength=' + Math.round(minLength * 60),
|
||||||
|
'mkv',
|
||||||
|
sourceArg,
|
||||||
|
targetTitle,
|
||||||
|
rawJobDir
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug('cli:makemkv:rip', {
|
||||||
|
cmd,
|
||||||
|
args: [...baseArgs, ...extra],
|
||||||
|
ripMode,
|
||||||
|
rawJobDir,
|
||||||
|
deviceInfo,
|
||||||
|
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
|
||||||
|
? Math.trunc(rawSelectedTitleId)
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
return { cmd, args: [...baseArgs, ...extra] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildMakeMKVRegisterConfig() {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const registrationKey = String(map.makemkv_registration_key || '').trim();
|
||||||
|
if (!registrationKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = map.makemkv_command || 'makemkvcon';
|
||||||
|
const args = ['reg', registrationKey];
|
||||||
|
logger.debug('cli:makemkv:register', { cmd, args: ['reg', '<redacted>'] });
|
||||||
|
return {
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
argsForLog: ['reg', '<redacted>']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildMediaInfoConfig(inputPath) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.mediainfo_command || 'mediainfo';
|
||||||
|
const baseArgs = ['--Output=JSON'];
|
||||||
|
const extra = splitArgs(map.mediainfo_extra_args);
|
||||||
|
const args = [...baseArgs, ...extra, inputPath];
|
||||||
|
logger.debug('cli:mediainfo', { cmd, args, inputPath });
|
||||||
|
return { cmd, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildHandBrakeConfig(inputFile, outputFile, options = {}) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.handbrake_command;
|
||||||
|
const rawTitleId = Number(options?.titleId);
|
||||||
|
const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
|
||||||
|
? Math.trunc(rawTitleId)
|
||||||
|
: null;
|
||||||
|
const baseArgs = ['-i', inputFile, '-o', outputFile];
|
||||||
|
if (selectedTitleId !== null) {
|
||||||
|
baseArgs.push('-t', String(selectedTitleId));
|
||||||
|
}
|
||||||
|
baseArgs.push('-Z', map.handbrake_preset);
|
||||||
|
const extra = splitArgs(map.handbrake_extra_args);
|
||||||
|
const rawSelection = options?.trackSelection || null;
|
||||||
|
const hasSelection = rawSelection && typeof rawSelection === 'object';
|
||||||
|
|
||||||
|
if (!hasSelection) {
|
||||||
|
logger.debug('cli:handbrake', {
|
||||||
|
cmd,
|
||||||
|
args: [...baseArgs, ...extra],
|
||||||
|
inputFile,
|
||||||
|
outputFile,
|
||||||
|
selectedTitleId
|
||||||
|
});
|
||||||
|
return { cmd, args: [...baseArgs, ...extra] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTrackIds = normalizeTrackIds(rawSelection.audioTrackIds);
|
||||||
|
const subtitleTrackIds = normalizeTrackIds(rawSelection.subtitleTrackIds);
|
||||||
|
const subtitleBurnTrackId = normalizeTrackIds([rawSelection.subtitleBurnTrackId])[0] || null;
|
||||||
|
const subtitleDefaultTrackId = normalizeTrackIds([rawSelection.subtitleDefaultTrackId])[0] || null;
|
||||||
|
const subtitleForcedTrackId = normalizeTrackIds([rawSelection.subtitleForcedTrackId])[0] || null;
|
||||||
|
const subtitleForcedOnly = Boolean(rawSelection.subtitleForcedOnly);
|
||||||
|
const filteredExtra = removeSelectionArgs(extra);
|
||||||
|
const overrideArgs = [
|
||||||
|
'-a',
|
||||||
|
audioTrackIds.length > 0 ? audioTrackIds.join(',') : 'none',
|
||||||
|
'-s',
|
||||||
|
subtitleTrackIds.length > 0 ? subtitleTrackIds.join(',') : 'none'
|
||||||
|
];
|
||||||
|
if (subtitleBurnTrackId !== null) {
|
||||||
|
overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`);
|
||||||
|
}
|
||||||
|
if (subtitleDefaultTrackId !== null) {
|
||||||
|
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
||||||
|
}
|
||||||
|
if (subtitleForcedTrackId !== null) {
|
||||||
|
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
||||||
|
} else if (subtitleForcedOnly) {
|
||||||
|
overrideArgs.push('--subtitle-forced');
|
||||||
|
}
|
||||||
|
const args = [...baseArgs, ...filteredExtra, ...overrideArgs];
|
||||||
|
|
||||||
|
logger.debug('cli:handbrake:with-selection', {
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
inputFile,
|
||||||
|
outputFile,
|
||||||
|
selectedTitleId,
|
||||||
|
trackSelection: {
|
||||||
|
audioTrackIds,
|
||||||
|
subtitleTrackIds,
|
||||||
|
subtitleBurnTrackId,
|
||||||
|
subtitleDefaultTrackId,
|
||||||
|
subtitleForcedTrackId,
|
||||||
|
subtitleForcedOnly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
trackSelection: {
|
||||||
|
audioTrackIds,
|
||||||
|
subtitleTrackIds,
|
||||||
|
subtitleBurnTrackId,
|
||||||
|
subtitleDefaultTrackId,
|
||||||
|
subtitleForcedTrackId,
|
||||||
|
subtitleForcedOnly
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveHandBrakeSourceArg(map, deviceInfo = null) {
|
||||||
|
if (map.drive_mode === 'explicit') {
|
||||||
|
const device = String(map.drive_device || '').trim();
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedPath = String(deviceInfo?.path || '').trim();
|
||||||
|
if (detectedPath) {
|
||||||
|
return detectedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredPath = String(map.drive_device || '').trim();
|
||||||
|
if (configuredPath) {
|
||||||
|
return configuredPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/dev/sr0';
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildHandBrakeScanConfig(deviceInfo = null) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||||
|
const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo);
|
||||||
|
// Match legacy rip.sh behavior: scan all titles, then decide in app logic.
|
||||||
|
const args = ['--scan', '--json', '-i', sourceArg, '-t', '0'];
|
||||||
|
logger.debug('cli:handbrake:scan', {
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
deviceInfo
|
||||||
|
});
|
||||||
|
return { cmd, args, sourceArg };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildHandBrakeScanConfigForInput(inputPath, options = {}) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||||
|
// RAW backup folders must be scanned as full BD source to get usable title list.
|
||||||
|
const rawTitleId = Number(options?.titleId);
|
||||||
|
const titleId = Number.isFinite(rawTitleId) && rawTitleId > 0
|
||||||
|
? Math.trunc(rawTitleId)
|
||||||
|
: 0;
|
||||||
|
const args = ['--scan', '--json', '-i', inputPath, '-t', String(titleId)];
|
||||||
|
logger.debug('cli:handbrake:scan:input', {
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
inputPath,
|
||||||
|
titleId: titleId > 0 ? titleId : null
|
||||||
|
});
|
||||||
|
return { cmd, args, sourceArg: inputPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||||
|
const presetName = map.handbrake_preset || null;
|
||||||
|
const rawTitleId = Number(options?.titleId);
|
||||||
|
const presetScanTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
|
||||||
|
? Math.trunc(rawTitleId)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
if (!presetName) {
|
||||||
|
return buildFallbackPresetProfile(null, 'Kein HandBrake-Preset konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sampleInputPath || !fs.existsSync(sampleInputPath)) {
|
||||||
|
return buildFallbackPresetProfile(
|
||||||
|
presetName,
|
||||||
|
'Preset-Export übersprungen: kein gültiger Sample-Input für HandBrake-Scan.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const exportFile = path.join(os.tmpdir(), `${exportName}.json`);
|
||||||
|
const args = [
|
||||||
|
'--scan',
|
||||||
|
'-i',
|
||||||
|
sampleInputPath,
|
||||||
|
'-t',
|
||||||
|
String(presetScanTitleId),
|
||||||
|
'-Z',
|
||||||
|
presetName,
|
||||||
|
'--preset-export',
|
||||||
|
exportName,
|
||||||
|
'--preset-export-file',
|
||||||
|
exportFile
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, args, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 180000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return buildFallbackPresetProfile(
|
||||||
|
presetName,
|
||||||
|
`Preset-Export fehlgeschlagen: ${result.error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = String(result.stderr || '').trim();
|
||||||
|
const stdout = String(result.stdout || '').trim();
|
||||||
|
const tail = stderr || stdout || `exit=${result.status}`;
|
||||||
|
return buildFallbackPresetProfile(
|
||||||
|
presetName,
|
||||||
|
`Preset-Export fehlgeschlagen (${tail.slice(0, 280)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(exportFile)) {
|
||||||
|
return buildFallbackPresetProfile(
|
||||||
|
presetName,
|
||||||
|
'Preset-Export fehlgeschlagen: Exportdatei wurde nicht erzeugt.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(exportFile, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const presetEntries = flattenPresetList(parsed?.PresetList || []);
|
||||||
|
const exported = presetEntries.find((entry) => entry.PresetName === exportName) || presetEntries[0];
|
||||||
|
|
||||||
|
if (!exported) {
|
||||||
|
return buildFallbackPresetProfile(
|
||||||
|
presetName,
|
||||||
|
'Preset-Export fehlgeschlagen: Kein Preset in Exportdatei gefunden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'preset-export',
|
||||||
|
message: null,
|
||||||
|
presetName,
|
||||||
|
audioTrackSelectionBehavior: exported.AudioTrackSelectionBehavior || 'first',
|
||||||
|
audioLanguages: Array.isArray(exported.AudioLanguageList) ? exported.AudioLanguageList : [],
|
||||||
|
audioEncoders: Array.isArray(exported.AudioList)
|
||||||
|
? exported.AudioList
|
||||||
|
.map((item) => item?.AudioEncoder)
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
|
audioCopyMask: Array.isArray(exported.AudioCopyMask)
|
||||||
|
? exported.AudioCopyMask
|
||||||
|
: DEFAULT_AUDIO_COPY_MASK,
|
||||||
|
audioFallback: exported.AudioEncoderFallback || 'av_aac',
|
||||||
|
subtitleTrackSelectionBehavior: exported.SubtitleTrackSelectionBehavior || 'none',
|
||||||
|
subtitleLanguages: Array.isArray(exported.SubtitleLanguageList) ? exported.SubtitleLanguageList : [],
|
||||||
|
subtitleBurnBehavior: exported.SubtitleBurnBehavior || 'none'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return buildFallbackPresetProfile(
|
||||||
|
presetName,
|
||||||
|
`Preset-Export Ausnahme: ${error.message}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(exportFile)) {
|
||||||
|
fs.unlinkSync(exportFile);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveSourceArg(map, deviceInfo = null) {
|
||||||
|
const mode = map.drive_mode;
|
||||||
|
if (mode === 'explicit') {
|
||||||
|
const device = map.drive_device;
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('drive_device ist leer, obwohl drive_mode=explicit gesetzt ist.');
|
||||||
|
}
|
||||||
|
return `dev:${device}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceInfo && deviceInfo.index !== undefined && deviceInfo.index !== null) {
|
||||||
|
return `disc:${deviceInfo.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `disc:${map.makemkv_source_index ?? 0}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new SettingsService();
|
||||||
65
backend/src/services/websocketService.js
Normal file
65
backend/src/services/websocketService.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const logger = require('./logger').child('WS');
|
||||||
|
|
||||||
|
class WebSocketService {
|
||||||
|
constructor() {
|
||||||
|
this.wss = null;
|
||||||
|
this.clients = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
init(httpServer) {
|
||||||
|
if (this.wss) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||||
|
|
||||||
|
this.wss.on('connection', (socket) => {
|
||||||
|
this.clients.add(socket);
|
||||||
|
logger.info('client:connected', { clients: this.clients.size });
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'WS_CONNECTED',
|
||||||
|
payload: { connectedAt: new Date().toISOString() }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.clients.delete(socket);
|
||||||
|
logger.info('client:closed', { clients: this.clients.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
this.clients.delete(socket);
|
||||||
|
logger.warn('client:error', { clients: this.clients.size });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(type, payload) {
|
||||||
|
if (!this.wss) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('broadcast', {
|
||||||
|
type,
|
||||||
|
clients: this.clients.size,
|
||||||
|
payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : []
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new WebSocketService();
|
||||||
57
backend/src/utils/commandLine.js
Normal file
57
backend/src/utils/commandLine.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
function splitArgs(input) {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [];
|
||||||
|
let current = '';
|
||||||
|
let quote = null;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (const ch of input) {
|
||||||
|
if (escaping) {
|
||||||
|
current += ch;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\\') {
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
args.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
args.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
splitArgs
|
||||||
|
};
|
||||||
1017
backend/src/utils/encodePlan.js
Normal file
1017
backend/src/utils/encodePlan.js
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/src/utils/errorMeta.js
Normal file
18
backend/src/utils/errorMeta.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
function errorToMeta(error) {
|
||||||
|
if (!error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
code: error.code,
|
||||||
|
signal: error.signal,
|
||||||
|
statusCode: error.statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
errorToMeta
|
||||||
|
};
|
||||||
70
backend/src/utils/files.js
Normal file
70
backend/src/utils/files.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function ensureDir(dirPath) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(input) {
|
||||||
|
return String(input || 'untitled')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template, values) {
|
||||||
|
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||||
|
const val = values[key.trim()];
|
||||||
|
if (val === undefined || val === null || val === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||||
|
const files = findMediaFiles(dirPath, extensions);
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.reduce((largest, file) => (largest === null || file.size > largest.size ? file : largest), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMediaFiles(dirPath, extensions = ['.mkv', '.mp4']) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function walk(current) {
|
||||||
|
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const abs = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(abs);
|
||||||
|
} else {
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
if (!extensions.includes(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(abs);
|
||||||
|
results.push({
|
||||||
|
path: abs,
|
||||||
|
size: stat.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(dirPath);
|
||||||
|
results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureDir,
|
||||||
|
sanitizeFileName,
|
||||||
|
renderTemplate,
|
||||||
|
findLargestMediaFile,
|
||||||
|
findMediaFiles
|
||||||
|
};
|
||||||
576
backend/src/utils/playlistAnalysis.js
Normal file
576
backend/src/utils/playlistAnalysis.js
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
const LARGE_JUMP_THRESHOLD = 20;
|
||||||
|
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
|
||||||
|
|
||||||
|
function parseDurationSeconds(raw) {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hms = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/);
|
||||||
|
if (hms) {
|
||||||
|
const h = Number(hms[1]);
|
||||||
|
const m = Number(hms[2]);
|
||||||
|
const s = Number(hms[3]);
|
||||||
|
return (h * 3600) + (m * 60) + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hm = text.match(/^(\d{1,2}):(\d{2})(?:\.\d+)?$/);
|
||||||
|
if (hm) {
|
||||||
|
const m = Number(hm[1]);
|
||||||
|
const s = Number(hm[2]);
|
||||||
|
return (m * 60) + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asNumber = Number(text);
|
||||||
|
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||||||
|
return Math.round(asNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const total = Number(seconds || 0);
|
||||||
|
if (!Number.isFinite(total) || total <= 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSizeBytes(raw) {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(text)) {
|
||||||
|
const direct = Number(text);
|
||||||
|
return Number.isFinite(direct) ? Math.max(0, Math.round(direct)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = text.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
|
||||||
|
if (!match) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(match[1]);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = String(match[2] || '').toUpperCase();
|
||||||
|
const factorByUnit = {
|
||||||
|
B: 1,
|
||||||
|
KB: 1024,
|
||||||
|
MB: 1024 ** 2,
|
||||||
|
GB: 1024 ** 3,
|
||||||
|
TB: 1024 ** 4
|
||||||
|
};
|
||||||
|
const factor = factorByUnit[unit] || 1;
|
||||||
|
return Math.max(0, Math.round(value * factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaylistId(raw) {
|
||||||
|
const value = String(raw || '').trim().toLowerCase();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/(\d{1,5})(?:\.mpls)?$/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(match[1]).padStart(5, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSegmentFile(segmentNumber) {
|
||||||
|
const value = Number(segmentNumber);
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${String(Math.trunc(value)).padStart(5, '0')}.m2ts`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSegmentNumbers(raw) {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = text.match(/\d{1,6}/g) || [];
|
||||||
|
return matches
|
||||||
|
.map((item) => Number(item))
|
||||||
|
.filter((value) => Number.isFinite(value) && value >= 0)
|
||||||
|
.map((value) => Math.trunc(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlaylistMapping(line) {
|
||||||
|
const raw = String(line || '');
|
||||||
|
|
||||||
|
// Robot message typically maps playlist to title id.
|
||||||
|
const msgMatch = raw.match(/MSG:3016.*,"(\d{5}\.mpls)","(\d+)"/i);
|
||||||
|
if (msgMatch) {
|
||||||
|
return {
|
||||||
|
playlistId: normalizePlaylistId(msgMatch[1]),
|
||||||
|
titleId: Number(msgMatch[2])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMatch = raw.match(/(?:file|datei)\s+(\d{5}\.mpls).*?(?:title\s*#|titel\s*#?\s*)(\d+)/i);
|
||||||
|
if (textMatch) {
|
||||||
|
return {
|
||||||
|
playlistId: normalizePlaylistId(textMatch[1]),
|
||||||
|
titleId: Number(textMatch[2])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnalyzeTitles(lines) {
|
||||||
|
const titleMap = new Map();
|
||||||
|
|
||||||
|
const ensureTitle = (titleId) => {
|
||||||
|
if (!titleMap.has(titleId)) {
|
||||||
|
titleMap.set(titleId, {
|
||||||
|
titleId,
|
||||||
|
playlistId: null,
|
||||||
|
playlistIdFromMap: null,
|
||||||
|
playlistIdFromField16: null,
|
||||||
|
playlistFile: null,
|
||||||
|
durationSeconds: 0,
|
||||||
|
durationLabel: null,
|
||||||
|
sizeBytes: 0,
|
||||||
|
sizeLabel: null,
|
||||||
|
chapters: 0,
|
||||||
|
segmentNumbers: [],
|
||||||
|
segmentFiles: [],
|
||||||
|
fields: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return titleMap.get(titleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines || []) {
|
||||||
|
const mapping = extractPlaylistMapping(line);
|
||||||
|
if (mapping && Number.isFinite(mapping.titleId) && mapping.titleId >= 0) {
|
||||||
|
const title = ensureTitle(mapping.titleId);
|
||||||
|
title.playlistIdFromMap = normalizePlaylistId(mapping.playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tinfo = String(line || '').match(/^TINFO:(\d+),(\d+),\d+,"([^"]*)"/i);
|
||||||
|
if (!tinfo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleId = Number(tinfo[1]);
|
||||||
|
const fieldId = Number(tinfo[2]);
|
||||||
|
const value = String(tinfo[3] || '').trim();
|
||||||
|
if (!Number.isFinite(titleId) || titleId < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = ensureTitle(titleId);
|
||||||
|
title.fields[fieldId] = value;
|
||||||
|
|
||||||
|
if (fieldId === 16) {
|
||||||
|
const fromField = normalizePlaylistId(value);
|
||||||
|
if (fromField) {
|
||||||
|
title.playlistIdFromField16 = fromField;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldId === 26) {
|
||||||
|
const segmentNumbers = parseSegmentNumbers(value);
|
||||||
|
if (segmentNumbers.length > 0) {
|
||||||
|
title.segmentNumbers = segmentNumbers;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldId === 9) {
|
||||||
|
const seconds = parseDurationSeconds(value);
|
||||||
|
if (seconds > 0) {
|
||||||
|
title.durationSeconds = seconds;
|
||||||
|
title.durationLabel = formatDuration(seconds);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldId === 10 || fieldId === 11) {
|
||||||
|
const bytes = parseSizeBytes(value);
|
||||||
|
if (bytes > 0) {
|
||||||
|
title.sizeBytes = bytes;
|
||||||
|
title.sizeLabel = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldId === 8 || fieldId === 7) {
|
||||||
|
const chapters = Number(value);
|
||||||
|
if (Number.isFinite(chapters) && chapters >= 0) {
|
||||||
|
title.chapters = Math.trunc(chapters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title.durationSeconds && /\d+:\d{2}:\d{2}/.test(value)) {
|
||||||
|
const seconds = parseDurationSeconds(value);
|
||||||
|
if (seconds > 0) {
|
||||||
|
title.durationSeconds = seconds;
|
||||||
|
title.durationLabel = formatDuration(seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title.sizeBytes && /(kb|mb|gb|tb)\b/i.test(value)) {
|
||||||
|
const bytes = parseSizeBytes(value);
|
||||||
|
if (bytes > 0) {
|
||||||
|
title.sizeBytes = bytes;
|
||||||
|
title.sizeLabel = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(titleMap.values())
|
||||||
|
.map((item) => {
|
||||||
|
const playlistId = normalizePlaylistId(item.playlistId);
|
||||||
|
const playlistIdFromMap = normalizePlaylistId(item.playlistIdFromMap);
|
||||||
|
const playlistIdFromField16 = normalizePlaylistId(item.playlistIdFromField16);
|
||||||
|
// Prefer explicit title<->playlist map lines from MakeMKV (MSG:3016).
|
||||||
|
const resolvedPlaylistId = playlistIdFromMap || playlistIdFromField16 || playlistId;
|
||||||
|
const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : [];
|
||||||
|
const segmentFiles = segmentNumbers
|
||||||
|
.map((number) => toSegmentFile(number))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
playlistId: resolvedPlaylistId,
|
||||||
|
playlistIdFromMap,
|
||||||
|
playlistIdFromField16,
|
||||||
|
playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null,
|
||||||
|
durationLabel: item.durationLabel || formatDuration(item.durationSeconds),
|
||||||
|
segmentNumbers,
|
||||||
|
segmentFiles
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.titleId - b.titleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueOrdered(values) {
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of values || []) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
output.push(String(value).trim());
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
|
||||||
|
const list = Array.isArray(candidates) ? [...candidates] : [];
|
||||||
|
const tolerance = Math.max(0, Math.round(Number(durationSimilaritySeconds || 0)));
|
||||||
|
const groups = [];
|
||||||
|
const used = new Set();
|
||||||
|
|
||||||
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
|
if (used.has(i)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = list[i];
|
||||||
|
const currentGroup = [base];
|
||||||
|
used.add(i);
|
||||||
|
|
||||||
|
for (let j = i + 1; j < list.length; j += 1) {
|
||||||
|
if (used.has(j)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = list[j];
|
||||||
|
if (Math.abs(Number(candidate.durationSeconds || 0) - Number(base.durationSeconds || 0)) <= tolerance) {
|
||||||
|
currentGroup.push(candidate);
|
||||||
|
used.add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGroup.length > 1) {
|
||||||
|
const sortedTitles = currentGroup
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||||||
|
const referenceDuration = Number(sortedTitles[0]?.durationSeconds || 0);
|
||||||
|
groups.push({
|
||||||
|
durationSeconds: referenceDuration,
|
||||||
|
durationLabel: formatDuration(referenceDuration),
|
||||||
|
titles: sortedTitles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.sort((a, b) =>
|
||||||
|
b.durationSeconds - a.durationSeconds || b.titles.length - a.titles.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSegmentMetrics(segmentNumbers) {
|
||||||
|
const numbers = Array.isArray(segmentNumbers)
|
||||||
|
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (numbers.length === 0) {
|
||||||
|
return {
|
||||||
|
segmentCount: 0,
|
||||||
|
segmentNumbers: [],
|
||||||
|
directSequenceSteps: 0,
|
||||||
|
backwardJumps: 0,
|
||||||
|
largeJumps: 0,
|
||||||
|
alternatingJumps: 0,
|
||||||
|
alternatingPairs: 0,
|
||||||
|
alternatingRatio: 0,
|
||||||
|
sequenceCoherence: 0,
|
||||||
|
monotonicRatio: 0,
|
||||||
|
score: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let directSequenceSteps = 0;
|
||||||
|
let backwardJumps = 0;
|
||||||
|
let largeJumps = 0;
|
||||||
|
let alternatingJumps = 0;
|
||||||
|
let alternatingPairs = 0;
|
||||||
|
let prevDiff = null;
|
||||||
|
|
||||||
|
for (let i = 1; i < numbers.length; i += 1) {
|
||||||
|
const current = numbers[i - 1];
|
||||||
|
const next = numbers[i];
|
||||||
|
const diff = next - current;
|
||||||
|
|
||||||
|
if (next < current) {
|
||||||
|
backwardJumps += 1;
|
||||||
|
}
|
||||||
|
if (Math.abs(diff) > LARGE_JUMP_THRESHOLD) {
|
||||||
|
largeJumps += 1;
|
||||||
|
}
|
||||||
|
if (diff === 1) {
|
||||||
|
directSequenceSteps += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevDiff !== null) {
|
||||||
|
const largePair = Math.abs(prevDiff) > LARGE_JUMP_THRESHOLD && Math.abs(diff) > LARGE_JUMP_THRESHOLD;
|
||||||
|
if (largePair) {
|
||||||
|
alternatingPairs += 1;
|
||||||
|
const signChanged = (prevDiff < 0 && diff > 0) || (prevDiff > 0 && diff < 0);
|
||||||
|
if (signChanged) {
|
||||||
|
alternatingJumps += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevDiff = diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions = Math.max(1, numbers.length - 1);
|
||||||
|
const sequenceCoherence = Number((directSequenceSteps / transitions).toFixed(4));
|
||||||
|
const alternatingRatio = alternatingPairs > 0
|
||||||
|
? Number((alternatingJumps / alternatingPairs).toFixed(4))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const score = (directSequenceSteps * 2) - (backwardJumps * 3) - (largeJumps * 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
segmentCount: numbers.length,
|
||||||
|
segmentNumbers: numbers,
|
||||||
|
directSequenceSteps,
|
||||||
|
backwardJumps,
|
||||||
|
largeJumps,
|
||||||
|
alternatingJumps,
|
||||||
|
alternatingPairs,
|
||||||
|
alternatingRatio,
|
||||||
|
sequenceCoherence,
|
||||||
|
monotonicRatio: sequenceCoherence,
|
||||||
|
score
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvaluationLabel(metrics) {
|
||||||
|
if (!metrics || metrics.segmentCount === 0) {
|
||||||
|
return 'Keine Segmentliste aus TINFO:26 verfügbar';
|
||||||
|
}
|
||||||
|
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
|
||||||
|
return 'Fake-Struktur (alternierendes Sprungmuster)';
|
||||||
|
}
|
||||||
|
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
|
||||||
|
return 'Auffällige Segmentreihenfolge';
|
||||||
|
}
|
||||||
|
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidates(groupTitles) {
|
||||||
|
const titles = Array.isArray(groupTitles) ? groupTitles : [];
|
||||||
|
if (titles.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return titles
|
||||||
|
.map((title) => {
|
||||||
|
const metrics = computeSegmentMetrics(title.segmentNumbers);
|
||||||
|
const reasons = [
|
||||||
|
`sequence_steps=${metrics.directSequenceSteps}`,
|
||||||
|
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
|
||||||
|
`backward_jumps=${metrics.backwardJumps}`,
|
||||||
|
`large_jumps=${metrics.largeJumps}`,
|
||||||
|
`alternating_ratio=${metrics.alternatingRatio.toFixed(3)}`
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...title,
|
||||||
|
score: Number(metrics.score || 0),
|
||||||
|
reasons,
|
||||||
|
structuralMetrics: metrics,
|
||||||
|
evaluationLabel: buildEvaluationLabel(metrics)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.score - a.score
|
||||||
|
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|
||||||
|
|| b.durationSeconds - a.durationSeconds
|
||||||
|
|| b.sizeBytes - a.sizeBytes
|
||||||
|
|| a.titleId - b.titleId
|
||||||
|
)
|
||||||
|
.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
recommended: index === 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistSegmentMap(titles) {
|
||||||
|
const map = {};
|
||||||
|
for (const title of titles || []) {
|
||||||
|
const playlistId = normalizePlaylistId(title?.playlistId);
|
||||||
|
if (!playlistId || map[playlistId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
map[playlistId] = {
|
||||||
|
playlistId,
|
||||||
|
playlistFile: `${playlistId}.mpls`,
|
||||||
|
playlistPath: `BDMV/PLAYLIST/${playlistId}.mpls`,
|
||||||
|
segmentCommand: `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
||||||
|
segmentFiles: Array.isArray(title?.segmentFiles) ? title.segmentFiles : [],
|
||||||
|
segmentNumbers: Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [],
|
||||||
|
fileExists: null,
|
||||||
|
source: 'makemkv_tinfo_26'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistToTitleIdMap(titles) {
|
||||||
|
const map = {};
|
||||||
|
for (const title of titles || []) {
|
||||||
|
const playlistId = normalizePlaylistId(title?.playlistId || title?.playlistFile || null);
|
||||||
|
const titleId = Number(title?.titleId);
|
||||||
|
if (!playlistId || !Number.isFinite(titleId) || titleId < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedTitleId = Math.trunc(titleId);
|
||||||
|
if (map[playlistId] === undefined) {
|
||||||
|
map[playlistId] = normalizedTitleId;
|
||||||
|
}
|
||||||
|
const playlistFile = `${playlistId}.mpls`;
|
||||||
|
if (map[playlistFile] === undefined) {
|
||||||
|
map[playlistFile] = normalizedTitleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWarningLines(lines) {
|
||||||
|
return (Array.isArray(lines) ? lines : [])
|
||||||
|
.filter((line) => /warn|warning|error|fehler|decode|decoder|timeout|corrupt/i.test(String(line || '')))
|
||||||
|
.slice(0, 40)
|
||||||
|
.map((line) => String(line || '').slice(0, 260));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlaylistMismatchWarnings(titles) {
|
||||||
|
return (Array.isArray(titles) ? titles : [])
|
||||||
|
.filter((title) => title?.playlistIdFromMap && title?.playlistIdFromField16)
|
||||||
|
.filter((title) => String(title.playlistIdFromMap) !== String(title.playlistIdFromField16))
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((title) =>
|
||||||
|
`Titel #${title.titleId}: MSG-Playlist=${title.playlistIdFromMap}.mpls, TINFO16=${title.playlistIdFromField16}.mpls (MSG bevorzugt)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) {
|
||||||
|
const parsedTitles = parseAnalyzeTitles(lines);
|
||||||
|
const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60));
|
||||||
|
const durationSimilaritySeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = parsedTitles
|
||||||
|
.filter((item) => Number(item.durationSeconds || 0) >= minSeconds)
|
||||||
|
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||||||
|
|
||||||
|
const similarityGroups = buildSimilarityGroups(candidates, durationSimilaritySeconds);
|
||||||
|
const obfuscationDetected = similarityGroups.length > 0;
|
||||||
|
const primaryGroup = similarityGroups[0] || null;
|
||||||
|
const evaluatedCandidates = primaryGroup ? scoreCandidates(primaryGroup.titles) : [];
|
||||||
|
const recommendation = evaluatedCandidates[0] || null;
|
||||||
|
const candidatePlaylists = primaryGroup
|
||||||
|
? uniqueOrdered(primaryGroup.titles.map((item) => item.playlistId).filter(Boolean))
|
||||||
|
: [];
|
||||||
|
const playlistSegments = buildPlaylistSegmentMap(primaryGroup ? primaryGroup.titles : []);
|
||||||
|
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
minLengthMinutes: Number(minLengthMinutes || 0),
|
||||||
|
minLengthSeconds: minSeconds,
|
||||||
|
durationSimilaritySeconds,
|
||||||
|
titles: parsedTitles,
|
||||||
|
candidates,
|
||||||
|
duplicateDurationGroups: similarityGroups,
|
||||||
|
obfuscationDetected,
|
||||||
|
manualDecisionRequired: obfuscationDetected,
|
||||||
|
candidatePlaylists,
|
||||||
|
candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`),
|
||||||
|
playlistToTitleId,
|
||||||
|
recommendation: recommendation
|
||||||
|
? {
|
||||||
|
titleId: recommendation.titleId,
|
||||||
|
playlistId: recommendation.playlistId,
|
||||||
|
score: Number(recommendation.score || 0),
|
||||||
|
reason: Array.isArray(recommendation.reasons) && recommendation.reasons.length > 0
|
||||||
|
? recommendation.reasons.join('; ')
|
||||||
|
: 'höchster Struktur-Score'
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
evaluatedCandidates,
|
||||||
|
playlistSegments,
|
||||||
|
structuralAnalysis: {
|
||||||
|
method: 'makemkv_tinfo_26',
|
||||||
|
sourceCommand: 'makemkvcon -r info disc:0 --robot',
|
||||||
|
analyzedPlaylists: Object.keys(playlistSegments).length
|
||||||
|
},
|
||||||
|
warningLines: [
|
||||||
|
...extractWarningLines(lines),
|
||||||
|
...extractPlaylistMismatchWarnings(parsedTitles)
|
||||||
|
].slice(0, 60)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizePlaylistId,
|
||||||
|
analyzePlaylistObfuscation
|
||||||
|
};
|
||||||
72
backend/src/utils/progressParsers.js
Normal file
72
backend/src/utils/progressParsers.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
function clampPercent(value) {
|
||||||
|
if (Number.isNaN(value) || value === Infinity || value === -Infinity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, Number(value.toFixed(2))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGenericPercent(line) {
|
||||||
|
const match = line.match(/(\d{1,3}(?:\.\d+)?)\s?%/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clampPercent(Number(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEta(line) {
|
||||||
|
const etaMatch = line.match(/ETA\s+([0-9:.hms-]+)/i);
|
||||||
|
if (!etaMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = etaMatch[1].trim();
|
||||||
|
if (!value || value.includes('--')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.replace(/[),.;]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMakeMkvProgress(line) {
|
||||||
|
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
|
||||||
|
if (prgv) {
|
||||||
|
const a = Number(prgv[1]);
|
||||||
|
const b = Number(prgv[2]);
|
||||||
|
const c = Number(prgv[3]);
|
||||||
|
|
||||||
|
if (c > 0) {
|
||||||
|
return { percent: clampPercent((a / c) * 100), eta: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b > 0) {
|
||||||
|
return { percent: clampPercent((a / b) * 100), eta: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = parseGenericPercent(line);
|
||||||
|
if (percent !== null) {
|
||||||
|
return { percent, eta: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHandBrakeProgress(line) {
|
||||||
|
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const match = normalized.match(/Encoding:\s*(?:task\s+\d+\s+of\s+\d+,\s*)?(\d+(?:\.\d+)?)\s?%/i);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
percent: clampPercent(Number(match[1])),
|
||||||
|
eta: parseEta(normalized)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseMakeMkvProgress,
|
||||||
|
parseHandBrakeProgress
|
||||||
|
};
|
||||||
112
backend/src/utils/validators.js
Normal file
112
backend/src/utils/validators.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
function parseJson(value, fallback = null) {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoolean(value) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'true' || value === '1' || value === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'false' || value === '0' || value === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValueByType(type, rawValue) {
|
||||||
|
if (rawValue === undefined || rawValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'number':
|
||||||
|
return Number(rawValue);
|
||||||
|
case 'boolean':
|
||||||
|
return toBoolean(rawValue);
|
||||||
|
case 'select':
|
||||||
|
case 'string':
|
||||||
|
case 'path':
|
||||||
|
default:
|
||||||
|
return String(rawValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeValueByType(type, value) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSetting(schemaItem, value) {
|
||||||
|
const errors = [];
|
||||||
|
const normalized = normalizeValueByType(schemaItem.type, value);
|
||||||
|
|
||||||
|
if (schemaItem.required) {
|
||||||
|
const emptyString = typeof normalized === 'string' && normalized.trim().length === 0;
|
||||||
|
if (normalized === null || emptyString) {
|
||||||
|
errors.push('Wert ist erforderlich.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemaItem.type === 'number' && normalized !== null) {
|
||||||
|
if (Number.isNaN(normalized)) {
|
||||||
|
errors.push('Ungültige Zahl.');
|
||||||
|
} else {
|
||||||
|
const rules = parseJson(schemaItem.validation_json, {});
|
||||||
|
if (typeof rules.min === 'number' && normalized < rules.min) {
|
||||||
|
errors.push(`Wert muss >= ${rules.min} sein.`);
|
||||||
|
}
|
||||||
|
if (typeof rules.max === 'number' && normalized > rules.max) {
|
||||||
|
errors.push(`Wert muss <= ${rules.max} sein.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemaItem.type === 'select' && normalized !== null) {
|
||||||
|
const options = parseJson(schemaItem.options_json, []);
|
||||||
|
const values = options.map((option) => option.value);
|
||||||
|
if (!values.includes(normalized)) {
|
||||||
|
errors.push('Ungültige Auswahl.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((schemaItem.type === 'path' || schemaItem.type === 'string') && normalized !== null) {
|
||||||
|
const rules = parseJson(schemaItem.validation_json, {});
|
||||||
|
if (typeof rules.minLength === 'number' && normalized.length < rules.minLength) {
|
||||||
|
errors.push(`Wert muss mindestens ${rules.minLength} Zeichen haben.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
normalized
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseJson,
|
||||||
|
normalizeValueByType,
|
||||||
|
serializeValueByType,
|
||||||
|
validateSetting,
|
||||||
|
toBoolean
|
||||||
|
};
|
||||||
65
db/schema.sql
Normal file
65
db/schema.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE settings_schema (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
required INTEGER NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
default_value TEXT,
|
||||||
|
options_json TEXT,
|
||||||
|
validation_json TEXT,
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE settings_values (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (key) REFERENCES settings_schema(key) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT,
|
||||||
|
year INTEGER,
|
||||||
|
imdb_id TEXT,
|
||||||
|
poster_url TEXT,
|
||||||
|
omdb_json TEXT,
|
||||||
|
selected_from_omdb INTEGER DEFAULT 0,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
output_path TEXT,
|
||||||
|
disc_device TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
detected_title TEXT,
|
||||||
|
last_state TEXT,
|
||||||
|
raw_path TEXT,
|
||||||
|
makemkv_info_json TEXT,
|
||||||
|
handbrake_info_json TEXT,
|
||||||
|
mediainfo_info_json TEXT,
|
||||||
|
encode_plan_json TEXT,
|
||||||
|
encode_input_path TEXT,
|
||||||
|
encode_review_confirmed INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_jobs_status ON jobs(status);
|
||||||
|
CREATE INDEX idx_jobs_created_at ON jobs(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE pipeline_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
active_job_id INTEGER,
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
eta TEXT,
|
||||||
|
status_text TEXT,
|
||||||
|
context_json TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (active_job_id) REFERENCES jobs(id)
|
||||||
|
);
|
||||||
39
deploy-ripster.sh
Executable file
39
deploy-ripster.sh
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE_USER="michael"
|
||||||
|
REMOTE_HOST="10.10.10.24"
|
||||||
|
REMOTE_PATH="/home/michael/ripster"
|
||||||
|
SSH_PASSWORD="rabenNest7$"
|
||||||
|
|
||||||
|
LOCAL_PATH="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
|
||||||
|
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
|
||||||
|
DATA_RELATIVE_DIR="backend/data/***"
|
||||||
|
|
||||||
|
if ! command -v sshpass >/dev/null 2>&1; then
|
||||||
|
echo "sshpass ist nicht installiert. Bitte installieren, z. B.: sudo apt-get install -y sshpass"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SSH_PASSWORD" == "CHANGE_ME" ]]; then
|
||||||
|
echo "Bitte in deploy-ripster.sh den Wert von SSH_PASSWORD setzen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Pruefe SSH-Verbindung zu ${REMOTE_USER}@${REMOTE_HOST} ..."
|
||||||
|
sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "echo connected" >/dev/null
|
||||||
|
|
||||||
|
echo "Stelle sicher, dass Remote-Ordner ${REMOTE_PATH} existiert ..."
|
||||||
|
sshpass -p "$SSH_PASSWORD" ssh $SSH_OPTS "${REMOTE_USER}@${REMOTE_HOST}" "set -euo pipefail; mkdir -p '${REMOTE_PATH}'"
|
||||||
|
|
||||||
|
echo "Uebertrage lokalen Ordner ${LOCAL_PATH} nach ${REMOTE_TARGET} ..."
|
||||||
|
echo "backend/data wird weder uebertragen noch auf dem Ziel geloescht: ${DATA_RELATIVE_DIR}"
|
||||||
|
sshpass -p "$SSH_PASSWORD" rsync -az --progress --delete \
|
||||||
|
--exclude "${DATA_RELATIVE_DIR}" \
|
||||||
|
--filter "protect ${DATA_RELATIVE_DIR}" \
|
||||||
|
--filter "protect debug" \
|
||||||
|
-e "ssh $SSH_OPTS" \
|
||||||
|
"${LOCAL_PATH}/" "${REMOTE_TARGET}/"
|
||||||
|
|
||||||
|
echo "Fertig: ${LOCAL_PATH} wurde nach ${REMOTE_TARGET} uebertragen (backend/data ausgenommen)."
|
||||||
1389
dev-script.sh
Executable file
1389
dev-script.sh
Executable file
File diff suppressed because it is too large
Load Diff
222
docs/api/history.md
Normal file
222
docs/api/history.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# History API
|
||||||
|
|
||||||
|
Endpunkte für die Job-Histoire, Dateimanagement und Orphan-Import.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/history
|
||||||
|
|
||||||
|
Gibt eine Liste aller Jobs zurück, optional gefiltert.
|
||||||
|
|
||||||
|
**Query-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Typ | Beschreibung |
|
||||||
|
|----------|-----|-------------|
|
||||||
|
| `status` | string | Filtert nach Status (z.B. `FINISHED`, `ERROR`) |
|
||||||
|
| `search` | string | Sucht in Filmtiteln |
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history?status=FINISHED&search=Inception
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"title": "Inception",
|
||||||
|
"imdb_id": "tt1375666",
|
||||||
|
"omdb_year": "2010",
|
||||||
|
"omdb_type": "movie",
|
||||||
|
"omdb_poster": "https://...",
|
||||||
|
"raw_path": "/mnt/nas/raw/Inception_t00.mkv",
|
||||||
|
"output_path": "/mnt/nas/movies/Inception (2010).mkv",
|
||||||
|
"created_at": "2024-01-15T10:00:00.000Z",
|
||||||
|
"updated_at": "2024-01-15T12:30:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/history/:id
|
||||||
|
|
||||||
|
Gibt Detail-Informationen für einen einzelnen Job zurück.
|
||||||
|
|
||||||
|
**URL-Parameter:** `id` – Job-ID
|
||||||
|
|
||||||
|
**Query-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Typ | Standard | Beschreibung |
|
||||||
|
|----------|-----|---------|-------------|
|
||||||
|
| `includeLogs` | boolean | `false` | Log-Inhalte einschließen |
|
||||||
|
| `includeLiveLog` | boolean | `false` | Aktuellen Live-Log einschließen |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"status": "FINISHED",
|
||||||
|
"title": "Inception",
|
||||||
|
"imdb_id": "tt1375666",
|
||||||
|
"encode_plan": { ... },
|
||||||
|
"makemkv_output": { ... },
|
||||||
|
"mediainfo_output": { ... },
|
||||||
|
"handbrake_log": "/path/to/log",
|
||||||
|
"logs": {
|
||||||
|
"handbrake": "Encoding: task 1 of 1, 100.0%\n..."
|
||||||
|
},
|
||||||
|
"created_at": "2024-01-15T10:00:00.000Z",
|
||||||
|
"updated_at": "2024-01-15T12:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/history/database
|
||||||
|
|
||||||
|
Gibt alle rohen Datenbankzeilen zurück (Debug-Ansicht).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": [ { "id": 1, "status": "FINISHED", ... } ],
|
||||||
|
"total": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/history/orphan-raw
|
||||||
|
|
||||||
|
Findet Raw-Ordner, die nicht als Jobs in der Datenbank registriert sind.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orphans": [
|
||||||
|
{
|
||||||
|
"path": "/mnt/nas/raw/UnknownMovie_2023-12-01",
|
||||||
|
"size": "45.2 GB",
|
||||||
|
"modifiedAt": "2023-12-01T15:00:00.000Z",
|
||||||
|
"files": ["t00.mkv", "t01.mkv"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/history/orphan-raw/import
|
||||||
|
|
||||||
|
Importiert einen Orphan-Raw-Ordner als Job in die Datenbank.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "/mnt/nas/raw/UnknownMovie_2023-12-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"jobId": 99,
|
||||||
|
"message": "Orphan-Ordner als Job importiert"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach dem Import kann dem Job über `/api/history/:id/omdb/assign` Metadaten zugewiesen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/history/:id/omdb/assign
|
||||||
|
|
||||||
|
Weist einem bestehenden Job OMDb-Metadaten nachträglich zu.
|
||||||
|
|
||||||
|
**URL-Parameter:** `id` – Job-ID
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"imdbId": "tt1375666",
|
||||||
|
"title": "Inception",
|
||||||
|
"year": "2010",
|
||||||
|
"type": "movie",
|
||||||
|
"poster": "https://..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/history/:id/delete-files
|
||||||
|
|
||||||
|
Löscht die Dateien eines Jobs (Raw und/oder Output), behält den Job-Eintrag.
|
||||||
|
|
||||||
|
**URL-Parameter:** `id` – Job-ID
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleteRaw": true,
|
||||||
|
"deleteOutput": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"deleted": {
|
||||||
|
"raw": "/mnt/nas/raw/Inception_t00.mkv",
|
||||||
|
"output": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/history/:id/delete
|
||||||
|
|
||||||
|
Löscht den Job-Eintrag aus der Datenbank, optional auch die Dateien.
|
||||||
|
|
||||||
|
**URL-Parameter:** `id` – Job-ID
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleteFiles": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Job gelöscht" }
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "Unwiderruflich"
|
||||||
|
Das Löschen von Jobs und Dateien ist nicht rückgängig zu machen.
|
||||||
85
docs/api/index.md
Normal file
85
docs/api/index.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# API-Referenz
|
||||||
|
|
||||||
|
Ripster bietet eine **REST-API** für alle Operationen sowie einen **WebSocket-Endpunkt** für Echtzeit-Updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basis-URL
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
Konfigurierbar über die Umgebungsvariable `PORT`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Gruppen
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-pipe: **Pipeline API**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Pipeline-Steuerung: Analyse starten, Metadaten setzen, Ripping und Encoding steuern.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Pipeline API](pipeline.md)
|
||||||
|
|
||||||
|
- :material-cog: **Settings API**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Einstellungen lesen und schreiben.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Settings API](settings.md)
|
||||||
|
|
||||||
|
- :material-history: **History API**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Job-Geschichte abfragen, Jobs löschen, Orphan-Ordner importieren.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: History API](history.md)
|
||||||
|
|
||||||
|
- :material-lightning-bolt: **WebSocket Events**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Echtzeit-Events für Pipeline-Status, Fortschritt und Disc-Erkennung.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: WebSocket](websocket.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
Die API hat **keine Authentifizierung**. Sie ist für den Einsatz im lokalen Netzwerk konzipiert.
|
||||||
|
|
||||||
|
!!! warning "Produktionsbetrieb"
|
||||||
|
Falls Ripster öffentlich erreichbar sein soll, schütze die API mit einem Reverse-Proxy (z. B. nginx mit Basic Auth oder OAuth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerformat
|
||||||
|
|
||||||
|
Alle API-Fehler werden im folgenden Format zurückgegeben:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Job nicht gefunden",
|
||||||
|
"details": "Kein Job mit ID 999 vorhanden"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP-Statuscodes:
|
||||||
|
|
||||||
|
| Code | Bedeutung |
|
||||||
|
|-----|-----------|
|
||||||
|
| `200` | Erfolg |
|
||||||
|
| `400` | Ungültige Anfrage |
|
||||||
|
| `404` | Ressource nicht gefunden |
|
||||||
|
| `409` | Konflikt (z.B. Pipeline bereits aktiv) |
|
||||||
|
| `500` | Interner Serverfehler |
|
||||||
249
docs/api/pipeline.md
Normal file
249
docs/api/pipeline.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Pipeline API
|
||||||
|
|
||||||
|
Alle Endpunkte zur Steuerung des Ripping-Workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/pipeline/state
|
||||||
|
|
||||||
|
Gibt den aktuellen Pipeline-Zustand zurück.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"state": "ENCODING",
|
||||||
|
"jobId": 42,
|
||||||
|
"job": {
|
||||||
|
"id": 42,
|
||||||
|
"title": "Inception",
|
||||||
|
"status": "ENCODING",
|
||||||
|
"imdb_id": "tt1375666",
|
||||||
|
"omdb_year": "2010"
|
||||||
|
},
|
||||||
|
"progress": 73.5,
|
||||||
|
"eta": "00:12:34",
|
||||||
|
"updatedAt": "2024-01-15T14:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**States:**
|
||||||
|
|
||||||
|
| Wert | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| `IDLE` | Wartet auf Disc |
|
||||||
|
| `ANALYZING` | MakeMKV analysiert |
|
||||||
|
| `METADATA_SELECTION` | Wartet auf Benutzer |
|
||||||
|
| `READY_TO_START` | Bereit zum Starten |
|
||||||
|
| `RIPPING` | Rippen läuft |
|
||||||
|
| `MEDIAINFO_CHECK` | Track-Analyse |
|
||||||
|
| `READY_TO_ENCODE` | Wartet auf Bestätigung |
|
||||||
|
| `ENCODING` | Encoding läuft |
|
||||||
|
| `FINISHED` | Abgeschlossen |
|
||||||
|
| `ERROR` | Fehler |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/analyze
|
||||||
|
|
||||||
|
Startet eine manuelle Disc-Analyse (ohne Disc-Detection-Trigger).
|
||||||
|
|
||||||
|
**Request:** Kein Body erforderlich
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Analyse gestartet" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fehlerfälle:**
|
||||||
|
- `409` – Pipeline bereits aktiv
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/rescan-disc
|
||||||
|
|
||||||
|
Erzwingt eine erneute Disc-Erkennung.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/pipeline/omdb/search
|
||||||
|
|
||||||
|
Sucht in der OMDb-API nach einem Filmtitel.
|
||||||
|
|
||||||
|
**Query-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Typ | Beschreibung |
|
||||||
|
|----------|-----|-------------|
|
||||||
|
| `q` | string | Suchbegriff (Filmtitel) |
|
||||||
|
| `type` | string | `movie` oder `series` (optional) |
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/pipeline/omdb/search?q=Inception&type=movie
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"imdbId": "tt1375666",
|
||||||
|
"title": "Inception",
|
||||||
|
"year": "2010",
|
||||||
|
"type": "movie",
|
||||||
|
"poster": "https://m.media-amazon.com/images/..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/select-metadata
|
||||||
|
|
||||||
|
Bestätigt Metadaten und Playlist-Auswahl für den aktuellen Job.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": 42,
|
||||||
|
"omdb": {
|
||||||
|
"imdbId": "tt1375666",
|
||||||
|
"title": "Inception",
|
||||||
|
"year": "2010",
|
||||||
|
"type": "movie",
|
||||||
|
"poster": "https://..."
|
||||||
|
},
|
||||||
|
"playlist": "00800.mpls"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`playlist` ist optional und nur bei Blu-rays relevant.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/start/:jobId
|
||||||
|
|
||||||
|
Startet den Ripping-Prozess für einen vorbereiteten Job.
|
||||||
|
|
||||||
|
**URL-Parameter:** `jobId` – ID des Jobs
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Ripping gestartet" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fehlerfälle:**
|
||||||
|
- `404` – Job nicht gefunden
|
||||||
|
- `409` – Job nicht im Status `READY_TO_START`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/confirm-encode/:jobId
|
||||||
|
|
||||||
|
Bestätigt die Encode-Konfiguration mit Track-Auswahl.
|
||||||
|
|
||||||
|
**URL-Parameter:** `jobId` – ID des Jobs
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audioTracks": [1, 2],
|
||||||
|
"subtitleTracks": [1]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Track-Indizes entsprechen den 1-basierten Track-Nummern aus dem Encode-Plan.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Encoding gestartet" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/cancel
|
||||||
|
|
||||||
|
Bricht den aktuellen Pipeline-Prozess ab.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Pipeline abgebrochen" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Der laufende Prozess wird mit SIGINT beendet (Fallback: SIGKILL nach Timeout).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/retry/:jobId
|
||||||
|
|
||||||
|
Wiederholt einen fehlgeschlagenen Job.
|
||||||
|
|
||||||
|
**URL-Parameter:** `jobId` – ID des Jobs
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Job wird wiederholt" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fehlerfälle:**
|
||||||
|
- `404` – Job nicht gefunden
|
||||||
|
- `409` – Job nicht im Status `ERROR`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/resume-ready/:jobId
|
||||||
|
|
||||||
|
Setzt einen Job im Status `READY_TO_ENCODE` zurück in die aktive Pipeline.
|
||||||
|
|
||||||
|
**URL-Parameter:** `jobId` – ID des Jobs
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/pipeline/reencode/:jobId
|
||||||
|
|
||||||
|
Startet ein erneutes Encoding für einen abgeschlossenen Job (ohne erneutes Ripping).
|
||||||
|
|
||||||
|
**URL-Parameter:** `jobId` – ID des Jobs
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audioTracks": [1, 2],
|
||||||
|
"subtitleTracks": [1]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Re-Encoding gestartet" }
|
||||||
|
```
|
||||||
140
docs/api/settings.md
Normal file
140
docs/api/settings.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Settings API
|
||||||
|
|
||||||
|
Endpunkte zum Lesen und Schreiben der Anwendungseinstellungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/settings
|
||||||
|
|
||||||
|
Gibt alle Einstellungen kategorisiert zurück.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"raw_dir": {
|
||||||
|
"value": "/mnt/nas/raw",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Raw-Verzeichnis",
|
||||||
|
"description": "Speicherort für rohe MKV-Dateien",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"movie_dir": {
|
||||||
|
"value": "/mnt/nas/movies",
|
||||||
|
"schema": { ... }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": { ... },
|
||||||
|
"encoding": { ... },
|
||||||
|
"drive": { ... },
|
||||||
|
"makemkv": { ... },
|
||||||
|
"omdb": { ... },
|
||||||
|
"notifications": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/settings/:key
|
||||||
|
|
||||||
|
Aktualisiert eine einzelne Einstellung.
|
||||||
|
|
||||||
|
**URL-Parameter:** `key` – Einstellungs-Schlüssel
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"value": "/mnt/storage/raw"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "key": "raw_dir", "value": "/mnt/storage/raw" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fehlerfälle:**
|
||||||
|
- `400` – Ungültiger Wert (Validierungsfehler)
|
||||||
|
- `404` – Einstellung nicht gefunden
|
||||||
|
|
||||||
|
!!! note "Encode-Review-Refresh"
|
||||||
|
Wenn eine encoding-relevante Einstellung geändert wird (z.B. `handbrake_preset`), wird der Encode-Plan für den aktuell wartenden Job automatisch neu berechnet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PUT /api/settings
|
||||||
|
|
||||||
|
Aktualisiert mehrere Einstellungen auf einmal.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"raw_dir": "/mnt/storage/raw",
|
||||||
|
"movie_dir": "/mnt/storage/movies",
|
||||||
|
"handbrake_preset": "H.265 MKV 720p30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"updated": ["raw_dir", "movie_dir", "handbrake_preset"],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/settings/pushover/test
|
||||||
|
|
||||||
|
Sendet eine Test-Benachrichtigung über PushOver.
|
||||||
|
|
||||||
|
**Request:** Kein Body erforderlich (verwendet gespeicherte Zugangsdaten)
|
||||||
|
|
||||||
|
**Response (Erfolg):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "message": "Test-Benachrichtigung gesendet" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Fehler):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "error": "Ungültiger API-Token" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
225
docs/api/websocket.md
Normal file
225
docs/api/websocket.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# WebSocket Events
|
||||||
|
|
||||||
|
Ripster verwendet WebSockets für Echtzeit-Updates. Der Endpunkt ist `/ws`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbindung
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ws = new WebSocket('ws://localhost:3001/ws');
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
console.log(message.type, message.data);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachrichten-Format
|
||||||
|
|
||||||
|
Alle Nachrichten folgen diesem Schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "EVENT_TYPE",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event-Typen
|
||||||
|
|
||||||
|
### PIPELINE_STATE_CHANGE
|
||||||
|
|
||||||
|
Wird gesendet, wenn der Pipeline-Zustand wechselt.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "PIPELINE_STATE_CHANGE",
|
||||||
|
"data": {
|
||||||
|
"state": "ENCODING",
|
||||||
|
"jobId": 42,
|
||||||
|
"job": {
|
||||||
|
"id": 42,
|
||||||
|
"title": "Inception",
|
||||||
|
"status": "ENCODING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PROGRESS_UPDATE
|
||||||
|
|
||||||
|
Wird während aktiver Prozesse (Ripping/Encoding) regelmäßig gesendet.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "PROGRESS_UPDATE",
|
||||||
|
"data": {
|
||||||
|
"progress": 73.5,
|
||||||
|
"eta": "00:12:34",
|
||||||
|
"speed": "45.2 fps",
|
||||||
|
"phase": "ENCODING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Felder:**
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|-----|-----|-------------|
|
||||||
|
| `progress` | number | Fortschritt 0–100 |
|
||||||
|
| `eta` | string | Geschätzte Restzeit (`HH:MM:SS`) |
|
||||||
|
| `speed` | string | Encoding-Geschwindigkeit (nur beim Encoding) |
|
||||||
|
| `phase` | string | Aktuelle Phase (`RIPPING` oder `ENCODING`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DISC_DETECTED
|
||||||
|
|
||||||
|
Wird gesendet, wenn eine Disc erkannt wird.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "DISC_DETECTED",
|
||||||
|
"data": {
|
||||||
|
"device": "/dev/sr0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DISC_REMOVED
|
||||||
|
|
||||||
|
Wird gesendet, wenn eine Disc ausgeworfen wird.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "DISC_REMOVED",
|
||||||
|
"data": {
|
||||||
|
"device": "/dev/sr0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JOB_COMPLETE
|
||||||
|
|
||||||
|
Wird gesendet, wenn ein Job erfolgreich abgeschlossen wurde.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "JOB_COMPLETE",
|
||||||
|
"data": {
|
||||||
|
"jobId": 42,
|
||||||
|
"title": "Inception",
|
||||||
|
"outputPath": "/mnt/nas/movies/Inception (2010).mkv"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ERROR
|
||||||
|
|
||||||
|
Wird gesendet, wenn ein Fehler aufgetreten ist.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ERROR",
|
||||||
|
"data": {
|
||||||
|
"jobId": 42,
|
||||||
|
"message": "HandBrake ist abgestürzt",
|
||||||
|
"details": "Exit code: 1\nStderr: ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### METADATA_REQUIRED
|
||||||
|
|
||||||
|
Wird gesendet, wenn Benutzer-Eingabe für Metadaten benötigt wird.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "METADATA_REQUIRED",
|
||||||
|
"data": {
|
||||||
|
"jobId": 42,
|
||||||
|
"makemkvData": { ... },
|
||||||
|
"playlistAnalysis": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ENCODE_REVIEW_REQUIRED
|
||||||
|
|
||||||
|
Wird gesendet, wenn der Benutzer den Encode-Plan bestätigen soll.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ENCODE_REVIEW_REQUIRED",
|
||||||
|
"data": {
|
||||||
|
"jobId": 42,
|
||||||
|
"encodePlan": {
|
||||||
|
"audioTracks": [ ... ],
|
||||||
|
"subtitleTracks": [ ... ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reconnect-Verhalten
|
||||||
|
|
||||||
|
Der Frontend-Hook `useWebSocket.js` implementiert automatisches Reconnect:
|
||||||
|
|
||||||
|
```
|
||||||
|
Verbindung verloren
|
||||||
|
↓
|
||||||
|
Warte 1s → Reconnect-Versuch
|
||||||
|
↓ (Fehlschlag)
|
||||||
|
Warte 2s → Reconnect-Versuch
|
||||||
|
↓ (Fehlschlag)
|
||||||
|
Warte 4s → ...
|
||||||
|
↓
|
||||||
|
Max. 30s Wartezeit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beispiel: React-Hook
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function usePipelineState() {
|
||||||
|
const [state, setState] = useState({ state: 'IDLE' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = new WebSocket(import.meta.env.VITE_WS_URL + '/ws');
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (msg.type === 'PIPELINE_STATE_CHANGE') {
|
||||||
|
setState(msg.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => ws.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
```
|
||||||
221
docs/architecture/backend.md
Normal file
221
docs/architecture/backend.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Backend-Services
|
||||||
|
|
||||||
|
Das Backend ist in Node.js/Express geschrieben und in **Services** aufgeteilt, die jeweils eine klar abgegrenzte Verantwortlichkeit haben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## pipelineService.js
|
||||||
|
|
||||||
|
**Der Kern von Ripster** – orchestriert den gesamten Ripping-Workflow.
|
||||||
|
|
||||||
|
### Zuständigkeiten
|
||||||
|
|
||||||
|
- Verwaltung des Pipeline-Zustands als State Machine
|
||||||
|
- Koordination zwischen allen externen Tools
|
||||||
|
- Generierung von Encode-Plänen
|
||||||
|
- Fehlerbehandlung und Recovery
|
||||||
|
|
||||||
|
### Haupt-Methoden
|
||||||
|
|
||||||
|
| Methode | Beschreibung |
|
||||||
|
|---------|-------------|
|
||||||
|
| `analyzeDisc()` | Startet MakeMKV-Analyse der eingelegten Disc |
|
||||||
|
| `selectMetadata(jobId, omdbData, playlist)` | Setzt Metadaten und Playlist für einen Job |
|
||||||
|
| `startJob(jobId)` | Startet den Ripping-Prozess |
|
||||||
|
| `confirmEncode(jobId, trackSelection)` | Bestätigt Encode mit Track-Auswahl |
|
||||||
|
| `cancelPipeline()` | Bricht aktiven Prozess ab |
|
||||||
|
| `retryJob(jobId)` | Wiederholt fehlgeschlagenen Job |
|
||||||
|
| `reencodeJob(jobId)` | Encodiert bestehende Raw-MKV neu |
|
||||||
|
|
||||||
|
### Zustandsübergänge
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> IDLE
|
||||||
|
IDLE --> ANALYZING: analyzeDisc()
|
||||||
|
ANALYZING --> METADATA_SELECTION: MakeMKV fertig
|
||||||
|
METADATA_SELECTION --> READY_TO_START: selectMetadata()
|
||||||
|
READY_TO_START --> RIPPING: startJob()
|
||||||
|
RIPPING --> MEDIAINFO_CHECK: MKV erstellt
|
||||||
|
MEDIAINFO_CHECK --> READY_TO_ENCODE: Tracks analysiert
|
||||||
|
READY_TO_ENCODE --> ENCODING: confirmEncode()
|
||||||
|
ENCODING --> FINISHED: HandBrake fertig
|
||||||
|
ENCODING --> ERROR: Fehler
|
||||||
|
RIPPING --> ERROR: Fehler
|
||||||
|
ERROR --> IDLE: retryJob() / cancel
|
||||||
|
FINISHED --> IDLE: cancel / neue Disc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## diskDetectionService.js
|
||||||
|
|
||||||
|
Überwacht das Disc-Laufwerk auf Disc-Einleger- und Auswurf-Ereignisse.
|
||||||
|
|
||||||
|
### Modi
|
||||||
|
|
||||||
|
| Modus | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| `auto` | Erkennt verfügbare Laufwerke automatisch |
|
||||||
|
| `explicit` | Überwacht ein bestimmtes Gerät (z.B. `/dev/sr0`) |
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
Der Service pollt das Laufwerk im konfigurierten Intervall (`disc_poll_interval_ms`, Standard: 5000ms) und emittiert Events:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Ereignisse
|
||||||
|
emit('disc-detected', { device: '/dev/sr0' })
|
||||||
|
emit('disc-removed', { device: '/dev/sr0' })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## processRunner.js
|
||||||
|
|
||||||
|
Verwaltet externe CLI-Prozesse.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Streaming**: stdout/stderr werden zeilenweise gelesen
|
||||||
|
- **Progress-Callbacks**: Ermöglicht Echtzeit-Fortschrittsanzeige
|
||||||
|
- **Graceful Shutdown**: SIGINT → Warte-Timeout → SIGKILL
|
||||||
|
- **Prozess-Registry**: Verfolgt aktive Prozesse für sauberes Beenden
|
||||||
|
|
||||||
|
### Nutzung
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await runProcess(
|
||||||
|
'HandBrakeCLI',
|
||||||
|
['--input', rawFile, '--output', outputFile, '--preset', preset],
|
||||||
|
{
|
||||||
|
onStderr: (line) => parseHandBrakeProgress(line),
|
||||||
|
onStdout: (line) => logger.debug(line)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## websocketService.js
|
||||||
|
|
||||||
|
WebSocket-Server für Echtzeit-Client-Kommunikation.
|
||||||
|
|
||||||
|
### Betrieb
|
||||||
|
|
||||||
|
- Läuft auf Pfad `/ws` des Express-Servers
|
||||||
|
- Hält eine Registry aller verbundenen Clients
|
||||||
|
- Ermöglicht Broadcast an alle Clients oder gezieltes Senden
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```js
|
||||||
|
broadcast({ type: 'PIPELINE_STATE_CHANGE', data: { state, jobId } });
|
||||||
|
broadcast({ type: 'PROGRESS_UPDATE', data: { progress, eta } });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## omdbService.js
|
||||||
|
|
||||||
|
Integration mit der [OMDb API](https://www.omdbapi.com/).
|
||||||
|
|
||||||
|
### Methoden
|
||||||
|
|
||||||
|
| Methode | Beschreibung |
|
||||||
|
|---------|-------------|
|
||||||
|
| `searchByTitle(title, type)` | Suche nach Titel (movie/series) |
|
||||||
|
| `fetchById(imdbId)` | Vollständige Metadaten per IMDb-ID |
|
||||||
|
|
||||||
|
### Zurückgegebene Daten
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"imdbId": "tt1375666",
|
||||||
|
"title": "Inception",
|
||||||
|
"year": "2010",
|
||||||
|
"type": "movie",
|
||||||
|
"poster": "https://...",
|
||||||
|
"plot": "...",
|
||||||
|
"director": "Christopher Nolan"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## settingsService.js
|
||||||
|
|
||||||
|
Verwaltet alle Anwendungseinstellungen.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Schema-getriebene Validierung**: Jede Einstellung hat Typ, Grenzen und Pflichtfeld-Flag
|
||||||
|
- **Kategorisierung**: Einstellungen sind in Kategorien gruppiert (Paths, Tools, Encoding, ...)
|
||||||
|
- **Persistenz**: Werte in SQLite, Schema ebenfalls in SQLite
|
||||||
|
- **Defaults**: `defaultSettings.js` definiert Standardwerte
|
||||||
|
|
||||||
|
### Einstellungs-Kategorien
|
||||||
|
|
||||||
|
| Kategorie | Einstellungen |
|
||||||
|
|-----------|--------------|
|
||||||
|
| `paths` | `raw_dir`, `movie_dir`, `log_dir` |
|
||||||
|
| `tools` | `makemkv_command`, `handbrake_command`, `mediainfo_command` |
|
||||||
|
| `encoding` | `handbrake_preset`, `handbrake_extra_args`, `output_extension`, `filename_template` |
|
||||||
|
| `drive` | `drive_mode`, `drive_device`, `disc_poll_interval_ms` |
|
||||||
|
| `makemkv` | `makemkv_min_length_minutes`, `makemkv_backup_mode` |
|
||||||
|
| `omdb` | `omdb_api_key`, `omdb_default_type` |
|
||||||
|
| `notifications` | `pushover_user_key`, `pushover_api_token` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## historyService.js
|
||||||
|
|
||||||
|
Datenbankoperationen für Job-Historie.
|
||||||
|
|
||||||
|
### Hauptoperationen
|
||||||
|
|
||||||
|
| Operation | Beschreibung |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `listJobs(filters)` | Jobs nach Status/Titel filtern |
|
||||||
|
| `getJob(id)` | Job-Details mit Logs abrufen |
|
||||||
|
| `findOrphanRawFolders()` | Nicht-getrackte Raw-Ordner finden |
|
||||||
|
| `importOrphanRaw(path)` | Orphan-Ordner als Job importieren |
|
||||||
|
| `assignOmdb(id, omdbData)` | OMDb-Metadaten nachträglich zuweisen |
|
||||||
|
| `deleteJob(id, deleteFiles)` | Job und optional Dateien löschen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## notificationService.js
|
||||||
|
|
||||||
|
PushOver-Push-Benachrichtigungen.
|
||||||
|
|
||||||
|
```js
|
||||||
|
await notify({
|
||||||
|
title: 'Ripster: Job abgeschlossen',
|
||||||
|
message: 'Inception (2010) wurde erfolgreich encodiert'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## logger.js
|
||||||
|
|
||||||
|
Strukturiertes Logging mit täglicher Log-Rotation.
|
||||||
|
|
||||||
|
### Log-Level
|
||||||
|
|
||||||
|
| Level | Verwendung |
|
||||||
|
|-------|-----------|
|
||||||
|
| `debug` | Detaillierte Entwicklungs-Informationen |
|
||||||
|
| `info` | Normale Betriebsereignisse |
|
||||||
|
| `warn` | Warnungen, die Aufmerksamkeit benötigen |
|
||||||
|
| `error` | Fehler, die den Betrieb beeinträchtigen |
|
||||||
|
|
||||||
|
### Log-Dateien
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/
|
||||||
|
├── ripster-2024-01-15.log ← Tages-Log
|
||||||
|
└── jobs/
|
||||||
|
└── job-42-handbrake.log ← Prozess-spezifische Logs
|
||||||
|
```
|
||||||
161
docs/architecture/database.md
Normal file
161
docs/architecture/database.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Datenbank
|
||||||
|
|
||||||
|
Ripster verwendet **SQLite3** als Datenbank. Die Datenbankdatei liegt unter `backend/data/ripster.db`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema-Übersicht
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Vier Haupt-Tabellen
|
||||||
|
settings_schema -- Einstellungs-Definitionen
|
||||||
|
settings_values -- Benutzer-Werte
|
||||||
|
jobs -- Rip-Job-Datensätze
|
||||||
|
pipeline_state -- Aktueller Pipeline-Zustand (Singleton)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabelle: jobs
|
||||||
|
|
||||||
|
Die wichtigste Tabelle – speichert alle Ripping-Jobs.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- Aktueller Status
|
||||||
|
title TEXT, -- Filmtitel (von OMDb)
|
||||||
|
imdb_id TEXT, -- IMDb-ID
|
||||||
|
omdb_year TEXT, -- Erscheinungsjahr
|
||||||
|
omdb_type TEXT, -- movie/series
|
||||||
|
omdb_poster TEXT, -- Poster-URL
|
||||||
|
raw_path TEXT, -- Pfad zur Raw-MKV
|
||||||
|
output_path TEXT, -- Pfad zur Ausgabedatei
|
||||||
|
playlist TEXT, -- Gewählte Blu-ray Playlist
|
||||||
|
makemkv_output TEXT, -- MakeMKV-Ausgabe (JSON)
|
||||||
|
mediainfo_output TEXT, -- MediaInfo-Ausgabe (JSON)
|
||||||
|
encode_plan TEXT, -- Encode-Plan (JSON)
|
||||||
|
handbrake_log TEXT, -- HandBrake Log-Pfad
|
||||||
|
error_message TEXT, -- Fehlermeldung bei ERROR
|
||||||
|
error_details TEXT -- Detaillierte Fehler-Infos
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job-Status-Werte
|
||||||
|
|
||||||
|
| Status | Beschreibung |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ANALYZING` | MakeMKV analysiert die Disc |
|
||||||
|
| `METADATA_SELECTION` | Wartet auf Benutzer-Metadaten-Auswahl |
|
||||||
|
| `READY_TO_START` | Bereit zum Starten |
|
||||||
|
| `RIPPING` | MakeMKV rippt die Disc |
|
||||||
|
| `MEDIAINFO_CHECK` | MediaInfo analysiert die Raw-Datei |
|
||||||
|
| `READY_TO_ENCODE` | Wartet auf Encode-Bestätigung |
|
||||||
|
| `ENCODING` | HandBrake encodiert |
|
||||||
|
| `FINISHED` | Erfolgreich abgeschlossen |
|
||||||
|
| `ERROR` | Fehler aufgetreten |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabelle: pipeline_state
|
||||||
|
|
||||||
|
Singleton-Tabelle für den aktuellen Pipeline-Zustand (immer genau 1 Zeile).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pipeline_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||||
|
state TEXT NOT NULL DEFAULT 'IDLE',
|
||||||
|
job_id INTEGER, -- Aktiver Job (NULL wenn IDLE)
|
||||||
|
progress REAL, -- Fortschritt 0-100
|
||||||
|
eta TEXT, -- Geschätzte Restzeit
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabelle: settings_schema
|
||||||
|
|
||||||
|
Definiert alle verfügbaren Einstellungen mit Metadaten.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE settings_schema (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
category TEXT NOT NULL, -- paths, tools, encoding, ...
|
||||||
|
type TEXT NOT NULL, -- string, number, boolean, select
|
||||||
|
label TEXT NOT NULL, -- Anzeigename
|
||||||
|
description TEXT, -- Hilfetext
|
||||||
|
default_val TEXT, -- Standardwert
|
||||||
|
required INTEGER, -- 1 = Pflichtfeld
|
||||||
|
min_val REAL, -- Minimalwert (für number)
|
||||||
|
max_val REAL, -- Maximalwert (für number)
|
||||||
|
options TEXT -- JSON-Array für select-Typ
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabelle: settings_values
|
||||||
|
|
||||||
|
Speichert benutzer-konfigurierte Werte.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE settings_values (
|
||||||
|
key TEXT PRIMARY KEY REFERENCES settings_schema(key),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema-Migrationen
|
||||||
|
|
||||||
|
`database.js` implementiert **automatische Migrationen**:
|
||||||
|
|
||||||
|
1. Beim Start wird das aktuelle Schema geprüft
|
||||||
|
2. Fehlende Tabellen werden erstellt
|
||||||
|
3. Fehlende Spalten werden hinzugefügt
|
||||||
|
4. Neue Default-Einstellungen werden eingefügt
|
||||||
|
|
||||||
|
### Korruptions-Recovery
|
||||||
|
|
||||||
|
Falls die Datenbankdatei korrupt ist:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Korrupte Datei wird erkannt (Verbindungsfehler / Integritätsprüfung)
|
||||||
|
2. Datei wird in /backend/data/quarantine/ verschoben
|
||||||
|
3. Neue, leere Datenbank wird erstellt
|
||||||
|
4. Schema wird neu initialisiert
|
||||||
|
5. Log-Eintrag mit Warnung
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbankpfad konfigurieren
|
||||||
|
|
||||||
|
Standard: `./data/ripster.db` (relativ zum Backend-Verzeichnis)
|
||||||
|
|
||||||
|
Über Umgebungsvariable anpassen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_PATH=/var/lib/ripster/ripster.db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direkte Datenbankinspektion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SQLite3-CLI
|
||||||
|
sqlite3 backend/data/ripster.db
|
||||||
|
|
||||||
|
# Alle Jobs anzeigen
|
||||||
|
.mode table
|
||||||
|
SELECT id, status, title, created_at FROM jobs ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
# Einstellungen anzeigen
|
||||||
|
SELECT key, value FROM settings_values;
|
||||||
|
```
|
||||||
190
docs/architecture/frontend.md
Normal file
190
docs/architecture/frontend.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Frontend-Komponenten
|
||||||
|
|
||||||
|
Das Frontend ist mit **React 18** und **PrimeReact** gebaut und kommuniziert über REST-API und WebSocket mit dem Backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seiten (Pages)
|
||||||
|
|
||||||
|
### DashboardPage.jsx
|
||||||
|
|
||||||
|
Die Hauptseite von Ripster – zeigt den aktuellen Pipeline-Status und ermöglicht alle Workflow-Aktionen.
|
||||||
|
|
||||||
|
**Funktionen:**
|
||||||
|
- Anzeige des aktuellen Pipeline-Zustands (IDLE, ANALYZING, RIPPING, ENCODING, ...)
|
||||||
|
- Live-Fortschrittsbalken mit ETA
|
||||||
|
- Trigger für Metadaten-Dialog
|
||||||
|
- Playlist-Entscheidungs-UI (bei Blu-ray Obfuskierung)
|
||||||
|
- Encode-Review mit Track-Auswahl
|
||||||
|
- Job-Steuerung (Start, Abbruch, Retry)
|
||||||
|
|
||||||
|
**Zugehörige Komponenten:**
|
||||||
|
- `PipelineStatusCard` – Status-Widget
|
||||||
|
- `MetadataSelectionDialog` – OMDb-Suche und Playlist-Auswahl
|
||||||
|
- `MediaInfoReviewPanel` – Track-Auswahl vor dem Encoding
|
||||||
|
- `DiscDetectedDialog` – Benachrichtigung bei Disc-Erkennung
|
||||||
|
|
||||||
|
### SettingsPage.jsx
|
||||||
|
|
||||||
|
Konfigurationsoberfläche für alle Ripster-Einstellungen.
|
||||||
|
|
||||||
|
**Funktionen:**
|
||||||
|
- Dynamisch generiertes Formular aus dem Settings-Schema
|
||||||
|
- Echtzeit-Validierungsfeedback
|
||||||
|
- PushOver-Verbindungstest
|
||||||
|
- Automatische Aktualisierung des Encode-Reviews bei relevanten Änderungen
|
||||||
|
|
||||||
|
### HistoryPage.jsx
|
||||||
|
|
||||||
|
Job-Historie mit vollständigem Audit-Trail.
|
||||||
|
|
||||||
|
**Funktionen:**
|
||||||
|
- Sortier- und filterbares Job-Verzeichnis
|
||||||
|
- Statusfilter (FINISHED, ERROR, WAITING_FOR_USER_DECISION, ...)
|
||||||
|
- Job-Detail-Dialog mit vollständigen Logs
|
||||||
|
- Re-Encode, Löschen und Metadaten-Zuweisung
|
||||||
|
- Import von Orphan-Raw-Ordnern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Komponenten (Components)
|
||||||
|
|
||||||
|
### MetadataSelectionDialog.jsx
|
||||||
|
|
||||||
|
Dialog für die Metadaten-Auswahl nach der Disc-Analyse.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Metadaten auswählen │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Suche: [Inception ] 🔍 │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Ergebnisse: │
|
||||||
|
│ ▶ Inception (2010) – Movie │
|
||||||
|
│ Inception: ... (2011) – Series │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Playlist (nur Blu-ray): │
|
||||||
|
│ ▶ 00800.mpls (2:30:15) ✓ Empfohlen │
|
||||||
|
│ 00801.mpls (0:01:23) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [Bestätigen] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### MediaInfoReviewPanel.jsx
|
||||||
|
|
||||||
|
Track-Auswahl-Panel vor dem Encoding.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Encode-Review │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Audio-Tracks: │
|
||||||
|
│ ☑ Track 1: Deutsch (AC-3, 5.1) │
|
||||||
|
│ ☑ Track 2: English (TrueHD, 7.1) │
|
||||||
|
│ ☐ Track 3: Français (AC-3, 2.0) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Untertitel: │
|
||||||
|
│ ☑ Track 1: Deutsch │
|
||||||
|
│ ☐ Track 2: English │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [Encodierung starten] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### DynamicSettingsForm.jsx
|
||||||
|
|
||||||
|
Wiederverwendbares Formular, das aus dem Settings-Schema generiert wird.
|
||||||
|
|
||||||
|
**Unterstützte Feldtypen:**
|
||||||
|
|
||||||
|
| Typ | UI-Element |
|
||||||
|
|----|-----------|
|
||||||
|
| `string` | Text-Input |
|
||||||
|
| `number` | Zahlen-Input mit Min/Max |
|
||||||
|
| `boolean` | Toggle/Checkbox |
|
||||||
|
| `select` | Dropdown |
|
||||||
|
| `password` | Passwort-Input |
|
||||||
|
|
||||||
|
### PipelineStatusCard.jsx
|
||||||
|
|
||||||
|
Status-Anzeige-Widget für die Dashboard-Seite.
|
||||||
|
|
||||||
|
### JobDetailDialog.jsx
|
||||||
|
|
||||||
|
Vollständiger Job-Detail-Dialog mit Logs-Viewer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
### useWebSocket.js
|
||||||
|
|
||||||
|
Zentraler Custom-Hook für die WebSocket-Verbindung.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { status, lastMessage } = useWebSocket({
|
||||||
|
onMessage: (msg) => {
|
||||||
|
if (msg.type === 'PIPELINE_STATE_CHANGE') {
|
||||||
|
setPipelineState(msg.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Automatische Verbindung zu `/ws`
|
||||||
|
- Reconnect mit exponential backoff
|
||||||
|
- Message-Parsing (JSON)
|
||||||
|
- Status-Tracking (connecting, connected, disconnected)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Client (client.js)
|
||||||
|
|
||||||
|
Zentraler HTTP-Client für alle Backend-Anfragen.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Beispiel-Aufrufe
|
||||||
|
const state = await api.getPipelineState();
|
||||||
|
const results = await api.searchOmdb('Inception');
|
||||||
|
await api.selectMetadata(jobId, omdbData, playlist);
|
||||||
|
await api.confirmEncode(jobId, { audioTracks: [0, 1], subtitleTracks: [0] });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Zentralisierte Fehlerbehandlung
|
||||||
|
- Automatische JSON-Serialisierung
|
||||||
|
- Basis-URL aus Umgebungsvariable (`VITE_API_BASE`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Entwicklung
|
||||||
|
|
||||||
|
### Entwicklungsserver
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vite-Proxy-Konfiguration
|
||||||
|
|
||||||
|
In der Entwicklungsumgebung proxied Vite API-Anfragen zum Backend:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// vite.config.js
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001',
|
||||||
|
'/ws': { target: 'ws://localhost:3001', ws: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production-Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
# → frontend/dist/
|
||||||
|
```
|
||||||
112
docs/architecture/index.md
Normal file
112
docs/architecture/index.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Architektur
|
||||||
|
|
||||||
|
Ripster ist als klassische **Client-Server-Anwendung** mit Echtzeit-Kommunikation über WebSockets aufgebaut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systemüberblick
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph Browser["Browser (React)"]
|
||||||
|
Dashboard["Dashboard"]
|
||||||
|
Settings["Einstellungen"]
|
||||||
|
History["History"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend["Node.js Backend"]
|
||||||
|
API["REST API\n(Express)"]
|
||||||
|
WS["WebSocket\nServer"]
|
||||||
|
Pipeline["Pipeline\nService"]
|
||||||
|
DB["SQLite\nDatenbank"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ExternalTools["Externe Tools"]
|
||||||
|
MakeMKV["makemkvcon"]
|
||||||
|
HandBrake["HandBrakeCLI"]
|
||||||
|
MediaInfo["mediainfo"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ExternalAPIs["Externe APIs"]
|
||||||
|
OMDb["OMDb API"]
|
||||||
|
PushOver["PushOver"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser <-->|HTTP REST| API
|
||||||
|
Browser <-->|WebSocket| WS
|
||||||
|
Pipeline --> MakeMKV
|
||||||
|
Pipeline --> HandBrake
|
||||||
|
Pipeline --> MediaInfo
|
||||||
|
Pipeline <-->|Metadaten| OMDb
|
||||||
|
Pipeline -->|Benachrichtigungen| PushOver
|
||||||
|
API --> DB
|
||||||
|
Pipeline --> DB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schichten-Architektur
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
index.js (Express Server)
|
||||||
|
├── Routes (API-Endpunkte)
|
||||||
|
│ ├── pipelineRoutes.js
|
||||||
|
│ ├── settingsRoutes.js
|
||||||
|
│ └── historyRoutes.js
|
||||||
|
├── Services (Business Logic)
|
||||||
|
│ ├── pipelineService.js ← Kern-Orchestrierung
|
||||||
|
│ ├── diskDetectionService.js
|
||||||
|
│ ├── processRunner.js
|
||||||
|
│ ├── websocketService.js
|
||||||
|
│ ├── omdbService.js
|
||||||
|
│ ├── settingsService.js
|
||||||
|
│ ├── notificationService.js
|
||||||
|
│ ├── historyService.js
|
||||||
|
│ └── logger.js
|
||||||
|
├── Database
|
||||||
|
│ ├── database.js
|
||||||
|
│ └── defaultSettings.js
|
||||||
|
└── Utils
|
||||||
|
├── encodePlan.js
|
||||||
|
├── playlistAnalysis.js
|
||||||
|
├── progressParsers.js
|
||||||
|
└── files.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
App.jsx (React Router)
|
||||||
|
├── Pages
|
||||||
|
│ ├── DashboardPage.jsx ← Haupt-Interface
|
||||||
|
│ ├── SettingsPage.jsx
|
||||||
|
│ └── HistoryPage.jsx
|
||||||
|
├── Components
|
||||||
|
│ ├── PipelineStatusCard.jsx
|
||||||
|
│ ├── MetadataSelectionDialog.jsx
|
||||||
|
│ ├── MediaInfoReviewPanel.jsx
|
||||||
|
│ ├── DynamicSettingsForm.jsx
|
||||||
|
│ └── JobDetailDialog.jsx
|
||||||
|
├── Hooks
|
||||||
|
│ └── useWebSocket.js
|
||||||
|
└── API
|
||||||
|
└── client.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weiterführende Dokumentation
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- [:octicons-arrow-right-24: Übersicht](overview.md)
|
||||||
|
|
||||||
|
- [:octicons-arrow-right-24: Backend-Services](backend.md)
|
||||||
|
|
||||||
|
- [:octicons-arrow-right-24: Frontend-Komponenten](frontend.md)
|
||||||
|
|
||||||
|
- [:octicons-arrow-right-24: Datenbank](database.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
144
docs/architecture/overview.md
Normal file
144
docs/architecture/overview.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Architektur-Übersicht
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kern-Designprinzipien
|
||||||
|
|
||||||
|
### Event-Driven Pipeline
|
||||||
|
|
||||||
|
Der gesamte Ripping-Workflow ist als **State Machine** implementiert. Der `pipelineService` verwaltet den aktuellen Zustand und emittiert Ereignisse bei jedem Zustandswechsel. Der WebSocket-Service überträgt diese Ereignisse sofort an alle verbundenen Clients.
|
||||||
|
|
||||||
|
```
|
||||||
|
Zustandswechsel → Event → WebSocket → Frontend-Update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service-Layer-Muster
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP-Route → Service → Datenbank
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes delegieren die gesamte Business-Logik an Services. Services sind voneinander unabhängig und können einzeln getestet werden.
|
||||||
|
|
||||||
|
### Schema-getriebene Einstellungen
|
||||||
|
|
||||||
|
Die Settings-Konfiguration definiert **sowohl** die Validierungsregeln als auch die UI-Struktur in einer einzigen Quelle (`settings_schema`-Tabelle). Die `DynamicSettingsForm`-Komponente rendert das Formular dynamisch aus dem Schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Echtzeit-Kommunikation
|
||||||
|
|
||||||
|
### WebSocket-Protokoll
|
||||||
|
|
||||||
|
Der WebSocket-Server läuft unter dem Pfad `/ws`. Nachrichten werden als JSON übertragen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "PIPELINE_STATE_CHANGE",
|
||||||
|
"data": {
|
||||||
|
"state": "ENCODING",
|
||||||
|
"jobId": 42,
|
||||||
|
"progress": 73.5,
|
||||||
|
"eta": "00:12:34"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nachrichtentypen:**
|
||||||
|
|
||||||
|
| Typ | Beschreibung |
|
||||||
|
|----|-------------|
|
||||||
|
| `PIPELINE_STATE_CHANGE` | Pipeline-Zustand hat gewechselt |
|
||||||
|
| `PROGRESS_UPDATE` | Fortschritt (% und ETA) |
|
||||||
|
| `DISC_DETECTED` | Disc wurde erkannt |
|
||||||
|
| `DISC_REMOVED` | Disc wurde entfernt |
|
||||||
|
| `ERROR` | Fehler aufgetreten |
|
||||||
|
| `JOB_COMPLETE` | Job abgeschlossen |
|
||||||
|
|
||||||
|
### Reconnect-Logik
|
||||||
|
|
||||||
|
Der Frontend-Hook `useWebSocket.js` implementiert automatisches Reconnect mit exponential backoff bei Verbindungsabbrüchen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prozess-Management
|
||||||
|
|
||||||
|
### processRunner.js
|
||||||
|
|
||||||
|
Externe Tools (MakeMKV, HandBrake, MediaInfo) werden als **Child Processes** gestartet:
|
||||||
|
|
||||||
|
```js
|
||||||
|
spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
```
|
||||||
|
|
||||||
|
- **stdout/stderr** werden zeilenweise gelesen und in Echtzeit verarbeitet
|
||||||
|
- **Progress-Parsing** erfolgt über reguläre Ausdrücke in `progressParsers.js`
|
||||||
|
- **Graceful Shutdown**: SIGINT → Timeout → SIGKILL
|
||||||
|
- **Prozess-Tracking**: Aktive Prozesse werden registriert für sauberes Beenden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenpersistenz
|
||||||
|
|
||||||
|
### SQLite-Datenbank
|
||||||
|
|
||||||
|
Ripster verwendet eine **einzige SQLite-Datei** für alle persistenten Daten:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/data/ripster.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tabellen:**
|
||||||
|
|
||||||
|
| Tabelle | Inhalt |
|
||||||
|
|---------|--------|
|
||||||
|
| `jobs` | Alle Rip-Jobs mit Status, Logs, Metadaten |
|
||||||
|
| `pipeline_state` | Aktueller Pipeline-Zustand (Singleton) |
|
||||||
|
| `settings_schema` | Schema aller verfügbaren Einstellungen |
|
||||||
|
| `settings_values` | Benutzer-konfigurierte Werte |
|
||||||
|
|
||||||
|
### Migrations-Strategie
|
||||||
|
|
||||||
|
Beim Start prüft `database.js` automatisch, ob das Schema aktuell ist, und führt fehlende Migrationen aus. Korrupte Datenbankdateien werden in ein Quarantäne-Verzeichnis verschoben und eine neue Datenbank erstellt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
### Strukturierte Fehler
|
||||||
|
|
||||||
|
Alle Fehler werden mit Kontext-Metadaten protokolliert:
|
||||||
|
|
||||||
|
```js
|
||||||
|
logger.error('Encoding fehlgeschlagen', {
|
||||||
|
jobId: job.id,
|
||||||
|
command: cmd,
|
||||||
|
exitCode: code,
|
||||||
|
stderr: lastLines
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Job-Fehler-Recovery
|
||||||
|
|
||||||
|
- Fehlgeschlagene Jobs bleiben in der Datenbank (Status `ERROR`)
|
||||||
|
- Vollständige Fehler-Logs werden im Job-Datensatz gespeichert
|
||||||
|
- **Retry-Funktion** ermöglicht Neustart von einem Fehler-Zustand
|
||||||
|
- **Re-Encode** erlaubt erneutes Encodieren ohne neu zu rippen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
### Eingabe-Validierung
|
||||||
|
|
||||||
|
- Alle Benutzer-Eingaben werden in `validators.js` validiert
|
||||||
|
- CLI-Argumente werden sicher über `commandLine.js` konstruiert (kein Shell-Injection-Risiko)
|
||||||
|
- Pfade werden sanitisiert bevor sie an externe Prozesse übergeben werden
|
||||||
|
|
||||||
|
### CORS-Konfiguration
|
||||||
|
|
||||||
|
```env
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
In Produktion sollte dieser Wert auf die tatsächliche Frontend-URL gesetzt werden.
|
||||||
96
docs/configuration/environment.md
Normal file
96
docs/configuration/environment.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Umgebungsvariablen
|
||||||
|
|
||||||
|
Umgebungsvariablen überschreiben die Standardwerte und eignen sich für Server-Deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend-Umgebungsvariablen
|
||||||
|
|
||||||
|
Konfigurationsdatei: `backend/.env`
|
||||||
|
|
||||||
|
| Variable | Standard | Beschreibung |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `PORT` | `3001` | Port des Express-Servers |
|
||||||
|
| `DB_PATH` | `./data/ripster.db` | Pfad zur SQLite-Datenbankdatei |
|
||||||
|
| `CORS_ORIGIN` | `http://localhost:5173` | Erlaubter CORS-Origin |
|
||||||
|
| `LOG_DIR` | `./logs` | Verzeichnis für Log-Dateien |
|
||||||
|
| `LOG_LEVEL` | `info` | Log-Level (`debug`, `info`, `warn`, `error`) |
|
||||||
|
|
||||||
|
### Beispiel: backend/.env
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
DB_PATH=/var/lib/ripster/ripster.db
|
||||||
|
CORS_ORIGIN=http://192.168.1.100:5173
|
||||||
|
LOG_DIR=/var/log/ripster
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend-Umgebungsvariablen
|
||||||
|
|
||||||
|
Konfigurationsdatei: `frontend/.env`
|
||||||
|
|
||||||
|
| Variable | Standard | Beschreibung |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `VITE_API_BASE` | `http://localhost:3001` | Backend-API-URL |
|
||||||
|
| `VITE_WS_URL` | `ws://localhost:3001` | WebSocket-URL |
|
||||||
|
| `VITE_PUBLIC_ORIGIN` | — | Öffentliche Origin-URL (für CORS) |
|
||||||
|
| `VITE_HMR_HOST` | — | Vite HMR-Host (für Remote-Entwicklung) |
|
||||||
|
| `VITE_HMR_PORT` | — | Vite HMR-Port |
|
||||||
|
|
||||||
|
### Beispiel: frontend/.env (Entwicklung)
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE=http://localhost:3001
|
||||||
|
VITE_WS_URL=ws://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beispiel: frontend/.env (Netzwerk-Zugriff)
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE=http://192.168.1.100:3001
|
||||||
|
VITE_WS_URL=ws://192.168.1.100:3001
|
||||||
|
VITE_PUBLIC_ORIGIN=http://192.168.1.100:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## .env.example Dateien
|
||||||
|
|
||||||
|
Das Repository enthält Vorlagen für beide Konfigurationsdateien:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorität der Konfiguration
|
||||||
|
|
||||||
|
Einstellungen werden in folgender Reihenfolge geladen (höhere Priorität überschreibt niedrigere):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Systemumgebungsvariablen (export VAR=value)
|
||||||
|
2. .env-Datei
|
||||||
|
3. Hardcodierte Standardwerte in config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOG_LEVEL
|
||||||
|
|
||||||
|
| Level | Ausgabe |
|
||||||
|
|-------|---------|
|
||||||
|
| `debug` | Alle Meldungen inkl. Debugging |
|
||||||
|
| `info` | Normale Betriebsinformationen |
|
||||||
|
| `warn` | Warnungen + Fehler |
|
||||||
|
| `error` | Nur Fehler |
|
||||||
|
|
||||||
|
!!! tip "Produktionsempfehlung"
|
||||||
|
Für Produktionsumgebungen `LOG_LEVEL=info` oder `LOG_LEVEL=warn` verwenden. `debug` erzeugt sehr viele Log-Einträge.
|
||||||
21
docs/configuration/index.md
Normal file
21
docs/configuration/index.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Konfiguration
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-tune: **Einstellungsreferenz**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Alle verfügbaren Einstellungen mit Typen, Standardwerten und Beschreibungen.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Einstellungsreferenz](settings-reference.md)
|
||||||
|
|
||||||
|
- :material-variable: **Umgebungsvariablen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Umgebungsvariablen für Backend und Frontend.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Umgebungsvariablen](environment.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
138
docs/configuration/settings-reference.md
Normal file
138
docs/configuration/settings-reference.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Einstellungsreferenz
|
||||||
|
|
||||||
|
Vollständige Übersicht aller Ripster-Einstellungen. Alle Einstellungen werden über die Web-Oberfläche unter **Einstellungen** verwaltet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kategorie: Pfade (paths)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
!!! example "Beispielkonfiguration"
|
||||||
|
```
|
||||||
|
raw_dir = /mnt/nas/raw
|
||||||
|
movie_dir = /mnt/nas/movies
|
||||||
|
log_dir = /var/log/ripster
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kategorie: Tools (tools)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
!!! 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)
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Verfügbare HandBrake-Presets
|
||||||
|
|
||||||
|
Eine vollständige Liste der verfügbaren Presets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI --preset-list
|
||||||
|
```
|
||||||
|
|
||||||
|
Häufig verwendete Presets:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Dateiname-Template-Platzhalter
|
||||||
|
|
||||||
|
| Platzhalter | Beispiel |
|
||||||
|
|------------|---------|
|
||||||
|
| `{title}` | `Inception` |
|
||||||
|
| `{year}` | `2010` |
|
||||||
|
| `{imdb_id}` | `tt1375666` |
|
||||||
|
| `{type}` | `movie` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 | `5000` | 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)
|
||||||
|
|
||||||
|
| Schlüssel | Typ | Standard | Beschreibung |
|
||||||
|
|---------|-----|---------|-------------|
|
||||||
|
| `pushover_user_key` | string | — | PushOver User-Key |
|
||||||
|
| `pushover_api_token` | string | — | PushOver API-Token |
|
||||||
|
|
||||||
|
Beide Felder müssen konfiguriert sein, um PushOver-Benachrichtigungen zu aktivieren. Die Verbindung kann mit dem **Test-Button** in den Einstellungen geprüft werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard-Einstellungen zurücksetzen
|
||||||
|
|
||||||
|
Über die Datenbank können Einstellungen auf Standardwerte zurückgesetzt werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/data/ripster.db \
|
||||||
|
"DELETE FROM settings_values WHERE key = 'handbrake_preset';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim nächsten Laden der Einstellungen wird der Standardwert verwendet.
|
||||||
137
docs/deployment/development.md
Normal file
137
docs/deployment/development.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Entwicklungsumgebung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js >= 20.19.0
|
||||||
|
- Alle [externen Tools](../getting-started/prerequisites.md) installiert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript startet automatisch:
|
||||||
|
- **Backend** auf Port 3001 (mit Nodemon für Hot-Reload)
|
||||||
|
- **Frontend** auf Port 5173 (mit Vite HMR)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuelle Entwicklungsumgebung
|
||||||
|
|
||||||
|
### Terminal 1 – Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend läuft auf `http://localhost:3001` mit **Nodemon** – Neustart bei Dateiänderungen.
|
||||||
|
|
||||||
|
### Terminal 2 – Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend läuft auf `http://localhost:5173` mit **Vite HMR** – sofortige Browser-Updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vite-Proxy
|
||||||
|
|
||||||
|
Im Entwicklungsmodus proxied Vite alle API- und WebSocket-Anfragen zum Backend:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/vite.config.js
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:3001',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Das bedeutet: Im Browser macht das Frontend Anfragen an `localhost:5173/api/...` – Vite leitet diese an `localhost:3001/api/...` weiter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remote-Entwicklung
|
||||||
|
|
||||||
|
Falls Ripster auf einem entfernten Server entwickelt wird (z.B. Homeserver), muss die Vite-Konfiguration angepasst werden:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# frontend/.env.local
|
||||||
|
VITE_API_BASE=http://192.168.1.100:3001
|
||||||
|
VITE_WS_URL=ws://192.168.1.100:3001
|
||||||
|
VITE_HMR_HOST=192.168.1.100
|
||||||
|
VITE_HMR_PORT=5173
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log-Level für Entwicklung
|
||||||
|
|
||||||
|
```env
|
||||||
|
# backend/.env
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Debug-Modus werden alle Ausgaben der externen Tools (MakeMKV, HandBrake) vollständig geloggt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stoppen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./kill.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linting & Type-Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend (ESLint)
|
||||||
|
cd frontend && npm run lint
|
||||||
|
|
||||||
|
# Backend hat keine separaten Lint-Scripts,
|
||||||
|
# nutze direkt eslint falls konfiguriert
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment-Script
|
||||||
|
|
||||||
|
Das `deploy-ripster.sh`-Script überträgt Code auf einen Remote-Server per SSH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy-ripster.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was das Script tut:**
|
||||||
|
1. `rsync` synchronisiert den Code (Backend-Quellcode ohne `data/`)
|
||||||
|
2. Die Datenbank (`backend/data/`) wird **nicht** überschrieben
|
||||||
|
3. Verbindung via SSH (konfigurierbar im Script)
|
||||||
|
|
||||||
|
**Anpassung des Scripts:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# deploy-ripster.sh
|
||||||
|
REMOTE_HOST="192.168.1.100"
|
||||||
|
REMOTE_USER="michael"
|
||||||
|
REMOTE_PATH="/home/michael/ripster"
|
||||||
|
```
|
||||||
21
docs/deployment/index.md
Normal file
21
docs/deployment/index.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-laptop: **Entwicklungsumgebung**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Lokale Entwicklungsumgebung einrichten.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Entwicklung](development.md)
|
||||||
|
|
||||||
|
- :material-server: **Produktion**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ripster auf einem Server dauerhaft betreiben.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Produktion](production.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
193
docs/deployment/production.md
Normal file
193
docs/deployment/production.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Produktions-Deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet / Heimnetz
|
||||||
|
↓
|
||||||
|
nginx (Reverse Proxy)
|
||||||
|
↓
|
||||||
|
┌────┴────┐
|
||||||
|
│ │
|
||||||
|
Backend Frontend
|
||||||
|
:3001 (statische Dateien)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## systemd-Service
|
||||||
|
|
||||||
|
Für ein dauerhaftes Betreiben als systemd-Service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/ripster.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Ripster - Disc Ripping Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=michael
|
||||||
|
WorkingDirectory=/home/michael/ripster
|
||||||
|
ExecStart=/bin/bash /home/michael/ripster/start.sh
|
||||||
|
ExecStop=/bin/bash /home/michael/ripster/kill.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10s
|
||||||
|
|
||||||
|
# Umgebungsvariablen
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=3001
|
||||||
|
Environment=LOG_LEVEL=info
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Service aktivieren und starten
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable ripster
|
||||||
|
sudo systemctl start ripster
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
sudo systemctl status ripster
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
journalctl -u ripster -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend-Build
|
||||||
|
|
||||||
|
Für Produktion das Frontend bauen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Die statischen Dateien landen in `frontend/dist/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## nginx-Konfiguration
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/sites-available/ripster
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ripster.local;
|
||||||
|
|
||||||
|
# Statisches Frontend
|
||||||
|
root /home/michael/ripster/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA Fallback (React Router)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-Proxy zum Backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket-Proxy
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nur-Backend-Produktion (ohne nginx)
|
||||||
|
|
||||||
|
Falls kein Reverse Proxy gewünscht ist, kann das Backend die Frontend-Dateien direkt ausliefern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend bauen
|
||||||
|
cd frontend && npm run build
|
||||||
|
|
||||||
|
# Backend startet und serviert frontend/dist/
|
||||||
|
cd backend && NODE_ENV=production npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Backend ist so konfiguriert, dass es im Produktionsmodus die `frontend/dist/`-Dateien als statische Assets ausliefert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Datenbank sichern
|
||||||
|
cp backend/data/ripster.db backend/data/ripster.db.backup.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# Oder mit SQLite-eigenem Backup-Befehl
|
||||||
|
sqlite3 backend/data/ripster.db ".backup '/mnt/backup/ripster.db'"
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Automatisches Backup"
|
||||||
|
Cron-Job für tägliches Backup:
|
||||||
|
```cron
|
||||||
|
0 3 * * * sqlite3 /home/michael/ripster/backend/data/ripster.db ".backup '/mnt/backup/ripster-$(date +\%Y\%m\%d).db'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log-Rotation
|
||||||
|
|
||||||
|
Ripster rotiert Logs automatisch täglich. Falls zusätzlich systemd-Journal-Rotation gewünscht ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/logrotate.d/ripster
|
||||||
|
/home/michael/ripster/backend/logs/*.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
compress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
!!! warning "Heimnetz-Einsatz"
|
||||||
|
Ripster ist für den Einsatz im **lokalen Heimnetz** konzipiert und enthält **keine Authentifizierung**. Stelle sicher, dass der Dienst nicht öffentlich erreichbar ist.
|
||||||
|
|
||||||
|
Falls öffentlicher Zugang benötigt wird:
|
||||||
|
|
||||||
|
1. **Basic Auth** via nginx:
|
||||||
|
```bash
|
||||||
|
sudo htpasswd -c /etc/nginx/.htpasswd michael
|
||||||
|
```
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
auth_basic "Ripster";
|
||||||
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **VPN-Zugang** (empfohlen): Zugriff nur über WireGuard/OpenVPN
|
||||||
|
|
||||||
|
3. **SSL/TLS**: Let's Encrypt mit certbot für HTTPS
|
||||||
118
docs/getting-started/configuration.md
Normal file
118
docs/getting-started/configuration.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Konfiguration
|
||||||
|
|
||||||
|
Alle Einstellungen werden über die Web-Oberfläche unter **Einstellungen** verwaltet und in der SQLite-Datenbank gespeichert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pflichteinstellungen
|
||||||
|
|
||||||
|
Diese Einstellungen müssen vor dem ersten Rip konfiguriert werden:
|
||||||
|
|
||||||
|
### Pfade
|
||||||
|
|
||||||
|
| Einstellung | Beschreibung | Beispiel |
|
||||||
|
|------------|-------------|---------|
|
||||||
|
| `raw_dir` | Verzeichnis für rohe MKV-Dateien | `/mnt/nas/raw` |
|
||||||
|
| `movie_dir` | Ausgabeverzeichnis für kodierte Filme | `/mnt/nas/movies` |
|
||||||
|
| `log_dir` | Verzeichnis für Log-Dateien | `/var/log/ripster` |
|
||||||
|
|
||||||
|
!!! warning "Berechtigungen"
|
||||||
|
Der Ripster-Prozess benötigt **Schreibrechte** auf alle konfigurierten Verzeichnisse.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verzeichnisse erstellen und Berechtigungen setzen
|
||||||
|
sudo mkdir -p /mnt/nas/{raw,movies}
|
||||||
|
sudo chown $USER:$USER /mnt/nas/{raw,movies}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OMDb API
|
||||||
|
|
||||||
|
| Einstellung | Beschreibung |
|
||||||
|
|------------|-------------|
|
||||||
|
| `omdb_api_key` | API-Key von omdbapi.com |
|
||||||
|
| `omdb_default_type` | Standard-Suchtyp: `movie` oder `series` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool-Konfiguration
|
||||||
|
|
||||||
|
| Einstellung | Standard | Beschreibung |
|
||||||
|
|------------|---------|-------------|
|
||||||
|
| `makemkv_command` | `makemkvcon` | Pfad oder Befehl für MakeMKV |
|
||||||
|
| `handbrake_command` | `HandBrakeCLI` | Pfad oder Befehl für HandBrake |
|
||||||
|
| `mediainfo_command` | `mediainfo` | Pfad oder Befehl für MediaInfo |
|
||||||
|
|
||||||
|
!!! tip "Absolute Pfade"
|
||||||
|
Falls die Tools nicht im `PATH` sind, verwende absolute Pfade:
|
||||||
|
```
|
||||||
|
/usr/local/bin/HandBrakeCLI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Encoding-Konfiguration
|
||||||
|
|
||||||
|
| Einstellung | Standard | Beschreibung |
|
||||||
|
|------------|---------|-------------|
|
||||||
|
| `handbrake_preset` | `H.265 MKV 1080p30` | HandBrake-Preset-Name |
|
||||||
|
| `handbrake_extra_args` | _(leer)_ | Zusätzliche HandBrake-Argumente |
|
||||||
|
| `output_extension` | `mkv` | Dateiendung der Ausgabedatei |
|
||||||
|
| `filename_template` | `{title} ({year})` | Template für Dateinamen |
|
||||||
|
|
||||||
|
### Dateiname-Template
|
||||||
|
|
||||||
|
Das Template unterstützt folgende Platzhalter:
|
||||||
|
|
||||||
|
| Platzhalter | Beschreibung | Beispiel |
|
||||||
|
|------------|-------------|---------|
|
||||||
|
| `{title}` | Filmtitel | `Inception` |
|
||||||
|
| `{year}` | Erscheinungsjahr | `2010` |
|
||||||
|
| `{imdb_id}` | IMDb-ID | `tt1375666` |
|
||||||
|
| `{type}` | `movie` oder `series` | `movie` |
|
||||||
|
|
||||||
|
**Beispiel-Template:**
|
||||||
|
```
|
||||||
|
{title} ({year})
|
||||||
|
→ Inception (2010).mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Laufwerk-Konfiguration
|
||||||
|
|
||||||
|
| Einstellung | Standard | Beschreibung |
|
||||||
|
|------------|---------|-------------|
|
||||||
|
| `drive_mode` | `auto` | `auto` (automatisch erkennen) oder `explicit` (festes Gerät) |
|
||||||
|
| `drive_device` | `/dev/sr0` | Geräte-Pfad (nur bei `explicit`) |
|
||||||
|
| `disc_poll_interval_ms` | `5000` | Polling-Intervall in Millisekunden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MakeMKV-Konfiguration
|
||||||
|
|
||||||
|
| Einstellung | Standard | Beschreibung |
|
||||||
|
|------------|---------|-------------|
|
||||||
|
| `makemkv_min_length_minutes` | `15` | Mindestlänge für Titel in Minuten |
|
||||||
|
| `makemkv_backup_mode` | `false` | Backup-Modus statt MKV-Modus |
|
||||||
|
|
||||||
|
!!! info "Backup-Modus"
|
||||||
|
Im Backup-Modus erstellt MakeMKV eine vollständige Kopie der Disc (inkl. Menüs). Der Standardmodus erstellt direkt MKV-Dateien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benachrichtigungen (PushOver)
|
||||||
|
|
||||||
|
| Einstellung | Beschreibung |
|
||||||
|
|------------|-------------|
|
||||||
|
| `pushover_user_key` | Dein PushOver User-Key |
|
||||||
|
| `pushover_api_token` | API-Token deiner PushOver-App |
|
||||||
|
|
||||||
|
Nach der Eingabe kann die Verbindung mit dem **Test-Button** geprüft werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vollständige Einstellungsreferenz
|
||||||
|
|
||||||
|
Eine vollständige Liste aller Einstellungen mit Typen, Validierung und Standardwerten findest du unter:
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Einstellungsreferenz](../configuration/settings-reference.md)
|
||||||
41
docs/getting-started/index.md
Normal file
41
docs/getting-started/index.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Erste Schritte
|
||||||
|
|
||||||
|
Dieser Abschnitt führt dich durch die Installation und Einrichtung von Ripster.
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-list-check: **Voraussetzungen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Systemanforderungen und externe Tools, die vor der Installation benötigt werden.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Voraussetzungen prüfen](prerequisites.md)
|
||||||
|
|
||||||
|
- :material-download: **Installation**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Schritt-für-Schritt-Anleitung zur Installation von Ripster.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Installation starten](installation.md)
|
||||||
|
|
||||||
|
- :material-tune: **Konfiguration**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Einrichten von Pfaden, API-Keys und Encoding-Presets.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Konfigurieren](configuration.md)
|
||||||
|
|
||||||
|
- :material-rocket-launch: **Schnellstart**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Rippe deinen ersten Film in wenigen Minuten.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Loslegen](quickstart.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
140
docs/getting-started/installation.md
Normal file
140
docs/getting-started/installation.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_GITHUB_USERNAME/ripster.git
|
||||||
|
cd ripster
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automatischer Start
|
||||||
|
|
||||||
|
Ripster enthält ein `start.sh`-Skript, das alle Abhängigkeiten installiert und Backend + Frontend gleichzeitig startet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript führt automatisch folgende Schritte durch:
|
||||||
|
|
||||||
|
1. **Node.js-Versionscheck** – prüft ob >= 20.19.0 verfügbar ist (mit nvm/npx-Fallback)
|
||||||
|
2. **Abhängigkeiten installieren** – `npm install` für Root, Backend und Frontend
|
||||||
|
3. **Dienste starten** – Backend und Frontend werden parallel gestartet
|
||||||
|
|
||||||
|
!!! success "Erfolgreich gestartet"
|
||||||
|
- Backend läuft auf `http://localhost:3001`
|
||||||
|
- Frontend läuft auf `http://localhost:5173`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuelle Installation
|
||||||
|
|
||||||
|
Falls du mehr Kontrolle benötigst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Root-Abhängigkeiten
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Backend-Abhängigkeiten
|
||||||
|
cd backend && npm install && cd ..
|
||||||
|
|
||||||
|
# Frontend-Abhängigkeiten
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
|
# Backend starten (Terminal 1)
|
||||||
|
cd backend && npm run dev
|
||||||
|
|
||||||
|
# Frontend starten (Terminal 2)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Bearbeite `backend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3001
|
||||||
|
DB_PATH=./data/ripster.db
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
LOG_DIR=./logs
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Bearbeite `frontend/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE=http://localhost:3001
|
||||||
|
VITE_WS_URL=ws://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Alle Umgebungsvariablen"
|
||||||
|
Eine vollständige Übersicht aller Umgebungsvariablen findest du unter [Umgebungsvariablen](../configuration/environment.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank initialisieren
|
||||||
|
|
||||||
|
Die SQLite-Datenbank wird **automatisch** beim ersten Start erstellt und mit dem Schema aus `db/schema.sql` initialisiert. Es sind keine manuellen Datenbankschritte erforderlich.
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/data/
|
||||||
|
└── ripster.db ← Wird automatisch angelegt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stoppen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./kill.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript beendet Backend- und Frontend-Prozesse graceful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verzeichnisstruktur nach Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
ripster/
|
||||||
|
├── backend/
|
||||||
|
│ ├── data/ ← SQLite-Datenbank (nach erstem Start)
|
||||||
|
│ ├── logs/ ← Log-Dateien
|
||||||
|
│ ├── node_modules/ ← Backend-Abhängigkeiten
|
||||||
|
│ └── .env ← Backend-Konfiguration
|
||||||
|
├── frontend/
|
||||||
|
│ ├── node_modules/ ← Frontend-Abhängigkeiten
|
||||||
|
│ ├── dist/ ← Production-Build (nach npm run build)
|
||||||
|
│ └── .env ← Frontend-Konfiguration
|
||||||
|
└── node_modules/ ← Root-Abhängigkeiten (concurrently etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
Nach erfolgreicher Installation:
|
||||||
|
|
||||||
|
1. Öffne [http://localhost:5173](http://localhost:5173)
|
||||||
|
2. Navigiere zu **Einstellungen**
|
||||||
|
3. Konfiguriere Pfade, API-Keys und Encoding-Presets
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Zur Konfiguration](configuration.md)
|
||||||
158
docs/getting-started/prerequisites.md
Normal file
158
docs/getting-started/prerequisites.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Voraussetzungen
|
||||||
|
|
||||||
|
Bevor du Ripster installierst, stelle sicher, dass folgende Software auf deinem System verfügbar ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System-Anforderungen
|
||||||
|
|
||||||
|
| Anforderung | Mindestversion | Empfohlen |
|
||||||
|
|------------|----------------|-----------|
|
||||||
|
| **Betriebssystem** | Linux / macOS | Ubuntu 22.04+ |
|
||||||
|
| **Node.js** | 20.19.0 | 20.x LTS |
|
||||||
|
| **RAM** | 4 GB | 8 GB+ |
|
||||||
|
| **Festplatte** | 50 GB frei | 500 GB+ (für Roh-MKVs) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js
|
||||||
|
|
||||||
|
Ripster benötigt **Node.js >= 20.19.0**.
|
||||||
|
|
||||||
|
=== "nvm (empfohlen)"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# nvm installieren
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
|
||||||
|
# Node.js 20 installieren
|
||||||
|
nvm install 20
|
||||||
|
nvm use 20
|
||||||
|
|
||||||
|
# Version prüfen
|
||||||
|
node --version # v20.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Ubuntu/Debian"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
node --version # v20.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "macOS (Homebrew)"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install node@20
|
||||||
|
node --version # v20.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Externe Tools
|
||||||
|
|
||||||
|
### MakeMKV
|
||||||
|
|
||||||
|
!!! warning "Lizenz erforderlich"
|
||||||
|
MakeMKV ist für den persönlichen Gebrauch kostenlos (Beta-Lizenz), benötigt aber eine gültige Lizenz.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian - PPA verwenden
|
||||||
|
sudo add-apt-repository ppa:heyarje/makemkv-beta
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install makemkv-bin makemkv-oss
|
||||||
|
|
||||||
|
# Installierte Version prüfen
|
||||||
|
makemkvcon --version
|
||||||
|
```
|
||||||
|
|
||||||
|
[:octicons-link-external-24: MakeMKV Download](https://www.makemkv.com/download/){ .md-button }
|
||||||
|
|
||||||
|
### HandBrake CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo add-apt-repository ppa:stebbins/handbrake-releases
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install handbrake-cli
|
||||||
|
|
||||||
|
# Version prüfen
|
||||||
|
HandBrakeCLI --version
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install handbrake
|
||||||
|
```
|
||||||
|
|
||||||
|
[:octicons-link-external-24: HandBrake Download](https://handbrake.fr/downloads2.php){ .md-button }
|
||||||
|
|
||||||
|
### MediaInfo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install mediainfo
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install mediainfo
|
||||||
|
|
||||||
|
# Version prüfen
|
||||||
|
mediainfo --Version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disc-Laufwerk
|
||||||
|
|
||||||
|
Ripster benötigt ein physisches **DVD- oder Blu-ray-Laufwerk**.
|
||||||
|
|
||||||
|
!!! info "Blu-ray unter Linux"
|
||||||
|
Für Blu-ray-Ripping unter Linux wird zusätzlich `libaacs` benötigt. MakeMKV bringt jedoch eine eigene Entschlüsselung mit, daher ist dies in den meisten Fällen nicht erforderlich.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Laufwerk prüfen
|
||||||
|
ls /dev/sr*
|
||||||
|
# oder
|
||||||
|
lsblk | grep rom
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OMDb API-Key
|
||||||
|
|
||||||
|
Ripster verwendet die [OMDb API](https://www.omdbapi.com/) für Filmmetadaten.
|
||||||
|
|
||||||
|
1. Registriere dich kostenlos auf [omdbapi.com](https://www.omdbapi.com/apikey.aspx)
|
||||||
|
2. Bestätige deine E-Mail-Adresse
|
||||||
|
3. Notiere deinen API-Key – du gibst ihn später in den Einstellungen ein
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optionale Voraussetzungen
|
||||||
|
|
||||||
|
### PushOver (Benachrichtigungen)
|
||||||
|
|
||||||
|
Für mobile Push-Benachrichtigungen bei Fertigstellung oder Fehlern:
|
||||||
|
|
||||||
|
- App kaufen auf [pushover.net](https://pushover.net) (~5 USD einmalig)
|
||||||
|
- **User Key** und **API Token** notieren
|
||||||
|
|
||||||
|
### SSH-Zugang (Deployment)
|
||||||
|
|
||||||
|
Für Remote-Deployment via `deploy-ripster.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sshpass installieren
|
||||||
|
sudo apt-get install sshpass
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkliste
|
||||||
|
|
||||||
|
- [ ] Node.js >= 20.19.0 installiert (`node --version`)
|
||||||
|
- [ ] `makemkvcon` installiert (`makemkvcon --version`)
|
||||||
|
- [ ] `HandBrakeCLI` installiert (`HandBrakeCLI --version`)
|
||||||
|
- [ ] `mediainfo` installiert (`mediainfo --Version`)
|
||||||
|
- [ ] DVD/Blu-ray Laufwerk vorhanden (`ls /dev/sr*`)
|
||||||
|
- [ ] OMDb API-Key beschafft
|
||||||
144
docs/getting-started/quickstart.md
Normal file
144
docs/getting-started/quickstart.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Schnellstart
|
||||||
|
|
||||||
|
Nach der [Installation](installation.md) und [Konfiguration](configuration.md) kannst du sofort mit dem ersten Rip beginnen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ripster starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ripster
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne [http://localhost:5173](http://localhost:5173) im Browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard
|
||||||
|
|
||||||
|
Das Dashboard zeigt den aktuellen Pipeline-Status:
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: IDLE – Bereit
|
||||||
|
Warte auf Disc...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Disc einlegen
|
||||||
|
|
||||||
|
Lege eine DVD oder Blu-ray in das Laufwerk ein. Ripster erkennt die Disc automatisch (Polling-Intervall konfigurierbar, Standard: 5 Sekunden) und wechselt in den Status **ANALYZING**.
|
||||||
|
|
||||||
|
!!! tip "Manuelle Analyse"
|
||||||
|
Falls die Disc nicht automatisch erkannt wird, kann über die API eine manuelle Analyse ausgelöst werden:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/pipeline/analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Analyse abwarten
|
||||||
|
|
||||||
|
MakeMKV analysiert die Disc-Struktur. Dieser Vorgang dauert je nach Disc **30 Sekunden bis 5 Minuten**.
|
||||||
|
|
||||||
|
Der Fortschritt wird live im Dashboard angezeigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Metadaten auswählen
|
||||||
|
|
||||||
|
Nach der Analyse öffnet sich der **Metadaten-Dialog**:
|
||||||
|
|
||||||
|
1. **Titel suchen**: Gib den Filmtitel in die Suchleiste ein
|
||||||
|
2. **Ergebnis auswählen**: Wähle den passenden Film aus der OMDb-Liste
|
||||||
|
3. **Playlist wählen** *(nur Blu-ray)*: Bei Blu-rays mit mehreren Playlists zeigt Ripster eine Analyse der wahrscheinlich korrekten Playlist an
|
||||||
|
4. **Bestätigen**: Klicke auf "Bestätigen"
|
||||||
|
|
||||||
|
!!! info "Playlist-Obfuskierung"
|
||||||
|
Einige Blu-rays enthalten absichtlich viele Fake-Playlists. Ripster analysiert diese automatisch und schlägt die wahrscheinlich korrekte Playlist vor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ripping starten
|
||||||
|
|
||||||
|
Nach der Metadaten-Auswahl wechselt der Status zu **READY_TO_START**.
|
||||||
|
|
||||||
|
Klicke auf **"Starten"** – MakeMKV beginnt mit dem Ripping.
|
||||||
|
|
||||||
|
**Typische Dauer:**
|
||||||
|
- DVD: 20–40 Minuten
|
||||||
|
- Blu-ray: 45–120 Minuten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Encode-Review
|
||||||
|
|
||||||
|
Nach dem Ripping analysiert MediaInfo die Track-Struktur. Im **Encode-Review** kannst du:
|
||||||
|
|
||||||
|
- **Audio-Tracks** auswählen (z. B. Deutsch + Englisch)
|
||||||
|
- **Untertitel-Tracks** auswählen
|
||||||
|
- Überflüssige Tracks deaktivieren
|
||||||
|
|
||||||
|
Klicke auf **"Encodierung bestätigen"**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Encoding
|
||||||
|
|
||||||
|
HandBrake encodiert die Datei mit dem konfigurierten Preset.
|
||||||
|
|
||||||
|
**Fortschrittsanzeige:**
|
||||||
|
- Aktueller Prozentsatz
|
||||||
|
- Geschätzte Restzeit (ETA)
|
||||||
|
- Encoding-Geschwindigkeit (FPS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Fertig!
|
||||||
|
|
||||||
|
Status wechselt zu **FINISHED**. Die encodierte Datei liegt im konfigurierten `movie_dir`.
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/nas/movies/
|
||||||
|
└── Inception (2010).mkv ← Fertige Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! success "PushOver-Benachrichtigung"
|
||||||
|
Falls PushOver konfiguriert ist, erhältst du eine Push-Benachrichtigung auf dein Mobilgerät.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow-Zusammenfassung
|
||||||
|
|
||||||
|
```
|
||||||
|
Disc einlegen
|
||||||
|
↓
|
||||||
|
ANALYZING (MakeMKV analysiert)
|
||||||
|
↓
|
||||||
|
METADATA_SELECTION (Titel & Playlist wählen)
|
||||||
|
↓
|
||||||
|
READY_TO_START → [Starten]
|
||||||
|
↓
|
||||||
|
RIPPING (MakeMKV rippt)
|
||||||
|
↓
|
||||||
|
MEDIAINFO_CHECK (Track-Analyse)
|
||||||
|
↓
|
||||||
|
READY_TO_ENCODE → [Bestätigen]
|
||||||
|
↓
|
||||||
|
ENCODING (HandBrake encodiert)
|
||||||
|
↓
|
||||||
|
FINISHED ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was tun bei Fehlern?
|
||||||
|
|
||||||
|
Falls ein Job in den Status **ERROR** wechselt:
|
||||||
|
|
||||||
|
1. Klicke auf **"Details"** im Dashboard
|
||||||
|
2. Prüfe die Log-Ausgabe
|
||||||
|
3. Klicke auf **"Retry"** um den Job erneut zu versuchen
|
||||||
|
|
||||||
|
Logs findest du auch in der [History-Seite](http://localhost:5173/history).
|
||||||
130
docs/index.md
Normal file
130
docs/index.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Ripster
|
||||||
|
|
||||||
|
**Halbautomatische Disc-Ripping-Plattform für DVDs und Blu-rays**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-disc: **Automatisiertes Ripping**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Disc einlegen – Ripster erkennt sie automatisch und startet den Analyse-Workflow mit MakeMKV.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Workflow verstehen](pipeline/workflow.md)
|
||||||
|
|
||||||
|
- :material-movie-open: **Metadata-Integration**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Automatische Suche in der OMDb-Datenbank für Filmtitel, Poster und IMDb-IDs.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Konfiguration](getting-started/configuration.md)
|
||||||
|
|
||||||
|
- :material-cog: **Flexibles Encoding**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
HandBrake-Encoding mit individueller Track-Auswahl für Audio- und Untertitelspuren.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Encode-Planung](pipeline/encoding.md)
|
||||||
|
|
||||||
|
- :material-history: **Job-Historie**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Vollständiges Audit-Trail aller Ripping-Jobs mit Logs und Re-Encode-Funktion.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: History API](api/history.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist Ripster?
|
||||||
|
|
||||||
|
Ripster ist eine webbasierte Anwendung zur **halbautomatischen Digitalisierung** von DVDs und Blu-rays. Die Anwendung kombiniert bewährte Open-Source-Tools zu einem durchgängigen, komfortablen Workflow:
|
||||||
|
|
||||||
|
```
|
||||||
|
Disc einlegen → Erkennung → Analyse → Metadaten wählen → Rippen → Encodieren → Fertig
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kernfunktionen
|
||||||
|
|
||||||
|
| Feature | Beschreibung |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Echtzeit-Updates** | WebSocket-basierte Live-Statusanzeige ohne Reload |
|
||||||
|
| **Intelligente Playlist-Analyse** | Erkennt Blu-ray Playlist-Verschleierung (Fake-Playlists) |
|
||||||
|
| **Track-Auswahl** | Individuelle Auswahl von Audio- und Untertitelspuren |
|
||||||
|
| **Orphan-Recovery** | Import von bereits gerippten Dateien als Jobs |
|
||||||
|
| **PushOver-Benachrichtigungen** | Mobile Alerts bei Fertigstellung oder Fehlern |
|
||||||
|
| **DB-Korruptions-Recovery** | Automatische Quarantäne bei korrupten SQLite-Dateien |
|
||||||
|
| **Re-Encoding** | Erneutes Encodieren ohne neu rippen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie-Stack
|
||||||
|
|
||||||
|
=== "Backend"
|
||||||
|
|
||||||
|
- **Node.js** >= 20.19.0 mit Express.js
|
||||||
|
- **SQLite3** mit automatischen Schema-Migrationen
|
||||||
|
- **WebSocket** (`ws`) für Echtzeit-Kommunikation
|
||||||
|
- Externe CLI-Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||||
|
|
||||||
|
=== "Frontend"
|
||||||
|
|
||||||
|
- **React** 18.3.1 mit React Router
|
||||||
|
- **Vite** 5.4.12 als Build-Tool
|
||||||
|
- **PrimeReact** 10.9.2 als UI-Bibliothek
|
||||||
|
- WebSocket-Client für Live-Updates
|
||||||
|
|
||||||
|
=== "Externe Tools"
|
||||||
|
|
||||||
|
| Tool | Zweck |
|
||||||
|
|------|-------|
|
||||||
|
| `makemkvcon` | Disc-Analyse & MKV/Backup-Ripping |
|
||||||
|
| `HandBrakeCLI` | Video-Encoding |
|
||||||
|
| `mediainfo` | Track-Informationen aus gerippten Dateien |
|
||||||
|
| OMDb API | Filmmetadaten (Titel, Poster, IMDb-ID) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Repository klonen
|
||||||
|
git clone https://github.com/YOUR_GITHUB_USERNAME/ripster.git
|
||||||
|
cd ripster
|
||||||
|
|
||||||
|
# 2. Starten (Node.js >= 20 erforderlich)
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
# 3. Browser öffnen
|
||||||
|
open http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Erste Schritte"
|
||||||
|
Die vollständige Installationsanleitung mit allen Voraussetzungen findest du unter [Erste Schritte](getting-started/index.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline-Überblick
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> IDLE
|
||||||
|
IDLE --> ANALYZING: Disc erkannt
|
||||||
|
ANALYZING --> METADATA_SELECTION: Analyse abgeschlossen
|
||||||
|
METADATA_SELECTION --> READY_TO_START: Metadaten bestätigt
|
||||||
|
READY_TO_START --> RIPPING: Start gedrückt
|
||||||
|
RIPPING --> MEDIAINFO_CHECK: MKV erstellt
|
||||||
|
MEDIAINFO_CHECK --> READY_TO_ENCODE: Tracks analysiert
|
||||||
|
READY_TO_ENCODE --> ENCODING: Encode bestätigt
|
||||||
|
ENCODING --> FINISHED: Encoding fertig
|
||||||
|
ENCODING --> ERROR: Fehler
|
||||||
|
RIPPING --> ERROR: Fehler
|
||||||
|
ERROR --> [*]
|
||||||
|
FINISHED --> [*]
|
||||||
|
```
|
||||||
159
docs/pipeline/encoding.md
Normal file
159
docs/pipeline/encoding.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Encode-Planung
|
||||||
|
|
||||||
|
`encodePlan.js` analysiert die MediaInfo-Ausgabe und erstellt einen strukturierten Encode-Plan mit Track-Auswahl.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
MediaInfo-JSON
|
||||||
|
↓
|
||||||
|
Track-Parsing (Video, Audio, Untertitel)
|
||||||
|
↓
|
||||||
|
Sprach-Normalisierung (ISO 639-1 → 639-3)
|
||||||
|
↓
|
||||||
|
Codec-Klassifizierung (copy-kompatibel / transcode)
|
||||||
|
↓
|
||||||
|
Encode-Plan generieren
|
||||||
|
↓
|
||||||
|
Benutzer-Review im Frontend
|
||||||
|
↓
|
||||||
|
HandBrake-CLI-Argumente aufbauen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Encode-Plan-Format
|
||||||
|
|
||||||
|
Der generierte Plan wird als JSON im Job-Datensatz gespeichert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"inputFile": "/mnt/raw/Inception_t00.mkv",
|
||||||
|
"outputFile": "/mnt/movies/Inception (2010).mkv",
|
||||||
|
"preset": "H.265 MKV 1080p30",
|
||||||
|
"audioTracks": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"codec": "dts",
|
||||||
|
"language": "deu",
|
||||||
|
"channels": 6,
|
||||||
|
"label": "Deutsch (DTS, 5.1)",
|
||||||
|
"copyCompatible": false,
|
||||||
|
"selected": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"codec": "truehd",
|
||||||
|
"language": "eng",
|
||||||
|
"channels": 8,
|
||||||
|
"label": "English (TrueHD, 7.1)",
|
||||||
|
"copyCompatible": true,
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subtitleTracks": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"language": "deu",
|
||||||
|
"label": "Deutsch",
|
||||||
|
"selected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprach-Normalisierung
|
||||||
|
|
||||||
|
MediaInfo liefert Sprachcodes in verschiedenen Formaten. `encodePlan.js` normalisiert diese auf **ISO 639-3**:
|
||||||
|
|
||||||
|
| MediaInfo-Output | Normalisiert |
|
||||||
|
|----------------|-------------|
|
||||||
|
| `de` | `deu` |
|
||||||
|
| `German` | `deu` |
|
||||||
|
| `en` | `eng` |
|
||||||
|
| `English` | `eng` |
|
||||||
|
| `fr` | `fra` |
|
||||||
|
| `ja` | `jpn` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codec-Klassifizierung
|
||||||
|
|
||||||
|
HandBrake kann einige Codecs direkt kopieren (ohne Transcoding):
|
||||||
|
|
||||||
|
| Codec | Copy-kompatibel | HandBrake-Encoder |
|
||||||
|
|-------|----------------|------------------|
|
||||||
|
| `ac3` | ✅ Ja | `copy:ac3` |
|
||||||
|
| `aac` | ✅ Ja | `copy:aac` |
|
||||||
|
| `mp3` | ✅ Ja | `copy:mp3` |
|
||||||
|
| `truehd` | ✅ Ja | `copy:truehd` |
|
||||||
|
| `eac3` | ✅ Ja | `copy:eac3` |
|
||||||
|
| `dts` | ❌ Nein | `ffaac` (transcode) |
|
||||||
|
| `dtshd` | ❌ Nein | `ffaac` (transcode) |
|
||||||
|
|
||||||
|
!!! info "DTS-Transcoding"
|
||||||
|
HandBrake unterstützt kein DTS-Passthrough in den Standard-Builds. DTS-Tracks werden zu AAC transcodiert, es sei denn, du verwendest einen speziellen HandBrake-Build mit DTS-Unterstützung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HandBrake-CLI-Argumente
|
||||||
|
|
||||||
|
Aus dem Encode-Plan generiert `commandLine.js` die HandBrake-Argumente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI \
|
||||||
|
--input "/mnt/raw/Inception_t00.mkv" \
|
||||||
|
--output "/mnt/movies/Inception (2010).mkv" \
|
||||||
|
--preset "H.265 MKV 1080p30" \
|
||||||
|
--audio 1,2 \
|
||||||
|
--aencoder copy:truehd,ffaac \
|
||||||
|
--subtitle 1 \
|
||||||
|
--subtitle-default 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zusätzliche Argumente
|
||||||
|
|
||||||
|
Über die Einstellung `handbrake_extra_args` können beliebige HandBrake-Argumente ergänzt werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
--crop 0:0:0:0 --loose-anamorphic
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateiname-Template
|
||||||
|
|
||||||
|
Die Ausgabedatei wird über das konfigurierte Template benannt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Template: {title} ({year})
|
||||||
|
Ergebnis: Inception (2010).mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
Verfügbare Platzhalter:
|
||||||
|
|
||||||
|
| Platzhalter | Wert |
|
||||||
|
|------------|------|
|
||||||
|
| `{title}` | Filmtitel von OMDb |
|
||||||
|
| `{year}` | Erscheinungsjahr |
|
||||||
|
| `{imdb_id}` | IMDb-ID (z.B. `tt1375666`) |
|
||||||
|
| `{type}` | `movie` oder `series` |
|
||||||
|
|
||||||
|
Sonderzeichen im Dateinamen werden automatisch sanitisiert (`:`, `/`, `?` etc. werden entfernt oder ersetzt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Re-Encoding
|
||||||
|
|
||||||
|
Abgeschlossene Jobs können mit geänderten Einstellungen neu encodiert werden:
|
||||||
|
|
||||||
|
1. Job in der History auswählen
|
||||||
|
2. "Re-Encode" klicken
|
||||||
|
3. Neue Track-Auswahl treffen (oder bestehende übernehmen)
|
||||||
|
4. Encoding startet mit aktuellen Einstellungen
|
||||||
|
|
||||||
|
Dies ist nützlich, wenn sich das HandBrake-Preset oder die Track-Auswahl geändert hat, ohne die zeitintensive Ripping-Phase zu wiederholen.
|
||||||
31
docs/pipeline/index.md
Normal file
31
docs/pipeline/index.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Pipeline
|
||||||
|
|
||||||
|
Der Pipeline-Abschnitt beschreibt den Kern-Workflow von Ripster.
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-state-machine: **Workflow & Zustände**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Der vollständige Ripping-Workflow mit allen Zustandsübergängen.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Workflow](workflow.md)
|
||||||
|
|
||||||
|
- :material-film: **Encode-Planung**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Wie Ripster Audio- und Untertitel-Tracks analysiert und Encode-Pläne erstellt.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Encoding](encoding.md)
|
||||||
|
|
||||||
|
- :material-playlist-check: **Playlist-Analyse**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Erkennung von Blu-ray Playlist-Obfuskierung und Auswahl der korrekten Playlist.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Playlist-Analyse](playlist-analysis.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
119
docs/pipeline/playlist-analysis.md
Normal file
119
docs/pipeline/playlist-analysis.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Playlist-Analyse
|
||||||
|
|
||||||
|
Einige Blu-rays verwenden **Playlist-Obfuskierung** als Kopierschutz-Mechanismus. Ripster erkennt dieses Muster und hilft bei der Auswahl der korrekten Playlist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Problem: Playlist-Obfuskierung
|
||||||
|
|
||||||
|
Moderne Blu-rays können Hunderte von Playlists enthalten, von denen nur eine den eigentlichen Film enthält. Die anderen sind:
|
||||||
|
|
||||||
|
- **Kurze Dummy-Playlists** (wenige Sekunden bis Minuten)
|
||||||
|
- **Umgeordnete Segmente** (falsche Reihenfolge der Film-Segmente)
|
||||||
|
- **Duplizierte Inhalte** (mehrere Playlists mit gleichem Inhalt, verschiedenen Timestamps)
|
||||||
|
|
||||||
|
Dies macht es schwierig, die korrekte Playlist manuell zu identifizieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ripsters Analyse-Algorithmus
|
||||||
|
|
||||||
|
`playlistAnalysis.js` analysiert alle von MakeMKV erkannten Playlists nach mehreren Kriterien:
|
||||||
|
|
||||||
|
### 1. Laufzeit-Matching
|
||||||
|
|
||||||
|
Die erwartete Laufzeit (aus OMDb-Metadaten) wird mit der Playlist-Laufzeit verglichen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Filmtitel: Inception (2010)
|
||||||
|
OMDb-Laufzeit: 148 Minuten
|
||||||
|
|
||||||
|
Playlist 00800.mpls: 148:22 → ✅ Match
|
||||||
|
Playlist 00801.mpls: 1:23 → ❌ Zu kurz
|
||||||
|
Playlist 00900.mpls: 148:25 → ✅ Match (Duplikat?)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Titel-Ähnlichkeit
|
||||||
|
|
||||||
|
Playlists mit Namen, die dem Filmtitel ähneln, werden bevorzugt.
|
||||||
|
|
||||||
|
### 3. Segment-Validierung
|
||||||
|
|
||||||
|
Die Playlist-Segmente werden auf logische Reihenfolge geprüft.
|
||||||
|
|
||||||
|
### 4. Häufigkeits-Analyse
|
||||||
|
|
||||||
|
Bei mehreren Kandidaten: Welche Segment-Kombination kommt am häufigsten vor?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benutzer-Interface
|
||||||
|
|
||||||
|
Wenn Playlist-Obfuskierung erkannt wird, zeigt Ripster im `MetadataSelectionDialog` eine Playlist-Auswahl:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Playlist auswählen │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ ★ 00800.mpls 2:28:05 ✓ Empfohlen (Laufzeit passt) │
|
||||||
|
│ 00801.mpls 0:01:23 Kurz (wahrscheinlich Menü) │
|
||||||
|
│ 00900.mpls 2:28:12 Mögliche Alternative │
|
||||||
|
│ 00901.mpls 0:00:45 Kurz │
|
||||||
|
│ ... ... ... │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ Hinweis: 847 Playlists gefunden – Analyse empfiehlt │
|
||||||
|
│ Playlist 00800.mpls als Hauptfilm. │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse-Ergebnis-Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"playlist": "00800.mpls",
|
||||||
|
"duration": "2:28:05",
|
||||||
|
"durationSeconds": 8885,
|
||||||
|
"score": 0.95,
|
||||||
|
"recommended": true,
|
||||||
|
"reasons": ["Laufzeit stimmt mit OMDb überein", "Häufigste Segment-Kombination"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"playlist": "00900.mpls",
|
||||||
|
"duration": "2:28:12",
|
||||||
|
"durationSeconds": 8892,
|
||||||
|
"score": 0.72,
|
||||||
|
"recommended": false,
|
||||||
|
"reasons": ["Ähnliche Laufzeit", "Seltene Segment-Kombination"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalPlaylists": 847,
|
||||||
|
"recommendation": "00800.mpls"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manuelle Auswahl
|
||||||
|
|
||||||
|
Falls die automatische Empfehlung nicht korrekt ist:
|
||||||
|
|
||||||
|
1. Wähle eine andere Playlist aus der Liste
|
||||||
|
2. Beachte die Laufzeit-Angabe
|
||||||
|
3. Vergleiche mit der erwarteten Filmlänge (aus OMDb oder Disc-Hülle)
|
||||||
|
|
||||||
|
!!! tip "Tipp"
|
||||||
|
Bei Blu-rays von bekannten Filmen kannst du die korrekte Playlist oft über Foren wie [MakeMKV-Forum](https://www.makemkv.com/forum/) verifizieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Die Playlist-Analyse ist automatisch aktiv. Einstellbar ist:
|
||||||
|
|
||||||
|
| Parameter | Beschreibung |
|
||||||
|
|----------|-------------|
|
||||||
|
| `makemkv_min_length_minutes` | Mindestlänge, um als Hauptfilm-Kandidat zu gelten (Standard: 15 Min) |
|
||||||
222
docs/pipeline/workflow.md
Normal file
222
docs/pipeline/workflow.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Workflow & Zustände
|
||||||
|
|
||||||
|
Der Ripping-Workflow von Ripster ist als **State Machine** implementiert. Jeder Zustand hat klar definierte Übergangsbedingungen und Aktionen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zustandsdiagramm
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
direction LR
|
||||||
|
[*] --> IDLE
|
||||||
|
|
||||||
|
IDLE --> ANALYZING: Disc erkannt\n(automatisch)
|
||||||
|
ANALYZING --> METADATA_SELECTION: MakeMKV-Analyse\nabgeschlossen
|
||||||
|
METADATA_SELECTION --> READY_TO_START: Benutzer wählt\nMetadaten + Playlist
|
||||||
|
|
||||||
|
READY_TO_START --> RIPPING: Benutzer startet\nden Job
|
||||||
|
|
||||||
|
RIPPING --> MEDIAINFO_CHECK: MKV/Backup\nerstellt
|
||||||
|
MEDIAINFO_CHECK --> READY_TO_ENCODE: Track-Analyse\nabgeschlossen
|
||||||
|
|
||||||
|
READY_TO_ENCODE --> ENCODING: Benutzer bestätigt\nEncode + Tracks
|
||||||
|
|
||||||
|
ENCODING --> FINISHED: HandBrake\nfertig
|
||||||
|
FINISHED --> IDLE: Disc auswerfen /\nneue Disc
|
||||||
|
|
||||||
|
RIPPING --> ERROR: Fehler
|
||||||
|
ENCODING --> ERROR: Fehler
|
||||||
|
ANALYZING --> ERROR: Fehler
|
||||||
|
ERROR --> IDLE: cancelPipeline()\noder retryJob()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zustandsbeschreibungen
|
||||||
|
|
||||||
|
### IDLE
|
||||||
|
|
||||||
|
**Ausgangszustand.** Ripster wartet auf eine Disc.
|
||||||
|
|
||||||
|
- `diskDetectionService` pollt das Laufwerk (Intervall konfigurierbar)
|
||||||
|
- Bei Disc-Erkennung: Automatischer Übergang zu `ANALYZING`
|
||||||
|
- WebSocket-Event: `DISC_DETECTED`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ANALYZING
|
||||||
|
|
||||||
|
**MakeMKV analysiert die Disc-Struktur.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon -r info disc:0
|
||||||
|
```
|
||||||
|
|
||||||
|
- Liest Titel-Informationen, Playlist-Liste, Track-Details
|
||||||
|
- Fortschritt wird über WebSocket übertragen
|
||||||
|
- Bei Blu-ray: Playlist-Liste für spätere Analyse gesammelt
|
||||||
|
- Dauer: 30 Sekunden bis 5 Minuten
|
||||||
|
|
||||||
|
**Ausgabe:** JSON-Struktur mit allen Titeln und Playlists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### METADATA_SELECTION
|
||||||
|
|
||||||
|
**Wartet auf Benutzer-Eingabe.**
|
||||||
|
|
||||||
|
- `MetadataSelectionDialog` wird im Frontend angezeigt
|
||||||
|
- Benutzer sucht in OMDb nach dem Filmtitel
|
||||||
|
- Benutzer wählt einen Eintrag aus den Suchergebnissen
|
||||||
|
- Bei Blu-ray: Playlist-Auswahl (mit Empfehlung durch `playlistAnalysis.js`)
|
||||||
|
|
||||||
|
**Übergang:** `selectMetadata(jobId, omdbData, playlist)` aufrufen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### READY_TO_START
|
||||||
|
|
||||||
|
**Metadaten bestätigt, bereit zum Starten.**
|
||||||
|
|
||||||
|
- Job-Datensatz in Datenbank mit Metadaten aktualisiert
|
||||||
|
- Start-Button im Dashboard aktiv
|
||||||
|
- Benutzer kann Metadaten noch mal ändern
|
||||||
|
|
||||||
|
**Übergang:** `startJob(jobId)` aufrufen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### RIPPING
|
||||||
|
|
||||||
|
**MakeMKV rippt die Disc.**
|
||||||
|
|
||||||
|
=== "MKV-Modus (Standard)"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon mkv disc:0 all /path/to/raw/ \
|
||||||
|
--minlength=900
|
||||||
|
```
|
||||||
|
|
||||||
|
Erstellt MKV-Datei(en) direkt aus den gewählten Titeln.
|
||||||
|
|
||||||
|
=== "Backup-Modus"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon backup disc:0 /path/to/raw/backup/ \
|
||||||
|
--decrypt
|
||||||
|
```
|
||||||
|
|
||||||
|
Erstellt vollständiges Disc-Backup inkl. Menüs.
|
||||||
|
|
||||||
|
**Live-Updates:** Fortschritt wird zeilenweise aus MakeMKV-Ausgabe geparst:
|
||||||
|
|
||||||
|
```
|
||||||
|
PRGC:5012,0,2048 → Fortschritt: X%
|
||||||
|
PRGT:5011,0,"..." → Aktueller Task
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MEDIAINFO_CHECK
|
||||||
|
|
||||||
|
**MediaInfo analysiert die gerippte Datei.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mediainfo --Output=JSON /path/to/raw/file.mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
- Liest alle Audio-, Untertitel- und Video-Tracks
|
||||||
|
- Extrahiert Codec-Informationen (DTS, TrueHD, AC-3, ...)
|
||||||
|
- Bestimmt Sprachcodes (ISO 639)
|
||||||
|
- Erstellt Encode-Plan via `encodePlan.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### READY_TO_ENCODE
|
||||||
|
|
||||||
|
**Encode-Plan erstellt, wartet auf Bestätigung.**
|
||||||
|
|
||||||
|
- `MediaInfoReviewPanel` wird im Frontend angezeigt
|
||||||
|
- Benutzer kann Audio- und Untertitel-Tracks de/aktivieren
|
||||||
|
- Vorgeschlagene Tracks basierend auf Sprach-Einstellungen
|
||||||
|
|
||||||
|
**Übergang:** `confirmEncode(jobId, trackSelection)` aufrufen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ENCODING
|
||||||
|
|
||||||
|
**HandBrake encodiert die Datei.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI \
|
||||||
|
--input /path/to/raw.mkv \
|
||||||
|
--output /path/to/movies/Inception\ \(2010\).mkv \
|
||||||
|
--preset "H.265 MKV 1080p30" \
|
||||||
|
--audio 1,2 \
|
||||||
|
--subtitle 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Live-Updates:** Fortschritt wird aus HandBrake-stderr geparst:
|
||||||
|
|
||||||
|
```
|
||||||
|
Encoding: task 1 of 1, 73.50 %
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FINISHED
|
||||||
|
|
||||||
|
**Job erfolgreich abgeschlossen.**
|
||||||
|
|
||||||
|
- Ausgabedatei liegt im konfigurierten `movie_dir`
|
||||||
|
- Job-Status in Datenbank auf `FINISHED` gesetzt
|
||||||
|
- PushOver-Benachrichtigung (falls konfiguriert)
|
||||||
|
- WebSocket-Event: `JOB_COMPLETE`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ERROR
|
||||||
|
|
||||||
|
**Fehler aufgetreten.**
|
||||||
|
|
||||||
|
- Fehlerdetails im Job-Datensatz gespeichert
|
||||||
|
- Fehler-Logs verfügbar in der History
|
||||||
|
- **Retry**: Job kann vom Fehlerzustand neu gestartet werden
|
||||||
|
- **Abbrechen**: Pipeline zurück zu IDLE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abbrechen & Retry
|
||||||
|
|
||||||
|
### Pipeline abbrechen
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/pipeline/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
- Sendet SIGINT an aktiven Prozess
|
||||||
|
- Wartet auf graceful exit (Timeout: 10 Sekunden)
|
||||||
|
- Falls kein graceful exit: SIGKILL
|
||||||
|
- Pipeline-Zustand zurück zu IDLE
|
||||||
|
|
||||||
|
### Job wiederholen
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/pipeline/retry/:jobId
|
||||||
|
```
|
||||||
|
|
||||||
|
- Setzt Job-Status zurück auf `READY_TO_START`
|
||||||
|
- Behält Metadaten und Playlist-Auswahl
|
||||||
|
- Pipeline neu starten mit vorhandenen Daten
|
||||||
|
|
||||||
|
### Re-Encode
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/pipeline/reencode/:jobId
|
||||||
|
```
|
||||||
|
|
||||||
|
- Encodiert bestehende Raw-MKV neu
|
||||||
|
- Nützlich für geänderte Encoding-Einstellungen
|
||||||
|
- Kein neues Ripping erforderlich
|
||||||
64
docs/stylesheets/extra.css
Normal file
64
docs/stylesheets/extra.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/* Ripster custom styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--md-primary-fg-color: #6a1b9a;
|
||||||
|
--md-accent-fg-color: #ab47bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards grid layout */
|
||||||
|
.grid.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.cards > * {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--md-default-fg-color--lightest);
|
||||||
|
padding: 1.25rem;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.cards > *:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge colors */
|
||||||
|
.status-idle { color: #78909c; }
|
||||||
|
.status-analyzing { color: #fb8c00; }
|
||||||
|
.status-ripping { color: #1976d2; }
|
||||||
|
.status-encoding { color: #7b1fa2; }
|
||||||
|
.status-finished { color: #388e3c; }
|
||||||
|
.status-error { color: #d32f2f; }
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
.md-typeset pre > code {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mermaid diagrams */
|
||||||
|
.md-typeset .mermaid {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table improvements */
|
||||||
|
.md-typeset table:not([class]) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset table:not([class]) th {
|
||||||
|
background-color: var(--md-primary-fg-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admonition tweaks */
|
||||||
|
.md-typeset .admonition.tip {
|
||||||
|
border-color: #00897b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-typeset .admonition.tip > .admonition-title {
|
||||||
|
background-color: rgba(0, 137, 123, 0.1);
|
||||||
|
}
|
||||||
137
docs/tools/handbrake.md
Normal file
137
docs/tools/handbrake.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# HandBrake
|
||||||
|
|
||||||
|
HandBrake encodiert die rohen MKV-Dateien in das gewünschte Format. Ripster nutzt `HandBrakeCLI`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendeter Befehl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI \
|
||||||
|
--input "/mnt/raw/Film_t00.mkv" \
|
||||||
|
--output "/mnt/movies/Film (2010).mkv" \
|
||||||
|
--preset "H.265 MKV 1080p30" \
|
||||||
|
--audio 1,2 \
|
||||||
|
--aencoder copy:ac3,ffaac \
|
||||||
|
--subtitle 1 \
|
||||||
|
--subtitle-default 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
HandBrake verwendet **Presets** für vorkonfigurierte Encoding-Einstellungen.
|
||||||
|
|
||||||
|
### Empfohlene Presets
|
||||||
|
|
||||||
|
| Preset | Codec | Auflösung | Für |
|
||||||
|
|--------|-------|----------|-----|
|
||||||
|
| `H.265 MKV 1080p30` | HEVC/H.265 | 1080p | Beste Qualität/Größe |
|
||||||
|
| `H.265 MKV 720p30` | HEVC/H.265 | 720p | Kleinere Dateien |
|
||||||
|
| `H.264 MKV 1080p30` | AVC/H.264 | 1080p | Breiteste Kompatibilität |
|
||||||
|
| `HQ 1080p30 Surround` | HEVC/H.265 | 1080p | Hohe Qualität mit Surround |
|
||||||
|
|
||||||
|
### Alle Presets anzeigen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HandBrakeCLI --preset-list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio-Encoding
|
||||||
|
|
||||||
|
### Copy-kompatible Codecs
|
||||||
|
|
||||||
|
HandBrake kann folgende Codecs direkt kopieren (kein Qualitätsverlust):
|
||||||
|
|
||||||
|
| Codec | `--aencoder` Wert |
|
||||||
|
|-------|-----------------|
|
||||||
|
| AC-3 | `copy:ac3` |
|
||||||
|
| AAC | `copy:aac` |
|
||||||
|
| MP3 | `copy:mp3` |
|
||||||
|
| TrueHD | `copy:truehd` |
|
||||||
|
| E-AC-3 | `copy:eac3` |
|
||||||
|
|
||||||
|
### Transcoding
|
||||||
|
|
||||||
|
Codecs die nicht kopiert werden können, werden zu AAC transcodiert:
|
||||||
|
|
||||||
|
| Original | Transcodiert zu |
|
||||||
|
|---------|----------------|
|
||||||
|
| DTS | AAC (`ffaac`) |
|
||||||
|
| DTS-HD | AAC (`ffaac`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extra-Argumente
|
||||||
|
|
||||||
|
Über die Einstellung `handbrake_extra_args` können beliebige HandBrake-Argumente hinzugefügt werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Cropping deaktivieren
|
||||||
|
--crop 0:0:0:0
|
||||||
|
|
||||||
|
# Loose Anamorphic
|
||||||
|
--loose-anamorphic
|
||||||
|
|
||||||
|
# Bestimmte Qualität setzen
|
||||||
|
--quality 20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fortschritts-Parsing
|
||||||
|
|
||||||
|
Ripster parst die HandBrake-Ausgabe auf stderr für die Fortschrittsanzeige:
|
||||||
|
|
||||||
|
```
|
||||||
|
Encoding: task 1 of 1, 73.50 % (45.23 fps, avg 44.12 fps, ETA 00h12m34s)
|
||||||
|
```
|
||||||
|
|
||||||
|
`progressParsers.js` extrahiert:
|
||||||
|
- Prozentzahl
|
||||||
|
- Aktuelle FPS
|
||||||
|
- ETA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration in Ripster
|
||||||
|
|
||||||
|
| Einstellung | Beschreibung |
|
||||||
|
|------------|-------------|
|
||||||
|
| `handbrake_command` | Pfad/Befehl für `HandBrakeCLI` |
|
||||||
|
| `handbrake_preset` | Preset-Name |
|
||||||
|
| `handbrake_extra_args` | Zusätzliche CLI-Argumente |
|
||||||
|
| `output_extension` | Dateiendung der Ausgabe |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### HandBrake findet Preset nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preset-Liste anzeigen
|
||||||
|
HandBrakeCLI --preset-list 2>&1 | grep -i "h.265"
|
||||||
|
```
|
||||||
|
|
||||||
|
Preset-Namen sind case-sensitive!
|
||||||
|
|
||||||
|
### Encoding sehr langsam
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CPU-Encoding-Preset anpassen (schneller = schlechtere Qualität)
|
||||||
|
handbrake_extra_args = --encoder-preset fast
|
||||||
|
```
|
||||||
|
|
||||||
|
Verfügbare Presets: `ultrafast`, `superfast`, `veryfast`, `faster`, `fast`, `medium`, `slow`, `slower`, `veryslow`
|
||||||
|
|
||||||
|
### GPU-Encoding nutzen (NVIDIA)
|
||||||
|
|
||||||
|
```
|
||||||
|
handbrake_preset = H.265 NVENC 1080p
|
||||||
|
```
|
||||||
|
|
||||||
|
Erfordert HandBrake-Build mit NVENC-Unterstützung und NVIDIA-GPU.
|
||||||
31
docs/tools/index.md
Normal file
31
docs/tools/index.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Externe Tools
|
||||||
|
|
||||||
|
Ripster ist ein **Orchestrator** – die eigentliche Arbeit erledigen diese bewährten Open-Source-Tools:
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-disc: **MakeMKV**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Disc-Analyse und Ripping. Erstellt MKV-Dateien oder vollständige Backups.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: MakeMKV](makemkv.md)
|
||||||
|
|
||||||
|
- :material-film: **HandBrake**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Video-Encoding mit umfangreichen Preset-Optionen.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: HandBrake](handbrake.md)
|
||||||
|
|
||||||
|
- :material-information: **MediaInfo**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Analyse von Track-Informationen in Mediendateien.
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: MediaInfo](mediainfo.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
118
docs/tools/makemkv.md
Normal file
118
docs/tools/makemkv.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# MakeMKV
|
||||||
|
|
||||||
|
MakeMKV analysiert und rippt DVDs und Blu-rays. Ripster nutzt `makemkvcon` (die CLI-Version).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendete Befehle
|
||||||
|
|
||||||
|
### Disc-Analyse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon -r --cache=1 info disc:0
|
||||||
|
```
|
||||||
|
|
||||||
|
Gibt alle Titel und Playlists der eingelegten Disc aus. Ripster parst diese Ausgabe um die verfügbaren Tracks und Playlists zu bestimmen.
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `-r` – Maschinen-lesbares Ausgabeformat
|
||||||
|
- `--cache=1` – Minimaler Disc-Cache
|
||||||
|
- `info disc:0` – Informationsabfrage für erstes Laufwerk
|
||||||
|
|
||||||
|
### MKV-Modus (Standard)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon mkv disc:0 all /path/to/raw/ \
|
||||||
|
--minlength=900 \
|
||||||
|
-r
|
||||||
|
```
|
||||||
|
|
||||||
|
Erstellt MKV-Dateien aus allen Titeln, die länger als 15 Minuten sind.
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `mkv` – MKV-Ausgabemodus
|
||||||
|
- `disc:0` – Erstes Disc-Laufwerk
|
||||||
|
- `all` – Alle passenden Titel (nicht nur einen bestimmten)
|
||||||
|
- `--minlength=900` – Mindestlänge in Sekunden (entspricht 15 Minuten)
|
||||||
|
|
||||||
|
### Backup-Modus
|
||||||
|
|
||||||
|
```bash
|
||||||
|
makemkvcon backup disc:0 /path/to/raw/backup/ \
|
||||||
|
--decrypt \
|
||||||
|
-r
|
||||||
|
```
|
||||||
|
|
||||||
|
Erstellt ein vollständiges Disc-Backup mit Menüs.
|
||||||
|
|
||||||
|
**Parameter:**
|
||||||
|
- `backup` – Backup-Modus
|
||||||
|
- `--decrypt` – Verschlüsselung entfernen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgabeformat
|
||||||
|
|
||||||
|
MakeMKV gibt Fortschritt und Status in einem strukturierten Format aus:
|
||||||
|
|
||||||
|
```
|
||||||
|
PRGV:current,total,max → Fortschrittsbalken-Werte
|
||||||
|
PRGT:code,id,"Beschreibung" → Aktueller Task
|
||||||
|
PRGC:code,id,"Beschreibung" → Aktueller Sub-Task
|
||||||
|
MSG:code,flags,count,"Text" → Nachricht
|
||||||
|
```
|
||||||
|
|
||||||
|
Ripster's `progressParsers.js` parst diese Ausgabe für die Live-Fortschrittsanzeige.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MakeMKV-Lizenz
|
||||||
|
|
||||||
|
MakeMKV ist **Beta-Software** und kostenlos für den persönlichen Gebrauch während der Beta-Phase. Eine Beta-Lizenz ist regelmäßig im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=1053) verfügbar.
|
||||||
|
|
||||||
|
Ohne gültige Lizenz können Blu-rays nicht entschlüsselt werden.
|
||||||
|
|
||||||
|
### Lizenz eintragen
|
||||||
|
|
||||||
|
Die Lizenz wird in den MakeMKV-Einstellungen eingetragen (GUI) oder direkt in:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.MakeMKV/settings.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
app_Key = "XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration in Ripster
|
||||||
|
|
||||||
|
| Einstellung | Beschreibung |
|
||||||
|
|------------|-------------|
|
||||||
|
| `makemkv_command` | Pfad/Befehl für `makemkvcon` |
|
||||||
|
| `makemkv_min_length_minutes` | Mindest-Titellänge (Standard: 15 Min) |
|
||||||
|
| `makemkv_backup_mode` | Backup-Modus statt MKV |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### MakeMKV erkennt Disc nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Laufwerk-Berechtigungen prüfen
|
||||||
|
ls -la /dev/sr0
|
||||||
|
sudo chmod a+rw /dev/sr0
|
||||||
|
|
||||||
|
# Oder Benutzer zur Gruppe cdrom hinzufügen
|
||||||
|
sudo usermod -a -G cdrom $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Langer Analyseprozess
|
||||||
|
|
||||||
|
Blu-ray-Analyse kann bei Discs mit vielen Playlists 5+ Minuten dauern. Dies ist normal.
|
||||||
|
|
||||||
|
### Fehlermeldung: "LibMMBD"
|
||||||
|
|
||||||
|
LibMMBD ist MakeMKVs interne Verschlüsselungsbibliothek. Bei Fehlern die MakeMKV-Version aktualisieren.
|
||||||
108
docs/tools/mediainfo.md
Normal file
108
docs/tools/mediainfo.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# MediaInfo
|
||||||
|
|
||||||
|
MediaInfo analysiert die Track-Struktur von Mediendateien. Ripster nutzt es nach dem Ripping um Audio- und Untertitelspuren zu identifizieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwendeter Befehl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mediainfo --Output=JSON /path/to/raw/film.mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
Gibt vollständige Track-Informationen als JSON zurück.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausgabe-Struktur
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"media": {
|
||||||
|
"track": [
|
||||||
|
{
|
||||||
|
"@type": "General",
|
||||||
|
"Duration": "8885.042",
|
||||||
|
"Format": "Matroska"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Video",
|
||||||
|
"Format": "HEVC",
|
||||||
|
"Width": "1920",
|
||||||
|
"Height": "1080",
|
||||||
|
"FrameRate": "23.976"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Audio",
|
||||||
|
"StreamOrder": "1",
|
||||||
|
"Format": "TrueHD",
|
||||||
|
"Channels": "8",
|
||||||
|
"Language": "en"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Audio",
|
||||||
|
"StreamOrder": "2",
|
||||||
|
"Format": "AC-3",
|
||||||
|
"Channels": "6",
|
||||||
|
"Language": "de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Text",
|
||||||
|
"StreamOrder": "1",
|
||||||
|
"Format": "UTF-8",
|
||||||
|
"Language": "de"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verarbeitung in Ripster
|
||||||
|
|
||||||
|
`encodePlan.js` verarbeitet die MediaInfo-Ausgabe:
|
||||||
|
|
||||||
|
1. **Track-Extraktion**: Alle Audio- und Untertitel-Tracks werden extrahiert
|
||||||
|
2. **Sprach-Normalisierung**: Sprachcodes werden auf ISO 639-3 normalisiert
|
||||||
|
3. **Codec-Klassifizierung**: Bestimmt ob Codec kopiert oder transcodiert werden kann
|
||||||
|
4. **Track-Labels**: Benutzerfreundliche Bezeichnungen (z.B. "Deutsch (AC-3, 5.1)")
|
||||||
|
|
||||||
|
### Track-Label-Format
|
||||||
|
|
||||||
|
```
|
||||||
|
{Sprache} ({Format}, {Kanäle})
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
- `Deutsch (AC-3, 5.1)`
|
||||||
|
- `English (TrueHD, 7.1)`
|
||||||
|
- `Français (AC-3, 2.0)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration in Ripster
|
||||||
|
|
||||||
|
| Einstellung | Beschreibung |
|
||||||
|
|------------|-------------|
|
||||||
|
| `mediainfo_command` | Pfad/Befehl für `mediainfo` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### MediaInfo gibt kein JSON aus
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Version prüfen
|
||||||
|
mediainfo --Version
|
||||||
|
|
||||||
|
# JSON-Ausgabe testen
|
||||||
|
mediainfo --Output=JSON /path/to/test.mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
MediaInfo >= 17.10 wird empfohlen.
|
||||||
|
|
||||||
|
### Sprache als "und" angezeigt
|
||||||
|
|
||||||
|
`und` steht für "undetermined" – die Sprache ist in der MKV-Datei nicht getaggt. Dies ist bei manchen Rips normal. Der Track wird trotzdem angezeigt und kann manuell ausgewählt werden.
|
||||||
5
frontend/.env.example
Normal file
5
frontend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Optional: komplett explizite API-Basis (sonst /api via Vite-Proxy)
|
||||||
|
# VITE_API_BASE=http://10.10.10.24:3001/api
|
||||||
|
|
||||||
|
# Optional: expliziter WS-Endpunkt (sonst ws(s)://<host>/ws)
|
||||||
|
# VITE_WS_URL=ws://10.10.10.24:3001/ws
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ripster</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1713
frontend/package-lock.json
generated
Normal file
1713
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "ripster-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primereact": "^10.9.2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^5.4.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
98
frontend/src/App.jsx
Normal file
98
frontend/src/App.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { api } from './api/client';
|
||||||
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import DatabasePage from './pages/DatabasePage';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
|
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const refreshPipeline = async () => {
|
||||||
|
const response = await api.getPipelineState();
|
||||||
|
setPipeline(response.pipeline);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPipeline().catch(() => null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
onMessage: (message) => {
|
||||||
|
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||||
|
setPipeline(message.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
|
setPipeline((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...message.payload
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISC_DETECTED') {
|
||||||
|
setLastDiscEvent(message.payload?.device || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISC_REMOVED') {
|
||||||
|
setLastDiscEvent(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ label: 'Dashboard', path: '/' },
|
||||||
|
{ label: 'Settings', path: '/settings' },
|
||||||
|
{ label: 'Historie', path: '/history' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand-block">
|
||||||
|
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||||
|
<div className="brand-copy">
|
||||||
|
<h1>Ripster</h1>
|
||||||
|
<p>Disc Ripping Control Center</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-buttons">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.path}
|
||||||
|
label={item.label}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
className={location.pathname === item.path ? 'nav-btn nav-btn-active' : 'nav-btn'}
|
||||||
|
outlined={location.pathname !== item.path}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<DashboardPage
|
||||||
|
pipeline={pipeline}
|
||||||
|
lastDiscEvent={lastDiscEvent}
|
||||||
|
refreshPipeline={refreshPipeline}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/history" element={<DatabasePage />} />
|
||||||
|
<Route path="/database" element={<DatabasePage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
175
frontend/src/api/client.js
Normal file
175
frontend/src/api/client.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {})
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorPayload = null;
|
||||||
|
let message = `HTTP ${response.status}`;
|
||||||
|
try {
|
||||||
|
errorPayload = await response.json();
|
||||||
|
message = errorPayload?.error?.message || message;
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = response.status;
|
||||||
|
error.details = errorPayload?.error?.details || null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getSettings() {
|
||||||
|
return request('/settings');
|
||||||
|
},
|
||||||
|
updateSetting(key, value) {
|
||||||
|
return request(`/settings/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ value })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateSettingsBulk(settings) {
|
||||||
|
return request('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ settings })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
testPushover(payload = {}) {
|
||||||
|
return request('/settings/pushover/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getPipelineState() {
|
||||||
|
return request('/pipeline/state');
|
||||||
|
},
|
||||||
|
analyzeDisc() {
|
||||||
|
return request('/pipeline/analyze', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rescanDisc() {
|
||||||
|
return request('/pipeline/rescan-disc', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
searchOmdb(q) {
|
||||||
|
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||||
|
},
|
||||||
|
selectMetadata(payload) {
|
||||||
|
return request('/pipeline/select-metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
startJob(jobId) {
|
||||||
|
return request(`/pipeline/start/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
confirmEncodeReview(jobId, payload = {}) {
|
||||||
|
return request(`/pipeline/confirm-encode/${jobId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancelPipeline() {
|
||||||
|
return request('/pipeline/cancel', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
retryJob(jobId) {
|
||||||
|
return request(`/pipeline/retry/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resumeReadyJob(jobId) {
|
||||||
|
return request(`/pipeline/resume-ready/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reencodeJob(jobId) {
|
||||||
|
return request(`/pipeline/reencode/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restartEncodeWithLastSettings(jobId) {
|
||||||
|
return request(`/pipeline/restart-encode/${jobId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getJobs(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (params.search) query.set('search', params.search);
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
return request(`/history${suffix}`);
|
||||||
|
},
|
||||||
|
getDatabaseRows(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (params.search) query.set('search', params.search);
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
return request(`/history/database${suffix}`);
|
||||||
|
},
|
||||||
|
getOrphanRawFolders() {
|
||||||
|
return request('/history/orphan-raw');
|
||||||
|
},
|
||||||
|
importOrphanRawFolder(rawPath) {
|
||||||
|
return request('/history/orphan-raw/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rawPath })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
assignJobOmdb(jobId, payload = {}) {
|
||||||
|
return request(`/history/${jobId}/omdb/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteJobFiles(jobId, target = 'both') {
|
||||||
|
return request(`/history/${jobId}/delete-files`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ target })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteJobEntry(jobId, target = 'none') {
|
||||||
|
return request(`/history/${jobId}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ target })
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getJob(jobId, options = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options.includeLiveLog) {
|
||||||
|
query.set('includeLiveLog', '1');
|
||||||
|
}
|
||||||
|
if (options.includeLogs) {
|
||||||
|
query.set('includeLogs', '1');
|
||||||
|
}
|
||||||
|
if (options.includeAllLogs) {
|
||||||
|
query.set('includeAllLogs', '1');
|
||||||
|
}
|
||||||
|
if (Number.isFinite(Number(options.logTailLines)) && Number(options.logTailLines) > 0) {
|
||||||
|
query.set('logTailLines', String(Math.trunc(Number(options.logTailLines))));
|
||||||
|
}
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
|
return request(`/history/${jobId}${suffix}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { API_BASE };
|
||||||
11
frontend/src/assets/media-bluray.svg
Normal file
11
frontend/src/assets/media-bluray.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Blu-ray">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="brg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#2f9bff"/>
|
||||||
|
<stop offset="100%" stop-color="#0a3f86"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="30" fill="url(#brg)"/>
|
||||||
|
<circle cx="32" cy="32" r="24" fill="none" stroke="#9cd4ff" stroke-width="2.5" opacity="0.7"/>
|
||||||
|
<text x="32" y="38" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="18" font-weight="700" fill="#ffffff">BR</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 588 B |
13
frontend/src/assets/media-disc.svg
Normal file
13
frontend/src/assets/media-disc.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Disc">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="cdg" cx="40%" cy="35%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff"/>
|
||||||
|
<stop offset="70%" stop-color="#d8dde5"/>
|
||||||
|
<stop offset="100%" stop-color="#8f98a6"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="30" fill="url(#cdg)"/>
|
||||||
|
<circle cx="32" cy="32" r="9" fill="#f7f9fc" stroke="#9ca6b5" stroke-width="2"/>
|
||||||
|
<path d="M15 25 A20 20 0 0 1 48 18" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" opacity="0.85"/>
|
||||||
|
<text x="32" y="54" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" fill="#3f4a5d">CD</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 741 B |
39
frontend/src/components/DiscDetectedDialog.jsx
Normal file
39
frontend/src/components/DiscDetectedDialog.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
|
||||||
|
export default function DiscDetectedDialog({ visible, device, onHide, onAnalyze, busy }) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
header="Neue Disk erkannt"
|
||||||
|
visible={visible}
|
||||||
|
onHide={onHide}
|
||||||
|
style={{ width: '32rem', maxWidth: '96vw' }}
|
||||||
|
className="disc-detected-dialog"
|
||||||
|
breakpoints={{ '768px': '96vw', '560px': '98vw' }}
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Laufwerk: <strong>{device?.path || 'unbekannt'}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Disk-Label: <strong>{device?.discLabel || 'n/a'}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Laufwerks-Label: <strong>{device?.label || 'n/a'}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Modell: <strong>{device?.model || 'n/a'}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Button label="Schließen" severity="secondary" onClick={onHide} text />
|
||||||
|
<Button
|
||||||
|
label="Analyse starten"
|
||||||
|
icon="pi pi-search"
|
||||||
|
onClick={onAnalyze}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
frontend/src/components/DynamicSettingsForm.jsx
Normal file
224
frontend/src/components/DynamicSettingsForm.jsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { TabView, TabPanel } from 'primereact/tabview';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
import { InputNumber } from 'primereact/inputnumber';
|
||||||
|
import { InputSwitch } from 'primereact/inputswitch';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettingKey(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolSections(settings) {
|
||||||
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
|
const definitions = [
|
||||||
|
{
|
||||||
|
id: 'makemkv',
|
||||||
|
title: 'MakeMKV',
|
||||||
|
description: 'Disc-Analyse und Rip-Einstellungen.',
|
||||||
|
match: (key) => key.startsWith('makemkv_')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mediainfo',
|
||||||
|
title: 'MediaInfo',
|
||||||
|
description: 'Track-Analyse und zusätzliche mediainfo Parameter.',
|
||||||
|
match: (key) => key.startsWith('mediainfo_')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'handbrake',
|
||||||
|
title: 'HandBrake',
|
||||||
|
description: 'Preset, Encoding-CLI und HandBrake-Optionen.',
|
||||||
|
match: (key) => key.startsWith('handbrake_')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'output',
|
||||||
|
title: 'Output',
|
||||||
|
description: 'Container-Format und Dateinamen-Template.',
|
||||||
|
match: (key) => key === 'output_extension' || key === 'filename_template'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const buckets = definitions.map((item) => ({
|
||||||
|
...item,
|
||||||
|
settings: []
|
||||||
|
}));
|
||||||
|
const fallbackBucket = {
|
||||||
|
id: 'other',
|
||||||
|
title: 'Weitere Tool-Settings',
|
||||||
|
description: null,
|
||||||
|
settings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const setting of list) {
|
||||||
|
const key = normalizeSettingKey(setting?.key);
|
||||||
|
let assigned = false;
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
if (bucket.match(key)) {
|
||||||
|
bucket.settings.push(setting);
|
||||||
|
assigned = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!assigned) {
|
||||||
|
fallbackBucket.settings.push(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = buckets.filter((item) => item.settings.length > 0);
|
||||||
|
if (fallbackBucket.settings.length > 0) {
|
||||||
|
sections.push(fallbackBucket);
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSectionsForCategory(categoryName, settings) {
|
||||||
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
|
const normalizedCategory = normalizeText(categoryName);
|
||||||
|
if (normalizedCategory === 'tools') {
|
||||||
|
const sections = buildToolSections(list);
|
||||||
|
if (sections.length > 0) {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
settings: list
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicSettingsForm({
|
||||||
|
categories,
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
dirtyKeys,
|
||||||
|
onChange
|
||||||
|
}) {
|
||||||
|
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (safeCategories.length === 0) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeIndex < 0 || activeIndex >= safeCategories.length) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
}, [activeIndex, safeCategories.length]);
|
||||||
|
|
||||||
|
if (safeCategories.length === 0) {
|
||||||
|
return <p>Keine Kategorien vorhanden.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabView
|
||||||
|
className="settings-tabview"
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onTabChange={(event) => setActiveIndex(Number(event.index || 0))}
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
{safeCategories.map((category, categoryIndex) => (
|
||||||
|
<TabPanel
|
||||||
|
key={`${category.category || 'category'}-${categoryIndex}`}
|
||||||
|
header={category.category || `Kategorie ${categoryIndex + 1}`}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||||
|
const grouped = sections.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-sections">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<section
|
||||||
|
key={`${category?.category || 'category'}-${section.id}`}
|
||||||
|
className={`settings-section${grouped ? ' grouped' : ''}`}
|
||||||
|
>
|
||||||
|
{section.title ? (
|
||||||
|
<div className="settings-section-head">
|
||||||
|
<h4>{section.title}</h4>
|
||||||
|
{section.description ? <small>{section.description}</small> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="settings-grid">
|
||||||
|
{(section.settings || []).map((setting) => {
|
||||||
|
const value = values?.[setting.key];
|
||||||
|
const error = errors?.[setting.key] || null;
|
||||||
|
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={setting.key} className="setting-row">
|
||||||
|
<label htmlFor={setting.key}>
|
||||||
|
{setting.label}
|
||||||
|
{setting.required && <span className="required">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{setting.type === 'string' || setting.type === 'path' ? (
|
||||||
|
<InputText
|
||||||
|
id={setting.key}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(event) => onChange?.(setting.key, event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{setting.type === 'number' ? (
|
||||||
|
<InputNumber
|
||||||
|
id={setting.key}
|
||||||
|
value={value ?? 0}
|
||||||
|
onValueChange={(event) => onChange?.(setting.key, event.value)}
|
||||||
|
mode="decimal"
|
||||||
|
useGrouping={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{setting.type === 'boolean' ? (
|
||||||
|
<InputSwitch
|
||||||
|
id={setting.key}
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{setting.type === 'select' ? (
|
||||||
|
<Dropdown
|
||||||
|
id={setting.key}
|
||||||
|
value={value}
|
||||||
|
options={setting.options}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => onChange?.(setting.key, event.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<small>{setting.description || ''}</small>
|
||||||
|
{error ? (
|
||||||
|
<small className="error-text">{error}</small>
|
||||||
|
) : (
|
||||||
|
<Tag
|
||||||
|
value={dirty ? 'Ungespeichert' : 'Gespeichert'}
|
||||||
|
severity={dirty ? 'warning' : 'success'}
|
||||||
|
className="saved-tag"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TabPanel>
|
||||||
|
))}
|
||||||
|
</TabView>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
frontend/src/components/JobDetailDialog.jsx
Normal file
230
frontend/src/components/JobDetailDialog.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||||
|
|
||||||
|
function JsonView({ title, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4>{title}</h4>
|
||||||
|
<pre className="json-box">{value ? JSON.stringify(value, null, 2) : '-'}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDetailDialog({
|
||||||
|
visible,
|
||||||
|
job,
|
||||||
|
onHide,
|
||||||
|
detailLoading = false,
|
||||||
|
onLoadLog,
|
||||||
|
logLoadingMode = null,
|
||||||
|
onAssignOmdb,
|
||||||
|
onReencode,
|
||||||
|
onDeleteFiles,
|
||||||
|
onDeleteEntry,
|
||||||
|
omdbAssignBusy = false,
|
||||||
|
actionBusy = false,
|
||||||
|
reencodeBusy = false,
|
||||||
|
deleteEntryBusy = false
|
||||||
|
}) {
|
||||||
|
const mkDone = !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||||
|
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||||
|
const showFinalLog = !running;
|
||||||
|
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && mkDone && !running);
|
||||||
|
const canDeleteEntry = !running;
|
||||||
|
const logCount = Number(job?.log_count || 0);
|
||||||
|
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||||
|
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||||
|
const logTruncated = Boolean(logMeta?.truncated);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
header={`Job #${job?.id || ''}`}
|
||||||
|
visible={visible}
|
||||||
|
onHide={onHide}
|
||||||
|
style={{ width: '70rem', maxWidth: '96vw' }}
|
||||||
|
className="job-detail-dialog"
|
||||||
|
breakpoints={{ '1440px': '94vw', '1024px': '96vw', '640px': '98vw' }}
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
{!job ? null : (
|
||||||
|
<>
|
||||||
|
{detailLoading ? <p>Details werden geladen ...</p> : null}
|
||||||
|
|
||||||
|
<div className="job-head-row">
|
||||||
|
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||||
|
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
|
||||||
|
) : (
|
||||||
|
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="job-meta-grid">
|
||||||
|
<div>
|
||||||
|
<strong>Titel:</strong> {job.title || job.detected_title || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Jahr:</strong> {job.year || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>IMDb:</strong> {job.imdb_id || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>OMDb Match:</strong>{' '}
|
||||||
|
<Tag value={job.selected_from_omdb ? 'Ja' : 'Nein'} severity={job.selected_from_omdb ? 'success' : 'secondary'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Status:</strong> <Tag value={job.status} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Start:</strong> {job.start_time || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Ende:</strong> {job.end_time || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>RAW Pfad:</strong> {job.raw_path || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Output:</strong> {job.output_path || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Mediainfo bestätigt:</strong> {job.encode_review_confirmed ? 'ja' : 'nein'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>RAW vorhanden:</strong> {job.rawStatus?.exists ? 'ja' : 'nein'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>RAW leer:</strong> {job.rawStatus?.isEmpty === null ? '-' : job.rawStatus?.isEmpty ? 'ja' : 'nein'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Movie Datei vorhanden:</strong> {job.outputStatus?.exists ? 'ja' : 'nein'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Movie-Dir leer:</strong> {job.movieDirStatus?.isEmpty === null ? '-' : job.movieDirStatus?.isEmpty ? 'ja' : 'nein'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Fehler:</strong> {job.error_message || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="job-json-grid">
|
||||||
|
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||||
|
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||||
|
<JsonView title="HandBrake Info" value={job.handbrakeInfo} />
|
||||||
|
<JsonView title="Mediainfo Info" value={job.mediainfoInfo} />
|
||||||
|
<JsonView title="Encode Plan" value={job.encodePlan} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.encodePlan ? (
|
||||||
|
<>
|
||||||
|
<h4>Mediainfo-Prüfung (Auswertung)</h4>
|
||||||
|
<MediaInfoReviewPanel review={job.encodePlan} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h4>Aktionen</h4>
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="OMDb neu zuordnen"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onAssignOmdb?.(job)}
|
||||||
|
loading={omdbAssignBusy}
|
||||||
|
disabled={running}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="RAW neu encodieren"
|
||||||
|
icon="pi pi-cog"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onReencode?.(job)}
|
||||||
|
loading={reencodeBusy}
|
||||||
|
disabled={!canReencode}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="RAW löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteFiles?.(job, 'raw')}
|
||||||
|
loading={actionBusy}
|
||||||
|
disabled={!job.rawStatus?.exists}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Movie löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteFiles?.(job, 'movie')}
|
||||||
|
loading={actionBusy}
|
||||||
|
disabled={!job.outputStatus?.exists}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Beides löschen"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteFiles?.(job, 'both')}
|
||||||
|
loading={actionBusy}
|
||||||
|
disabled={!job.rawStatus?.exists && !job.outputStatus?.exists}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Historieneintrag löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onDeleteEntry?.(job)}
|
||||||
|
loading={deleteEntryBusy}
|
||||||
|
disabled={!canDeleteEntry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Log</h4>
|
||||||
|
{showFinalLog ? (
|
||||||
|
<>
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label={logLoaded ? 'Tail neu laden (800)' : 'Tail laden (800)'}
|
||||||
|
icon="pi pi-download"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onLoadLog?.(job, 'tail')}
|
||||||
|
loading={logLoadingMode === 'tail'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Vollständiges Log laden"
|
||||||
|
icon="pi pi-list"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
onClick={() => onLoadLog?.(job, 'all')}
|
||||||
|
loading={logLoadingMode === 'all'}
|
||||||
|
disabled={logCount <= 0}
|
||||||
|
/>
|
||||||
|
<small>{`Log-Zeilen: ${logCount}`}</small>
|
||||||
|
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
||||||
|
</div>
|
||||||
|
{logLoaded ? (
|
||||||
|
<pre className="log-box">{job.log || ''}</pre>
|
||||||
|
) : (
|
||||||
|
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Live-Log wird nur im Dashboard während laufender Analyse/Rip/Encode angezeigt.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
827
frontend/src/components/MediaInfoReviewPanel.jsx
Normal file
827
frontend/src/components/MediaInfoReviewPanel.jsx
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
function formatDuration(minutes) {
|
||||||
|
const value = Number(minutes || 0);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return `${value.toFixed(2)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const value = Number(bytes || 0);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = value;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(2)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackIdList(values) {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of list) {
|
||||||
|
const normalized = normalizeTrackId(value);
|
||||||
|
if (normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(normalized);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitArgs(input) {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [];
|
||||||
|
let current = '';
|
||||||
|
let quote = null;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (const ch of input) {
|
||||||
|
if (escaping) {
|
||||||
|
current += ch;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\\') {
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
args.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
args.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIO_SELECTION_KEYS_WITH_VALUE = new Set(['-a', '--audio', '--audio-lang-list']);
|
||||||
|
const AUDIO_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-audio', '--first-audio']);
|
||||||
|
const SUBTITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-s', '--subtitle', '--subtitle-lang-list']);
|
||||||
|
const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-subtitle']);
|
||||||
|
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
|
|
||||||
|
function removeSelectionArgs(extraArgs) {
|
||||||
|
const args = Array.isArray(extraArgs) ? extraArgs : [];
|
||||||
|
const filtered = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const token = String(args[i] || '');
|
||||||
|
const key = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
|
||||||
|
|
||||||
|
const isAudioWithValue = AUDIO_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||||
|
const isAudioFlagOnly = AUDIO_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||||
|
const isSubtitleWithValue = SUBTITLE_SELECTION_KEYS_WITH_VALUE.has(key)
|
||||||
|
|| SUBTITLE_FLAG_KEYS_WITH_VALUE.has(key);
|
||||||
|
const isSubtitleFlagOnly = SUBTITLE_SELECTION_KEYS_FLAG_ONLY.has(key);
|
||||||
|
const isTitleWithValue = TITLE_SELECTION_KEYS_WITH_VALUE.has(key);
|
||||||
|
const skip = isAudioWithValue || isAudioFlagOnly || isSubtitleWithValue || isSubtitleFlagOnly || isTitleWithValue;
|
||||||
|
|
||||||
|
if (!skip) {
|
||||||
|
filtered.push(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isAudioWithValue || isSubtitleWithValue || isTitleWithValue) && !token.includes('=')) {
|
||||||
|
const nextToken = String(args[i + 1] || '');
|
||||||
|
if (nextToken && !nextToken.startsWith('-')) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellQuote(value) {
|
||||||
|
const raw = String(value ?? '');
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return "''";
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return `'${raw.replace(/'/g, `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHandBrakeCommandPreview({
|
||||||
|
review,
|
||||||
|
title,
|
||||||
|
selectedAudioTrackIds,
|
||||||
|
selectedSubtitleTrackIds,
|
||||||
|
commandOutputPath = null
|
||||||
|
}) {
|
||||||
|
const inputPath = String(title?.filePath || review?.encodeInputPath || '').trim();
|
||||||
|
const handBrakeCmd = String(
|
||||||
|
review?.selectors?.handbrakeCommand
|
||||||
|
|| review?.selectors?.handBrakeCommand
|
||||||
|
|| 'HandBrakeCLI'
|
||||||
|
).trim() || 'HandBrakeCLI';
|
||||||
|
const preset = String(review?.selectors?.preset || '').trim();
|
||||||
|
const extraArgs = String(review?.selectors?.extraArgs || '').trim();
|
||||||
|
const rawMappedTitleId = Number(review?.handBrakeTitleId);
|
||||||
|
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
|
||||||
|
? Math.trunc(rawMappedTitleId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const selectedSubtitleSet = new Set(normalizeTrackIdList(selectedSubtitleTrackIds).map((id) => String(id)));
|
||||||
|
const selectedSubtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).filter((track) => {
|
||||||
|
const id = normalizeTrackId(track?.id);
|
||||||
|
return id !== null && selectedSubtitleSet.has(String(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtitleBurnTrackId = normalizeTrackIdList(
|
||||||
|
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewBurnIn || track?.burnIn)).map((track) => track?.id)
|
||||||
|
)[0] || null;
|
||||||
|
const subtitleDefaultTrackId = normalizeTrackIdList(
|
||||||
|
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
||||||
|
)[0] || null;
|
||||||
|
const subtitleForcedTrackId = normalizeTrackIdList(
|
||||||
|
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewForced || track?.forced)).map((track) => track?.id)
|
||||||
|
)[0] || null;
|
||||||
|
const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.subtitlePreviewForcedOnly || track?.forcedOnly));
|
||||||
|
|
||||||
|
const baseArgs = [
|
||||||
|
'-i',
|
||||||
|
inputPath || '<encode-input>',
|
||||||
|
'-o',
|
||||||
|
String(commandOutputPath || '').trim() || '<encode-output>'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (mappedTitleId !== null) {
|
||||||
|
baseArgs.push('-t', String(mappedTitleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset) {
|
||||||
|
baseArgs.push('-Z', preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredExtra = removeSelectionArgs(splitArgs(extraArgs));
|
||||||
|
const overrideArgs = [
|
||||||
|
'-a',
|
||||||
|
normalizeTrackIdList(selectedAudioTrackIds).join(',') || 'none',
|
||||||
|
'-s',
|
||||||
|
normalizeTrackIdList(selectedSubtitleTrackIds).join(',') || 'none'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (subtitleBurnTrackId !== null) {
|
||||||
|
overrideArgs.push(`--subtitle-burned=${subtitleBurnTrackId}`);
|
||||||
|
}
|
||||||
|
if (subtitleDefaultTrackId !== null) {
|
||||||
|
overrideArgs.push(`--subtitle-default=${subtitleDefaultTrackId}`);
|
||||||
|
}
|
||||||
|
if (subtitleForcedTrackId !== null) {
|
||||||
|
overrideArgs.push(`--subtitle-forced=${subtitleForcedTrackId}`);
|
||||||
|
} else if (subtitleForcedOnly) {
|
||||||
|
overrideArgs.push('--subtitle-forced');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalArgs = [...baseArgs, ...filteredExtra, ...overrideArgs];
|
||||||
|
return `${handBrakeCmd} ${finalArgs.map((arg) => shellQuote(arg)).join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLang2(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return 'und';
|
||||||
|
}
|
||||||
|
const map = {
|
||||||
|
en: 'en',
|
||||||
|
eng: 'en',
|
||||||
|
de: 'de',
|
||||||
|
deu: 'de',
|
||||||
|
ger: 'de',
|
||||||
|
tr: 'tr',
|
||||||
|
tur: 'tr',
|
||||||
|
fr: 'fr',
|
||||||
|
fra: 'fr',
|
||||||
|
fre: 'fr',
|
||||||
|
es: 'es',
|
||||||
|
spa: 'es',
|
||||||
|
it: 'it',
|
||||||
|
ita: 'it'
|
||||||
|
};
|
||||||
|
if (map[raw]) {
|
||||||
|
return map[raw];
|
||||||
|
}
|
||||||
|
if (raw.length === 2) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw.length >= 3) {
|
||||||
|
return raw.slice(0, 2);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simplifyCodec(type, value, hint = null) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
const hintRaw = String(hint || '').trim();
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
const merged = `${raw} ${hintRaw}`.toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'subtitle') {
|
||||||
|
if (merged.includes('pgs')) {
|
||||||
|
return 'PGS';
|
||||||
|
}
|
||||||
|
return raw.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.includes('dts-hd ma') || merged.includes('dts hd ma')) {
|
||||||
|
return 'DTS-HD MA';
|
||||||
|
}
|
||||||
|
if (merged.includes('dts-hd hra') || merged.includes('dts hd hra')) {
|
||||||
|
return 'DTS-HD HRA';
|
||||||
|
}
|
||||||
|
if (merged.includes('dts-hd') || merged.includes('dts hd')) {
|
||||||
|
return 'DTS-HD';
|
||||||
|
}
|
||||||
|
if (merged.includes('dts') || merged.includes('dca')) {
|
||||||
|
return 'DTS';
|
||||||
|
}
|
||||||
|
if (merged.includes('truehd')) {
|
||||||
|
return 'TRUEHD';
|
||||||
|
}
|
||||||
|
if (merged.includes('e-ac-3') || merged.includes('eac3') || merged.includes('dd+')) {
|
||||||
|
return 'E-AC-3';
|
||||||
|
}
|
||||||
|
if (merged.includes('ac-3') || merged.includes('ac3') || merged.includes('dolby digital')) {
|
||||||
|
return 'AC-3';
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(raw);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
if (numeric === 262144) {
|
||||||
|
return 'DTS-HD';
|
||||||
|
}
|
||||||
|
if (numeric === 131072) {
|
||||||
|
return 'DTS';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAudioVariant(hint) {
|
||||||
|
const raw = String(hint || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const paren = raw.match(/\(([^)]+)\)/);
|
||||||
|
if (!paren) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = paren[1]
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const extras = parts.filter((item) => {
|
||||||
|
const lower = item.toLowerCase();
|
||||||
|
if (lower.includes('dts') || lower.includes('ac3') || lower.includes('e-ac3') || lower.includes('eac3')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/\d+(?:\.\d+)?\s*ch/i.test(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/\d+\s*kbps/i.test(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return extras.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelCount(rawValue) {
|
||||||
|
const raw = String(rawValue || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.includes('7.1')) {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if (raw.includes('5.1')) {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix')) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (raw.includes('mono') || raw.includes('1.0')) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(raw);
|
||||||
|
if (Number.isFinite(numeric) && numeric > 0) {
|
||||||
|
if (Math.abs(numeric - 7.1) < 0.2) {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
if (Math.abs(numeric - 5.1) < 0.2) {
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
return Math.trunc(numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = raw.match(/(\d+)\s*ch/);
|
||||||
|
if (match) {
|
||||||
|
const value = Number(match[1]);
|
||||||
|
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioChannelLabel(rawValue) {
|
||||||
|
const raw = String(rawValue || '').trim().toLowerCase();
|
||||||
|
const count = channelCount(rawValue);
|
||||||
|
|
||||||
|
if (raw.includes('7.1') || count === 8) {
|
||||||
|
return 'Surround 7.1';
|
||||||
|
}
|
||||||
|
if (raw.includes('5.1') || count === 6) {
|
||||||
|
return 'Surround 5.1';
|
||||||
|
}
|
||||||
|
if (raw.includes('stereo') || raw.includes('2.0') || raw.includes('downmix') || count === 2) {
|
||||||
|
return 'Stereo';
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return 'Mono';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_AUDIO_FALLBACK_PREVIEW = 'av_aac';
|
||||||
|
|
||||||
|
function mapTrackToCopyCodec(track) {
|
||||||
|
const raw = [
|
||||||
|
track?.codecToken,
|
||||||
|
track?.format,
|
||||||
|
track?.codecName,
|
||||||
|
track?.description,
|
||||||
|
track?.title
|
||||||
|
]
|
||||||
|
.map((value) => String(value || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw.includes('e-ac-3') || raw.includes('eac3') || raw.includes('dd+')) {
|
||||||
|
return 'eac3';
|
||||||
|
}
|
||||||
|
if (raw.includes('ac-3') || raw.includes('ac3') || raw.includes('dolby digital')) {
|
||||||
|
return 'ac3';
|
||||||
|
}
|
||||||
|
if (raw.includes('truehd')) {
|
||||||
|
return 'truehd';
|
||||||
|
}
|
||||||
|
if (raw.includes('dts-hd') || raw.includes('dtshd')) {
|
||||||
|
return 'dtshd';
|
||||||
|
}
|
||||||
|
if (raw.includes('dca') || raw.includes('dts')) {
|
||||||
|
return 'dts';
|
||||||
|
}
|
||||||
|
if (raw.includes('aac')) {
|
||||||
|
return 'aac';
|
||||||
|
}
|
||||||
|
if (raw.includes('flac')) {
|
||||||
|
return 'flac';
|
||||||
|
}
|
||||||
|
if (raw.includes('mp3') || raw.includes('mpeg audio')) {
|
||||||
|
return 'mp3';
|
||||||
|
}
|
||||||
|
if (raw.includes('opus')) {
|
||||||
|
return 'opus';
|
||||||
|
}
|
||||||
|
if (raw.includes('pcm') || raw.includes('lpcm')) {
|
||||||
|
return 'lpcm';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAudioEncoderPreviewLabel(track, encoderToken, copyMask, fallbackEncoder) {
|
||||||
|
const normalizedToken = String(encoderToken || '').trim().toLowerCase();
|
||||||
|
if (!normalizedToken || normalizedToken === 'preset-default') {
|
||||||
|
return 'Preset-Default (HandBrake)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedToken.startsWith('copy')) {
|
||||||
|
const sourceCodec = mapTrackToCopyCodec(track);
|
||||||
|
const explicitCopyCodec = normalizedToken.includes(':')
|
||||||
|
? normalizedToken.split(':').slice(1).join(':').trim().toLowerCase()
|
||||||
|
: null;
|
||||||
|
const normalizedCopyMask = Array.isArray(copyMask)
|
||||||
|
? copyMask.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let canCopy = false;
|
||||||
|
if (explicitCopyCodec) {
|
||||||
|
canCopy = Boolean(sourceCodec && sourceCodec === explicitCopyCodec);
|
||||||
|
} else if (sourceCodec && normalizedCopyMask.length > 0) {
|
||||||
|
canCopy = normalizedCopyMask.includes(sourceCodec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canCopy) {
|
||||||
|
return `Copy (${sourceCodec || track?.format || 'Quelle'})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = String(fallbackEncoder || DEFAULT_AUDIO_FALLBACK_PREVIEW).trim().toLowerCase() || DEFAULT_AUDIO_FALLBACK_PREVIEW;
|
||||||
|
return `Fallback Transcode (${fallback})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Transcode (${normalizedToken})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
|
||||||
|
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
|
||||||
|
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
|
||||||
|
let encoderPlan = [];
|
||||||
|
|
||||||
|
if (selector.encoderSource === 'args' && availableEncoders.length > 0) {
|
||||||
|
const safeIndex = Number.isFinite(selectedIndex) && selectedIndex >= 0 ? selectedIndex : 0;
|
||||||
|
encoderPlan = [availableEncoders[Math.min(safeIndex, availableEncoders.length - 1)]];
|
||||||
|
} else if (availableEncoders.length > 0) {
|
||||||
|
encoderPlan = [...availableEncoders];
|
||||||
|
} else {
|
||||||
|
encoderPlan = ['preset-default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = encoderPlan
|
||||||
|
.map((token) => resolveAudioEncoderPreviewLabel(track, token, selector.copyMask, selector.fallbackEncoder))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return labels.join(' + ') || 'Übernehmen';
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackList({
|
||||||
|
title,
|
||||||
|
tracks,
|
||||||
|
type = 'generic',
|
||||||
|
allowSelection = false,
|
||||||
|
selectedTrackIds = [],
|
||||||
|
onToggleTrack = null,
|
||||||
|
audioSelector = null
|
||||||
|
}) {
|
||||||
|
const selectedIds = normalizeTrackIdList(selectedTrackIds);
|
||||||
|
const checkedTrackOrder = (Array.isArray(tracks) ? tracks : [])
|
||||||
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
|
.filter((trackId, index) => {
|
||||||
|
if (trackId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (allowSelection) {
|
||||||
|
return selectedIds.includes(trackId);
|
||||||
|
}
|
||||||
|
const track = tracks[index];
|
||||||
|
return Boolean(track?.selectedForEncode);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4>{title}</h4>
|
||||||
|
{!tracks || tracks.length === 0 ? (
|
||||||
|
<p>Keine Einträge.</p>
|
||||||
|
) : (
|
||||||
|
<div className="media-track-list">
|
||||||
|
{tracks.map((track) => {
|
||||||
|
const trackId = normalizeTrackId(track.id);
|
||||||
|
const checked = allowSelection
|
||||||
|
? (trackId !== null && selectedIds.includes(trackId))
|
||||||
|
: Boolean(track.selectedForEncode);
|
||||||
|
const selectedIndex = trackId !== null
|
||||||
|
? checkedTrackOrder.indexOf(trackId)
|
||||||
|
: -1;
|
||||||
|
const actionInfo = type === 'audio'
|
||||||
|
? (checked
|
||||||
|
? (() => {
|
||||||
|
const base = String(track.encodePreviewSummary || track.encodeActionSummary || '').trim();
|
||||||
|
const staleUnselectedSummary = /^nicht übernommen$/i.test(base);
|
||||||
|
if (staleUnselectedSummary) {
|
||||||
|
return buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
||||||
|
}
|
||||||
|
return base || buildAudioActionPreviewSummary(track, selectedIndex, audioSelector);
|
||||||
|
})()
|
||||||
|
: 'Nicht übernommen')
|
||||||
|
: type === 'subtitle'
|
||||||
|
? (checked
|
||||||
|
? (() => {
|
||||||
|
const base = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
|
||||||
|
return /^nicht übernommen$/i.test(base) ? 'Übernehmen' : (base || 'Übernehmen');
|
||||||
|
})()
|
||||||
|
: 'Nicht übernommen')
|
||||||
|
: null;
|
||||||
|
const subtitleFlags = type === 'subtitle' && checked
|
||||||
|
? (Array.isArray(track.subtitlePreviewFlags)
|
||||||
|
? track.subtitlePreviewFlags
|
||||||
|
: (Array.isArray(track.flags) ? track.flags : []))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const displayLanguage = toLang2(track.language || track.languageLabel || 'und');
|
||||||
|
const displayHint = track.description || track.title;
|
||||||
|
const displayCodec = simplifyCodec(type, track.format, displayHint);
|
||||||
|
const displayChannelCount = channelCount(track.channels);
|
||||||
|
const displayAudioTitle = audioChannelLabel(track.channels);
|
||||||
|
const audioVariant = type === 'audio' ? extractAudioVariant(displayHint) : '';
|
||||||
|
const burned = type === 'subtitle' && checked
|
||||||
|
? Boolean(
|
||||||
|
track.subtitlePreviewBurnIn
|
||||||
|
|| track.burnIn
|
||||||
|
|| subtitleFlags.includes('burned')
|
||||||
|
|| /burned/i.test(String(track.subtitlePreviewSummary || track.subtitleActionSummary || ''))
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
let displayText = `#${track.id} | ${displayLanguage} | ${displayCodec}`;
|
||||||
|
if (type === 'audio') {
|
||||||
|
if (displayChannelCount !== null) {
|
||||||
|
displayText += ` | ${displayChannelCount}ch`;
|
||||||
|
}
|
||||||
|
if (displayAudioTitle) {
|
||||||
|
displayText += ` | ${displayAudioTitle}`;
|
||||||
|
}
|
||||||
|
if (audioVariant) {
|
||||||
|
displayText += ` | ${audioVariant}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'subtitle' && burned) {
|
||||||
|
displayText += ' | burned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${title}-${track.id}`} className="media-track-item">
|
||||||
|
<label className="readonly-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!allowSelection || typeof onToggleTrack !== 'function' || trackId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggleTrack(trackId, event.target.checked);
|
||||||
|
}}
|
||||||
|
readOnly={!allowSelection}
|
||||||
|
disabled={!allowSelection}
|
||||||
|
/>
|
||||||
|
<span>{displayText}</span>
|
||||||
|
</label>
|
||||||
|
{actionInfo ? <small className="track-action-note">Encode: {actionInfo}</small> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitleId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaInfoReviewPanel({
|
||||||
|
review,
|
||||||
|
commandOutputPath = null,
|
||||||
|
selectedEncodeTitleId = null,
|
||||||
|
allowTitleSelection = false,
|
||||||
|
onSelectEncodeTitle = null,
|
||||||
|
allowTrackSelection = false,
|
||||||
|
trackSelectionByTitle = {},
|
||||||
|
onTrackSelectionChange = null
|
||||||
|
}) {
|
||||||
|
if (!review) {
|
||||||
|
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles = review.titles || [];
|
||||||
|
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
|
||||||
|
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
|
||||||
|
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
||||||
|
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
||||||
|
const playlistRecommendation = review.playlistRecommendation || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="media-review-wrap">
|
||||||
|
<div className="media-review-meta">
|
||||||
|
<div><strong>Preset:</strong> {review.selectors?.preset || '-'}</div>
|
||||||
|
<div><strong>Extra Args:</strong> {review.selectors?.extraArgs || '(keine)'}</div>
|
||||||
|
<div><strong>Preset-Profil:</strong> {review.selectors?.presetProfileSource || '-'}</div>
|
||||||
|
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
||||||
|
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
|
||||||
|
<div><strong>Audio Auswahl:</strong> {review.selectors?.audio?.mode || '-'}</div>
|
||||||
|
<div><strong>Audio Encoder:</strong> {(review.selectors?.audio?.encoders || []).join(', ') || 'Preset-Default'}</div>
|
||||||
|
<div><strong>Audio Copy-Mask:</strong> {(review.selectors?.audio?.copyMask || []).join(', ') || '-'}</div>
|
||||||
|
<div><strong>Audio Fallback:</strong> {review.selectors?.audio?.fallbackEncoder || '-'}</div>
|
||||||
|
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
|
||||||
|
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.forcedOnly ? 'forced-only' : '-'}{review.selectors?.subtitle?.burnBehavior === 'first' ? ' + burned(first)' : ''}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{review.partial ? (
|
||||||
|
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{playlistRecommendation ? (
|
||||||
|
<div className="playlist-recommendation-box">
|
||||||
|
<small>
|
||||||
|
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
|
||||||
|
{playlistRecommendation.reviewTitleId ? ` (Titel #${playlistRecommendation.reviewTitleId})` : ''}
|
||||||
|
</small>
|
||||||
|
{playlistRecommendation.reason ? <small>{playlistRecommendation.reason}</small> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Array.isArray(review.notes) && review.notes.length > 0 ? (
|
||||||
|
<div className="media-review-notes">
|
||||||
|
{review.notes.map((note, idx) => (
|
||||||
|
<small key={`${idx}-${note}`}>{note}</small>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h4>Titel</h4>
|
||||||
|
<div className="media-title-list">
|
||||||
|
{titles.length === 0 ? (
|
||||||
|
<p>Keine Titel analysiert.</p>
|
||||||
|
) : titles.map((title) => {
|
||||||
|
const titleEligible = title?.eligibleForEncode !== false;
|
||||||
|
const titleChecked = allowTitleSelection
|
||||||
|
? currentSelectedId === normalizeTitleId(title.id)
|
||||||
|
: Boolean(title.selectedForEncode);
|
||||||
|
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||||
|
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||||
|
.filter((track) => Boolean(track?.selectedByRule))
|
||||||
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
|
.filter((id) => id !== null);
|
||||||
|
const defaultSubtitleTrackIds = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||||
|
.filter((track) => Boolean(track?.selectedByRule))
|
||||||
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
|
.filter((id) => id !== null);
|
||||||
|
const selectedAudioTrackIds = normalizeTrackIdList(
|
||||||
|
Array.isArray(titleSelectionEntry?.audioTrackIds)
|
||||||
|
? titleSelectionEntry.audioTrackIds
|
||||||
|
: defaultAudioTrackIds
|
||||||
|
);
|
||||||
|
const selectedSubtitleTrackIds = normalizeTrackIdList(
|
||||||
|
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
|
||||||
|
? titleSelectionEntry.subtitleTrackIds
|
||||||
|
: defaultSubtitleTrackIds
|
||||||
|
);
|
||||||
|
const allowTrackSelectionForTitle = Boolean(
|
||||||
|
allowTrackSelection
|
||||||
|
&& allowTitleSelection
|
||||||
|
&& titleChecked
|
||||||
|
&& titleEligible
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={title.id} className="media-title-block">
|
||||||
|
<label className="readonly-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={titleChecked}
|
||||||
|
onChange={() => {
|
||||||
|
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectEncodeTitle(normalizeTitleId(title.id));
|
||||||
|
}}
|
||||||
|
readOnly={!allowTitleSelection}
|
||||||
|
disabled={!allowTitleSelection || !titleEligible}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} | {formatBytes(title.sizeBytes)}
|
||||||
|
{title.encodeInput ? ' | Encode-Input' : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
|
||||||
|
<div className="playlist-info-box">
|
||||||
|
<small>
|
||||||
|
<strong>Playlist:</strong> {title.playlistFile || '-'}
|
||||||
|
{title.playlistRecommended ? ' | empfohlen' : ''}
|
||||||
|
</small>
|
||||||
|
{title.playlistEvaluationLabel ? (
|
||||||
|
<small><strong>Bewertung:</strong> {title.playlistEvaluationLabel}</small>
|
||||||
|
) : null}
|
||||||
|
{title.playlistSegmentCommand ? (
|
||||||
|
<small><strong>Analyse-Command:</strong> {title.playlistSegmentCommand}</small>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(title.playlistSegmentFiles) && title.playlistSegmentFiles.length > 0 ? (
|
||||||
|
<details className="playlist-segment-toggle">
|
||||||
|
<summary>Segment-Dateien anzeigen ({title.playlistSegmentFiles.length})</summary>
|
||||||
|
<pre className="playlist-segment-output">{title.playlistSegmentFiles.join('\n')}</pre>
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
<small>Segment-Ausgabe: keine m2ts-Einträge gefunden.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="media-track-grid">
|
||||||
|
<TrackList
|
||||||
|
title={`Tonspuren (Titel #${title.id})`}
|
||||||
|
tracks={title.audioTracks || []}
|
||||||
|
type="audio"
|
||||||
|
allowSelection={allowTrackSelectionForTitle}
|
||||||
|
selectedTrackIds={selectedAudioTrackIds}
|
||||||
|
audioSelector={review?.selectors?.audio || null}
|
||||||
|
onToggleTrack={(trackId, checked) => {
|
||||||
|
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTrackSelectionChange(title.id, 'audio', trackId, checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TrackList
|
||||||
|
title={`Subtitles (Titel #${title.id})`}
|
||||||
|
tracks={title.subtitleTracks || []}
|
||||||
|
type="subtitle"
|
||||||
|
allowSelection={allowTrackSelectionForTitle}
|
||||||
|
selectedTrackIds={selectedSubtitleTrackIds}
|
||||||
|
onToggleTrack={(trackId, checked) => {
|
||||||
|
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{titleChecked ? (() => {
|
||||||
|
const commandPreview = buildHandBrakeCommandPreview({
|
||||||
|
review,
|
||||||
|
title,
|
||||||
|
selectedAudioTrackIds,
|
||||||
|
selectedSubtitleTrackIds,
|
||||||
|
commandOutputPath
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="handbrake-command-preview">
|
||||||
|
<small><strong>Finaler HandBrakeCLI-Befehl (Preview):</strong></small>
|
||||||
|
<pre>{commandPreview}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
frontend/src/components/MetadataSelectionDialog.jsx
Normal file
172
frontend/src/components/MetadataSelectionDialog.jsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Dialog } from 'primereact/dialog';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { DataTable } from 'primereact/datatable';
|
||||||
|
import { Column } from 'primereact/column';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
|
||||||
|
export default function MetadataSelectionDialog({
|
||||||
|
visible,
|
||||||
|
context,
|
||||||
|
onHide,
|
||||||
|
onSubmit,
|
||||||
|
onSearch,
|
||||||
|
busy
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [manualTitle, setManualTitle] = useState('');
|
||||||
|
const [manualYear, setManualYear] = useState('');
|
||||||
|
const [manualImdb, setManualImdb] = useState('');
|
||||||
|
const [extraResults, setExtraResults] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMetadata = context?.selectedMetadata || {};
|
||||||
|
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||||
|
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
||||||
|
const defaultImdb = selectedMetadata.imdbId || '';
|
||||||
|
|
||||||
|
setSelected(null);
|
||||||
|
setQuery(defaultTitle);
|
||||||
|
setManualTitle(defaultTitle);
|
||||||
|
setManualYear(defaultYear);
|
||||||
|
setManualImdb(defaultImdb);
|
||||||
|
setExtraResults([]);
|
||||||
|
}, [visible, context]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const base = context?.omdbCandidates || [];
|
||||||
|
const all = [...base, ...extraResults];
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
all.forEach((item) => {
|
||||||
|
if (item?.imdbId) {
|
||||||
|
map.set(item.imdbId, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [context, extraResults]);
|
||||||
|
|
||||||
|
const titleWithPosterBody = (row) => (
|
||||||
|
<div className="omdb-row">
|
||||||
|
{row.poster && row.poster !== 'N/A' ? (
|
||||||
|
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
|
||||||
|
) : (
|
||||||
|
<div className="poster-thumb-lg poster-fallback">-</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div><strong>{row.title}</strong></div>
|
||||||
|
<small>{row.year} | {row.imdbId}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = await onSearch(query.trim());
|
||||||
|
setExtraResults(results || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const payload = selected
|
||||||
|
? {
|
||||||
|
jobId: context.jobId,
|
||||||
|
title: selected.title,
|
||||||
|
year: selected.year,
|
||||||
|
imdbId: selected.imdbId,
|
||||||
|
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
||||||
|
fromOmdb: true
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
jobId: context.jobId,
|
||||||
|
title: manualTitle,
|
||||||
|
year: manualYear,
|
||||||
|
imdbId: manualImdb,
|
||||||
|
poster: null,
|
||||||
|
fromOmdb: false
|
||||||
|
};
|
||||||
|
|
||||||
|
await onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
header="Metadaten auswählen"
|
||||||
|
visible={visible}
|
||||||
|
onHide={onHide}
|
||||||
|
style={{ width: '52rem', maxWidth: '95vw' }}
|
||||||
|
className="metadata-selection-dialog"
|
||||||
|
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
||||||
|
modal
|
||||||
|
>
|
||||||
|
<div className="search-row">
|
||||||
|
<InputText
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Titel suchen"
|
||||||
|
/>
|
||||||
|
<Button label="OMDb Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-scroll-wrap table-scroll-medium">
|
||||||
|
<DataTable
|
||||||
|
value={rows}
|
||||||
|
selectionMode="single"
|
||||||
|
selection={selected}
|
||||||
|
onSelectionChange={(event) => setSelected(event.value)}
|
||||||
|
dataKey="imdbId"
|
||||||
|
size="small"
|
||||||
|
scrollable
|
||||||
|
scrollHeight="22rem"
|
||||||
|
emptyMessage="Keine Treffer"
|
||||||
|
responsiveLayout="stack"
|
||||||
|
breakpoint="960px"
|
||||||
|
>
|
||||||
|
<Column header="Titel" body={titleWithPosterBody} />
|
||||||
|
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
||||||
|
<Column field="imdbId" header="IMDb" style={{ width: '10rem' }} />
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Manuelle Eingabe</h4>
|
||||||
|
<div className="metadata-grid">
|
||||||
|
<InputText
|
||||||
|
value={manualTitle}
|
||||||
|
onChange={(event) => setManualTitle(event.target.value)}
|
||||||
|
placeholder="Titel"
|
||||||
|
disabled={!!selected}
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
value={manualYear}
|
||||||
|
onChange={(event) => setManualYear(event.target.value)}
|
||||||
|
placeholder="Jahr"
|
||||||
|
disabled={!!selected}
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
value={manualImdb}
|
||||||
|
onChange={(event) => setManualImdb(event.target.value)}
|
||||||
|
placeholder="IMDb-ID"
|
||||||
|
disabled={!!selected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
|
||||||
|
<Button
|
||||||
|
label="Auswahl übernehmen"
|
||||||
|
icon="pi pi-play"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!selected && !manualTitle.trim() && !manualImdb.trim()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
598
frontend/src/components/PipelineStatusCard.jsx
Normal file
598
frontend/src/components/PipelineStatusCard.jsx
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
const severityMap = {
|
||||||
|
IDLE: 'success',
|
||||||
|
DISC_DETECTED: 'info',
|
||||||
|
ANALYZING: 'warning',
|
||||||
|
METADATA_SELECTION: 'warning',
|
||||||
|
WAITING_FOR_USER_DECISION: 'warning',
|
||||||
|
READY_TO_START: 'info',
|
||||||
|
MEDIAINFO_CHECK: 'warning',
|
||||||
|
READY_TO_ENCODE: 'info',
|
||||||
|
RIPPING: 'warning',
|
||||||
|
ENCODING: 'warning',
|
||||||
|
FINISHED: 'success',
|
||||||
|
ERROR: 'danger'
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTitleId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaylistId(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i);
|
||||||
|
return match ? String(match[1]).padStart(5, '0') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackIdList(values) {
|
||||||
|
const list = Array.isArray(values) ? values : [];
|
||||||
|
const seen = new Set();
|
||||||
|
const output = [];
|
||||||
|
for (const value of list) {
|
||||||
|
const normalized = normalizeTrackId(value);
|
||||||
|
if (normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(normalized);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultTrackSelection(review) {
|
||||||
|
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
||||||
|
const selection = {};
|
||||||
|
|
||||||
|
for (const title of titles) {
|
||||||
|
const titleId = normalizeTitleId(title?.id);
|
||||||
|
if (!titleId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selection[titleId] = {
|
||||||
|
audioTrackIds: normalizeTrackIdList(
|
||||||
|
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
|
||||||
|
.filter((track) => Boolean(track?.selectedByRule))
|
||||||
|
.map((track) => track?.id)
|
||||||
|
),
|
||||||
|
subtitleTrackIds: normalizeTrackIdList(
|
||||||
|
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
|
||||||
|
.filter((track) => Boolean(track?.selectedByRule))
|
||||||
|
.map((track) => track?.id)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTrackSelectionForTitle(review, titleId) {
|
||||||
|
const defaults = buildDefaultTrackSelection(review);
|
||||||
|
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSettingsMap(categories) {
|
||||||
|
const map = {};
|
||||||
|
const list = Array.isArray(categories) ? categories : [];
|
||||||
|
for (const category of list) {
|
||||||
|
for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) {
|
||||||
|
map[setting.key] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(input) {
|
||||||
|
return String(input || 'untitled')
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template, values) {
|
||||||
|
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
||||||
|
const value = values[key.trim()];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
|
||||||
|
const movieDir = String(settings?.movie_dir || '').trim();
|
||||||
|
if (!movieDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||||
|
const year = metadata?.year || new Date().getFullYear();
|
||||||
|
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
||||||
|
const template = settings?.filename_template || '${title} (${year})';
|
||||||
|
const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId }));
|
||||||
|
const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId }));
|
||||||
|
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
|
||||||
|
const root = movieDir.replace(/\/+$/g, '');
|
||||||
|
return `${root}/${folderName}/${baseName}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PipelineStatusCard({
|
||||||
|
pipeline,
|
||||||
|
onAnalyze,
|
||||||
|
onReanalyze,
|
||||||
|
onStart,
|
||||||
|
onRestartEncode,
|
||||||
|
onConfirmReview,
|
||||||
|
onSelectPlaylist,
|
||||||
|
onCancel,
|
||||||
|
onRetry,
|
||||||
|
busy,
|
||||||
|
liveJobLog = ''
|
||||||
|
}) {
|
||||||
|
const state = pipeline?.state || 'IDLE';
|
||||||
|
const progress = Number(pipeline?.progress || 0);
|
||||||
|
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
|
||||||
|
const retryJobId = pipeline?.context?.jobId;
|
||||||
|
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
||||||
|
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||||
|
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
||||||
|
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
||||||
|
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
|
||||||
|
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
||||||
|
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
||||||
|
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
||||||
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||||
|
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||||
|
const [settingsMap, setSettingsMap] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getSettings();
|
||||||
|
if (!cancelled) {
|
||||||
|
setSettingsMap(buildSettingsMap(response?.categories || []));
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSettingsMap({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSettings();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
||||||
|
setSelectedEncodeTitleId(fromReview);
|
||||||
|
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
||||||
|
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||||
|
if (!currentTitleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrackSelectionByTitle((prev) => {
|
||||||
|
if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = buildDefaultTrackSelection(mediaInfoReview);
|
||||||
|
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[currentTitleId]: fallback
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]);
|
||||||
|
|
||||||
|
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
|
||||||
|
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
|
||||||
|
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
|
||||||
|
const canStartReadyJob = isPreRipReview
|
||||||
|
? Boolean(retryJobId)
|
||||||
|
: Boolean(retryJobId && encodeInputPath);
|
||||||
|
const canRestartEncodeFromLastSettings = Boolean(
|
||||||
|
state === 'ERROR'
|
||||||
|
&& retryJobId
|
||||||
|
&& pipeline?.context?.canRestartEncodeFromLastSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
const waitingPlaylistRows = useMemo(() => {
|
||||||
|
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
||||||
|
? playlistAnalysis.evaluatedCandidates
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const rows = evaluated.length > 0
|
||||||
|
? evaluated
|
||||||
|
: (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []);
|
||||||
|
|
||||||
|
const normalized = rows
|
||||||
|
.map((item) => {
|
||||||
|
const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item);
|
||||||
|
if (!playlistId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const playlistFile = `${playlistId}.mpls`;
|
||||||
|
const score = Number(item?.score);
|
||||||
|
const sequenceCoherence = Number(
|
||||||
|
item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence
|
||||||
|
);
|
||||||
|
const handBrakeTitleId = Number(item?.handBrakeTitleId);
|
||||||
|
return {
|
||||||
|
playlistId,
|
||||||
|
playlistFile,
|
||||||
|
titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null,
|
||||||
|
score: Number.isFinite(score) ? score : null,
|
||||||
|
evaluationLabel: item?.evaluationLabel || null,
|
||||||
|
segmentCommand: item?.segmentCommand
|
||||||
|
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
||||||
|
segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [],
|
||||||
|
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
|
||||||
|
recommended: Boolean(item?.recommended),
|
||||||
|
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
|
||||||
|
? Math.trunc(handBrakeTitleId)
|
||||||
|
: null,
|
||||||
|
audioSummary: item?.audioSummary || null,
|
||||||
|
audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : []
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const dedup = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const row of normalized) {
|
||||||
|
if (seen.has(row.playlistId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(row.playlistId);
|
||||||
|
dedup.push(row);
|
||||||
|
}
|
||||||
|
return dedup;
|
||||||
|
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'WAITING_FOR_USER_DECISION') {
|
||||||
|
setSelectedPlaylistId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist);
|
||||||
|
if (current) {
|
||||||
|
setSelectedPlaylistId(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null;
|
||||||
|
const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId);
|
||||||
|
const fallback = waitingPlaylistRows[0]?.playlistId || null;
|
||||||
|
setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback);
|
||||||
|
}, [
|
||||||
|
state,
|
||||||
|
retryJobId,
|
||||||
|
waitingPlaylistRows,
|
||||||
|
playlistAnalysis?.recommendation?.playlistId,
|
||||||
|
pipeline?.context?.selectedPlaylist
|
||||||
|
]);
|
||||||
|
|
||||||
|
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
||||||
|
const commandOutputPath = useMemo(
|
||||||
|
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
|
||||||
|
[settingsMap, selectedMetadata, retryJobId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Pipeline Status" subTitle="Live Zustand und Fortschritt">
|
||||||
|
<div className="status-row">
|
||||||
|
<Tag value={state} severity={severityMap[state] || 'secondary'} />
|
||||||
|
<span>{pipeline?.statusText || 'Bereit'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{running && (
|
||||||
|
<div className="progress-wrap">
|
||||||
|
<ProgressBar value={progress} showValue />
|
||||||
|
<small>{pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'FINISHED' && (
|
||||||
|
<div className="progress-wrap">
|
||||||
|
<ProgressBar value={100} showValue />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="actions-row">
|
||||||
|
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
|
||||||
|
<Button
|
||||||
|
label="Analyse starten"
|
||||||
|
icon="pi pi-search"
|
||||||
|
onClick={onAnalyze}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'READY_TO_START' && retryJobId && (
|
||||||
|
<Button
|
||||||
|
label="Job starten"
|
||||||
|
icon="pi pi-play"
|
||||||
|
severity="success"
|
||||||
|
onClick={() => onStart(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'READY_TO_ENCODE' && retryJobId && (
|
||||||
|
<Button
|
||||||
|
label="Auswahl bestätigen"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
onClick={() => {
|
||||||
|
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
|
||||||
|
const selectionEntry = encodeTitleId
|
||||||
|
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
|
||||||
|
: null;
|
||||||
|
const fallbackSelection = encodeTitleId
|
||||||
|
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
|
||||||
|
: { audioTrackIds: [], subtitleTrackIds: [] };
|
||||||
|
const effectiveSelection = selectionEntry || fallbackSelection;
|
||||||
|
const selectedTrackSelection = encodeTitleId
|
||||||
|
? {
|
||||||
|
[encodeTitleId]: {
|
||||||
|
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
||||||
|
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
onConfirmReview(retryJobId, encodeTitleId, selectedTrackSelection);
|
||||||
|
}}
|
||||||
|
loading={busy}
|
||||||
|
disabled={reviewConfirmed || !canConfirmReview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playlistDecisionRequiredBeforeStart && retryJobId && (
|
||||||
|
<Button
|
||||||
|
label="Playlist übernehmen"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="warning"
|
||||||
|
outlined
|
||||||
|
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!normalizePlaylistId(selectedPlaylistId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'READY_TO_ENCODE' && retryJobId && (
|
||||||
|
<Button
|
||||||
|
label={isPreRipReview ? 'Backup + Encode starten' : 'Encode starten'}
|
||||||
|
icon="pi pi-play"
|
||||||
|
severity="success"
|
||||||
|
onClick={() => onStart(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!canStartReadyJob || !reviewConfirmed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running && (
|
||||||
|
<Button
|
||||||
|
label="Abbrechen"
|
||||||
|
icon="pi pi-stop"
|
||||||
|
severity="danger"
|
||||||
|
onClick={onCancel}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canRestartEncodeFromLastSettings ? (
|
||||||
|
<Button
|
||||||
|
label="Encode neu starten"
|
||||||
|
icon="pi pi-play"
|
||||||
|
severity="success"
|
||||||
|
onClick={() => onRestartEncode?.(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!retryJobId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state === 'ERROR' && retryJobId && (
|
||||||
|
<Button
|
||||||
|
label="Retry Rippen"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="warning"
|
||||||
|
onClick={() => onRetry(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'ERROR' ? (
|
||||||
|
<Button
|
||||||
|
label="Disk-Analyse neu starten"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={onReanalyze || onAnalyze}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{running ? (
|
||||||
|
<div className="live-log-block">
|
||||||
|
<h4>Aktueller Job-Log</h4>
|
||||||
|
<pre className="log-box">{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{playlistDecisionRequiredBeforeStart ? (
|
||||||
|
<div className="playlist-decision-block">
|
||||||
|
<h3>Playlist-Auswahl erforderlich</h3>
|
||||||
|
<small>
|
||||||
|
Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden.
|
||||||
|
</small>
|
||||||
|
{waitingPlaylistRows.length > 0 ? (
|
||||||
|
<div className="playlist-decision-list">
|
||||||
|
{waitingPlaylistRows.map((row) => (
|
||||||
|
<div key={row.playlistId} className="playlist-decision-item">
|
||||||
|
<label className="readonly-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
|
||||||
|
onChange={() => {
|
||||||
|
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
|
||||||
|
setSelectedPlaylistId(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{row.playlistFile}
|
||||||
|
{row.titleId !== null ? ` | Titel #${row.titleId}` : ''}
|
||||||
|
{row.score !== null ? ` | Score ${row.score}` : ''}
|
||||||
|
{row.recommended ? ' | empfohlen' : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{row.evaluationLabel ? <small className="track-action-note">{row.evaluationLabel}</small> : null}
|
||||||
|
{row.sequenceCoherence !== null ? (
|
||||||
|
<small className="track-action-note">Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)}</small>
|
||||||
|
) : null}
|
||||||
|
{row.handBrakeTitleId !== null ? (
|
||||||
|
<small className="track-action-note">HandBrake Titel: -t {row.handBrakeTitleId}</small>
|
||||||
|
) : null}
|
||||||
|
{row.audioSummary ? (
|
||||||
|
<small className="track-action-note">Audio: {row.audioSummary}</small>
|
||||||
|
) : null}
|
||||||
|
{row.segmentCommand ? <small className="track-action-note">Info: {row.segmentCommand}</small> : null}
|
||||||
|
{Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
|
||||||
|
<details className="playlist-segment-toggle">
|
||||||
|
<summary>Audio-Spuren anzeigen ({row.audioTrackPreview.length})</summary>
|
||||||
|
<pre className="playlist-segment-output">{row.audioTrackPreview.join('\n')}</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
|
||||||
|
<details className="playlist-segment-toggle">
|
||||||
|
<summary>Segment-Dateien anzeigen ({row.segmentFiles.length})</summary>
|
||||||
|
<pre className="playlist-segment-output">{row.segmentFiles.join('\n')}</pre>
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
<small className="track-action-note">Keine Segmentliste aus TINFO:26 verfügbar.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<small>Keine Kandidaten gefunden. Bitte Analyse erneut ausführen.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedMetadata ? (
|
||||||
|
<div className="pipeline-meta-inline">
|
||||||
|
{selectedMetadata.poster ? (
|
||||||
|
<img
|
||||||
|
src={selectedMetadata.poster}
|
||||||
|
alt={selectedMetadata.title || 'Poster'}
|
||||||
|
className="poster-large"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="poster-large poster-fallback">Kein Poster</div>
|
||||||
|
)}
|
||||||
|
<div className="device-meta">
|
||||||
|
<div>
|
||||||
|
<strong>Titel:</strong> {selectedMetadata.title || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Jahr:</strong> {selectedMetadata.year || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Status:</strong> {state}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
|
||||||
|
<div className="mediainfo-review-block">
|
||||||
|
<h3>Titel-/Spurprüfung</h3>
|
||||||
|
{state === 'READY_TO_ENCODE' && !reviewConfirmed ? (
|
||||||
|
<small>
|
||||||
|
{isPreRipReview
|
||||||
|
? 'Backup/Rip + Encode ist gesperrt, bis die Spurauswahl bestätigt wurde.'
|
||||||
|
: 'Encode ist gesperrt, bis die Titel-/Spurauswahl bestätigt wurde.'}
|
||||||
|
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
<MediaInfoReviewPanel
|
||||||
|
review={mediaInfoReview}
|
||||||
|
commandOutputPath={commandOutputPath}
|
||||||
|
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||||
|
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||||
|
onSelectEncodeTitle={(titleId) => setSelectedEncodeTitleId(normalizeTitleId(titleId))}
|
||||||
|
allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
|
||||||
|
trackSelectionByTitle={trackSelectionByTitle}
|
||||||
|
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
||||||
|
const normalizedTitleId = normalizeTitleId(titleId);
|
||||||
|
const normalizedTrackId = normalizeTrackId(trackId);
|
||||||
|
if (!normalizedTitleId || normalizedTrackId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrackSelectionByTitle((prev) => {
|
||||||
|
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
|
||||||
|
audioTrackIds: [],
|
||||||
|
subtitleTrackIds: []
|
||||||
|
};
|
||||||
|
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
|
||||||
|
const existing = normalizeTrackIdList(current?.[key] || []);
|
||||||
|
const next = checked
|
||||||
|
? normalizeTrackIdList([...existing, normalizedTrackId])
|
||||||
|
: existing.filter((id) => id !== normalizedTrackId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[normalizedTitleId]: {
|
||||||
|
...current,
|
||||||
|
[key]: next
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/hooks/useWebSocket.js
Normal file
62
frontend/src/hooks/useWebSocket.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
function buildWsUrl() {
|
||||||
|
if (import.meta.env.VITE_WS_URL) {
|
||||||
|
return import.meta.env.VITE_WS_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket({ onMessage }) {
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let socket;
|
||||||
|
let reconnectTimer;
|
||||||
|
let isUnmounted = false;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (isUnmounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = new WebSocket(buildWsUrl());
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
onMessageRef.current?.(message);
|
||||||
|
} catch (error) {
|
||||||
|
// ignore invalid json
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (!isUnmounted) {
|
||||||
|
reconnectTimer = setTimeout(connect, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isUnmounted = true;
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
}
|
||||||
|
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
21
frontend/src/main.jsx
Normal file
21
frontend/src/main.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import 'primereact/resources/themes/lara-light-amber/theme.css';
|
||||||
|
import 'primereact/resources/primereact.min.css';
|
||||||
|
import 'primeicons/primeicons.css';
|
||||||
|
import './styles/app.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
629
frontend/src/pages/DashboardPage.jsx
Normal file
629
frontend/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import PipelineStatusCard from '../components/PipelineStatusCard';
|
||||||
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
|
||||||
|
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
|
||||||
|
const dashboardStatuses = new Set([
|
||||||
|
'ANALYZING',
|
||||||
|
'METADATA_SELECTION',
|
||||||
|
'WAITING_FOR_USER_DECISION',
|
||||||
|
'READY_TO_START',
|
||||||
|
'MEDIAINFO_CHECK',
|
||||||
|
'READY_TO_ENCODE',
|
||||||
|
'RIPPING',
|
||||||
|
'ENCODING',
|
||||||
|
'ERROR'
|
||||||
|
]);
|
||||||
|
const statusSeverityMap = {
|
||||||
|
IDLE: 'secondary',
|
||||||
|
DISC_DETECTED: 'info',
|
||||||
|
ANALYZING: 'warning',
|
||||||
|
METADATA_SELECTION: 'warning',
|
||||||
|
WAITING_FOR_USER_DECISION: 'warning',
|
||||||
|
READY_TO_START: 'info',
|
||||||
|
MEDIAINFO_CHECK: 'warning',
|
||||||
|
READY_TO_ENCODE: 'info',
|
||||||
|
RIPPING: 'warning',
|
||||||
|
ENCODING: 'warning',
|
||||||
|
FINISHED: 'success',
|
||||||
|
ERROR: 'danger'
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeJobId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnalyzeContext(job) {
|
||||||
|
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
||||||
|
? job.makemkvInfo.analyzeContext
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMediaType(job) {
|
||||||
|
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||||
|
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaIndicatorMeta(job) {
|
||||||
|
const mediaType = resolveMediaType(job);
|
||||||
|
return mediaType === 'bluray'
|
||||||
|
? {
|
||||||
|
mediaType,
|
||||||
|
src: blurayIndicatorIcon,
|
||||||
|
alt: 'Blu-ray',
|
||||||
|
title: 'Blu-ray'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mediaType,
|
||||||
|
src: discIndicatorIcon,
|
||||||
|
alt: 'Disc',
|
||||||
|
title: 'CD/sonstiges Medium'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||||
|
const jobId = normalizeJobId(job?.id);
|
||||||
|
if (
|
||||||
|
jobId
|
||||||
|
&& currentPipelineJobId
|
||||||
|
&& jobId === currentPipelineJobId
|
||||||
|
&& String(currentPipeline?.state || '').trim().toUpperCase() !== 'IDLE'
|
||||||
|
) {
|
||||||
|
return currentPipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
||||||
|
const analyzeContext = getAnalyzeContext(job);
|
||||||
|
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
|
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||||
|
const inputPath = isPreRip
|
||||||
|
? null
|
||||||
|
: (job?.encode_input_path || encodePlan?.encodeInputPath || null);
|
||||||
|
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
||||||
|
const hasEncodableTitle = isPreRip
|
||||||
|
? Boolean(encodePlan?.encodeInputTitleId)
|
||||||
|
: Boolean(inputPath || job?.raw_path);
|
||||||
|
const jobStatus = String(job?.status || job?.last_state || 'IDLE').trim().toUpperCase() || 'IDLE';
|
||||||
|
const lastState = String(job?.last_state || '').trim().toUpperCase();
|
||||||
|
const errorText = String(job?.error_message || '').trim().toUpperCase();
|
||||||
|
const hasEncodePlan = Boolean(encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0);
|
||||||
|
const looksLikeEncodingError = jobStatus === 'ERROR' && (
|
||||||
|
errorText.includes('ENCODING')
|
||||||
|
|| errorText.includes('HANDBRAKE')
|
||||||
|
|| lastState === 'ENCODING'
|
||||||
|
|| Boolean(job?.handbrakeInfo)
|
||||||
|
);
|
||||||
|
const canRestartEncodeFromLastSettings = Boolean(
|
||||||
|
hasEncodePlan
|
||||||
|
&& reviewConfirmed
|
||||||
|
&& hasEncodableTitle
|
||||||
|
&& (
|
||||||
|
jobStatus === 'READY_TO_ENCODE'
|
||||||
|
|| jobStatus === 'ENCODING'
|
||||||
|
|| looksLikeEncodingError
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: jobStatus,
|
||||||
|
activeJobId: jobId,
|
||||||
|
progress: Number.isFinite(Number(job?.progress)) ? Number(job.progress) : 0,
|
||||||
|
eta: job?.eta || null,
|
||||||
|
statusText: job?.status_text || job?.error_message || null,
|
||||||
|
context: {
|
||||||
|
jobId,
|
||||||
|
inputPath,
|
||||||
|
hasEncodableTitle,
|
||||||
|
reviewConfirmed,
|
||||||
|
mode,
|
||||||
|
sourceJobId: encodePlan?.sourceJobId || null,
|
||||||
|
selectedMetadata: {
|
||||||
|
title: job?.title || job?.detected_title || null,
|
||||||
|
year: job?.year || null,
|
||||||
|
imdbId: job?.imdb_id || null,
|
||||||
|
poster: job?.poster_url || null
|
||||||
|
},
|
||||||
|
mediaInfoReview: encodePlan,
|
||||||
|
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
||||||
|
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
||||||
|
playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates)
|
||||||
|
? analyzeContext.playlistAnalysis.evaluatedCandidates
|
||||||
|
: [],
|
||||||
|
selectedPlaylist: analyzeContext.selectedPlaylist || null,
|
||||||
|
selectedTitleId: analyzeContext.selectedTitleId ?? null,
|
||||||
|
canRestartEncodeFromLastSettings
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage({ pipeline, lastDiscEvent, refreshPipeline }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
|
const [liveJobLog, setLiveJobLog] = useState('');
|
||||||
|
const [jobsLoading, setJobsLoading] = useState(false);
|
||||||
|
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||||
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
|
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
||||||
|
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
||||||
|
const isProcessing = processingStates.includes(state);
|
||||||
|
|
||||||
|
const loadDashboardJobs = async () => {
|
||||||
|
setJobsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getJobs();
|
||||||
|
const allJobs = Array.isArray(response?.jobs) ? response.jobs : [];
|
||||||
|
const next = allJobs
|
||||||
|
.filter((job) => dashboardStatuses.has(String(job?.status || '').trim().toUpperCase()))
|
||||||
|
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
|
||||||
|
|
||||||
|
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||||
|
try {
|
||||||
|
const active = await api.getJob(currentPipelineJobId);
|
||||||
|
if (active?.job) {
|
||||||
|
next.unshift(active.job);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore; dashboard still shows available rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const deduped = [];
|
||||||
|
for (const job of next) {
|
||||||
|
const id = normalizeJobId(job?.id);
|
||||||
|
if (!id || seen.has(String(id))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(String(id));
|
||||||
|
deduped.push(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardJobs(deduped);
|
||||||
|
} catch (_error) {
|
||||||
|
setDashboardJobs([]);
|
||||||
|
} finally {
|
||||||
|
setJobsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
|
||||||
|
setMetadataDialogVisible(false);
|
||||||
|
}
|
||||||
|
}, [pipeline?.state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadDashboardJobs();
|
||||||
|
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||||
|
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||||
|
if (hasExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect explicit user collapse.
|
||||||
|
if (expandedJobId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPipelineJobId && dashboardJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
||||||
|
setExpandedJobId(currentPipelineJobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedJobId(normalizeJobId(dashboardJobs[0]?.id));
|
||||||
|
}, [dashboardJobs, expandedJobId, currentPipelineJobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPipelineJobId || !isProcessing) {
|
||||||
|
setLiveJobLog('');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const refreshLiveLog = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(currentPipelineJobId, { includeLiveLog: true });
|
||||||
|
if (!cancelled) {
|
||||||
|
setLiveJobLog(response?.job?.log || '');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore transient polling errors to avoid noisy toasts while background polling
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshLiveLog();
|
||||||
|
const interval = setInterval(refreshLiveLog, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [currentPipelineJobId, isProcessing]);
|
||||||
|
|
||||||
|
const pipelineByJobId = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const job of dashboardJobs) {
|
||||||
|
const id = normalizeJobId(job?.id);
|
||||||
|
if (!id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [dashboardJobs, pipeline, currentPipelineJobId]);
|
||||||
|
|
||||||
|
const showError = (error) => {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.analyzeDisc();
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReanalyze = async () => {
|
||||||
|
const hasActiveJob = Boolean(pipeline?.context?.jobId || pipeline?.activeJobId);
|
||||||
|
if (hasActiveJob && !['IDLE', 'DISC_DETECTED', 'FINISHED'].includes(state)) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Aktuellen Ablauf verwerfen und die Disk ab der ersten MakeMKV-Analyse neu starten?'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await handleAnalyze();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRescan = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.rescanDisc();
|
||||||
|
const emitted = response?.result?.emitted || 'none';
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: emitted === 'discInserted' ? 'success' : 'info',
|
||||||
|
summary: 'Laufwerk neu gelesen',
|
||||||
|
detail:
|
||||||
|
emitted === 'discInserted'
|
||||||
|
? 'Disk-Event wurde neu ausgelöst.'
|
||||||
|
: 'Kein Medium erkannt.',
|
||||||
|
life: 2800
|
||||||
|
});
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.cancelPipeline();
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartJob = async (jobId) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.startJob(jobId);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId(normalizeJobId(jobId));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmReview = async (jobId, selectedEncodeTitleId = null, selectedTrackSelection = null) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.confirmEncodeReview(jobId, {
|
||||||
|
selectedEncodeTitleId,
|
||||||
|
selectedTrackSelection
|
||||||
|
});
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId(normalizeJobId(jobId));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPlaylist = async (jobId, selectedPlaylist = null) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.selectMetadata({
|
||||||
|
jobId,
|
||||||
|
selectedPlaylist: selectedPlaylist || null
|
||||||
|
});
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId(normalizeJobId(jobId));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (jobId) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.retryJob(jobId);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId(normalizeJobId(jobId));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.restartEncodeWithLastSettings(jobId);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId(normalizeJobId(jobId));
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOmdbSearch = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await api.searchOmdb(query);
|
||||||
|
return response.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetadataSubmit = async (payload) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.selectMetadata(payload);
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setMetadataDialogVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const device = lastDiscEvent || pipeline?.context?.device;
|
||||||
|
const canReanalyze = !processingStates.includes(state);
|
||||||
|
const canOpenMetadataModal = pipeline?.state === 'METADATA_SELECTION' || pipeline?.state === 'WAITING_FOR_USER_DECISION';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-grid">
|
||||||
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
||||||
|
{jobsLoading ? (
|
||||||
|
<p>Jobs werden geladen ...</p>
|
||||||
|
) : dashboardJobs.length === 0 ? (
|
||||||
|
<p>Keine relevanten Jobs im Dashboard (aktive/fortsetzbare Status).</p>
|
||||||
|
) : (
|
||||||
|
<div className="dashboard-job-list">
|
||||||
|
{dashboardJobs.map((job) => {
|
||||||
|
const jobId = normalizeJobId(job?.id);
|
||||||
|
if (!jobId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||||
|
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
|
||||||
|
const isResumable = String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' && !isCurrentSession;
|
||||||
|
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
||||||
|
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||||
|
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
|
const mediaIndicator = mediaIndicatorMeta(job);
|
||||||
|
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||||
|
const clampedProgress = Number.isFinite(rawProgress)
|
||||||
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
|
: 0;
|
||||||
|
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||||
|
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
return (
|
||||||
|
<div key={jobId} className="dashboard-job-expanded">
|
||||||
|
<div className="dashboard-job-expanded-head">
|
||||||
|
<div className="dashboard-job-expanded-title">
|
||||||
|
<strong className="dashboard-job-title-line">
|
||||||
|
<img
|
||||||
|
src={mediaIndicator.src}
|
||||||
|
alt={mediaIndicator.alt}
|
||||||
|
title={mediaIndicator.title}
|
||||||
|
className="media-indicator-icon"
|
||||||
|
/>
|
||||||
|
<span>#{jobId} | {jobTitle}</span>
|
||||||
|
</strong>
|
||||||
|
<div className="dashboard-job-badges">
|
||||||
|
<Tag value={String(job?.status || '-')} severity={statusSeverityMap[String(job?.status || '').trim().toUpperCase()] || 'secondary'} />
|
||||||
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
|
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
|
||||||
|
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="Einklappen"
|
||||||
|
icon="pi pi-angle-up"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => setExpandedJobId(null)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PipelineStatusCard
|
||||||
|
pipeline={pipelineForJob}
|
||||||
|
onAnalyze={handleAnalyze}
|
||||||
|
onReanalyze={handleReanalyze}
|
||||||
|
onStart={handleStartJob}
|
||||||
|
onRestartEncode={handleRestartEncodeWithLastSettings}
|
||||||
|
onConfirmReview={handleConfirmReview}
|
||||||
|
onSelectPlaylist={handleSelectPlaylist}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
busy={busy}
|
||||||
|
liveJobLog={isCurrentSession ? liveJobLog : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={jobId}
|
||||||
|
type="button"
|
||||||
|
className="dashboard-job-row"
|
||||||
|
onClick={() => setExpandedJobId(jobId)}
|
||||||
|
>
|
||||||
|
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||||
|
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
||||||
|
) : (
|
||||||
|
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
||||||
|
)}
|
||||||
|
<div className="dashboard-job-row-main">
|
||||||
|
<strong className="dashboard-job-title-line">
|
||||||
|
<img
|
||||||
|
src={mediaIndicator.src}
|
||||||
|
alt={mediaIndicator.alt}
|
||||||
|
title={mediaIndicator.title}
|
||||||
|
className="media-indicator-icon"
|
||||||
|
/>
|
||||||
|
<span>{jobTitle}</span>
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
#{jobId}
|
||||||
|
{job?.year ? ` | ${job.year}` : ''}
|
||||||
|
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
|
||||||
|
</small>
|
||||||
|
<div className="dashboard-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||||
|
<ProgressBar value={clampedProgress} showValue={false} />
|
||||||
|
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-job-badges">
|
||||||
|
<Tag value={String(job?.status || '-')} severity={statusSeverityMap[String(job?.status || '').trim().toUpperCase()] || 'secondary'} />
|
||||||
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
|
{String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE'
|
||||||
|
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Disk-Information">
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Laufwerk neu lesen"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={handleRescan}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Disk neu analysieren"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="warning"
|
||||||
|
onClick={handleReanalyze}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!canReanalyze}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Metadaten-Modal öffnen"
|
||||||
|
icon="pi pi-list"
|
||||||
|
onClick={() => setMetadataDialogVisible(true)}
|
||||||
|
disabled={!canOpenMetadataModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{device ? (
|
||||||
|
<div className="device-meta">
|
||||||
|
<div>
|
||||||
|
<strong>Pfad:</strong> {device.path || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Modell:</strong> {device.model || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Disk-Label:</strong> {device.discLabel || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Laufwerks-Label:</strong> {device.label || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Mount:</strong> {device.mountpoint || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>Aktuell keine Disk erkannt.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<MetadataSelectionDialog
|
||||||
|
visible={metadataDialogVisible}
|
||||||
|
context={pipeline?.context || {}}
|
||||||
|
onHide={() => setMetadataDialogVisible(false)}
|
||||||
|
onSubmit={handleMetadataSubmit}
|
||||||
|
onSearch={handleOmdbSearch}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
557
frontend/src/pages/DatabasePage.jsx
Normal file
557
frontend/src/pages/DatabasePage.jsx
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { DataTable } from 'primereact/datatable';
|
||||||
|
import { Column } from 'primereact/column';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Alle', value: '' },
|
||||||
|
{ label: 'FINISHED', value: 'FINISHED' },
|
||||||
|
{ label: 'ERROR', value: 'ERROR' },
|
||||||
|
{ label: 'WAITING_FOR_USER_DECISION', value: 'WAITING_FOR_USER_DECISION' },
|
||||||
|
{ label: 'READY_TO_START', value: 'READY_TO_START' },
|
||||||
|
{ label: 'READY_TO_ENCODE', value: 'READY_TO_ENCODE' },
|
||||||
|
{ label: 'MEDIAINFO_CHECK', value: 'MEDIAINFO_CHECK' },
|
||||||
|
{ label: 'RIPPING', value: 'RIPPING' },
|
||||||
|
{ label: 'ENCODING', value: 'ENCODING' },
|
||||||
|
{ label: 'ANALYZING', value: 'ANALYZING' },
|
||||||
|
{ label: 'METADATA_SELECTION', value: 'METADATA_SELECTION' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusSeverity(status) {
|
||||||
|
if (status === 'FINISHED') return 'success';
|
||||||
|
if (status === 'ERROR') return 'danger';
|
||||||
|
if (status === 'READY_TO_START' || status === 'READY_TO_ENCODE') return 'info';
|
||||||
|
if (status === 'WAITING_FOR_USER_DECISION') return 'warning';
|
||||||
|
if (status === 'RIPPING' || status === 'ENCODING' || status === 'ANALYZING' || status === 'MEDIAINFO_CHECK') return 'warning';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatabasePage() {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [orphanRows, setOrphanRows] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [orphanLoading, setOrphanLoading] = useState(false);
|
||||||
|
const [selectedJob, setSelectedJob] = useState(null);
|
||||||
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||||
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
|
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||||
|
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
||||||
|
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
|
||||||
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
|
const loadRows = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getDatabaseRows({ search, status });
|
||||||
|
setRows(response.rows || []);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOrphans = async () => {
|
||||||
|
setOrphanLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getOrphanRawFolders();
|
||||||
|
setOrphanRows(response.rows || []);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'RAW-Prüfung fehlgeschlagen', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setOrphanLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
await Promise.all([loadRows(), loadOrphans()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
load();
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailVisible || !selectedJob?.id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPoll =
|
||||||
|
['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(selectedJob.status) ||
|
||||||
|
(selectedJob.status === 'READY_TO_ENCODE' && !selectedJob.encodePlan);
|
||||||
|
|
||||||
|
if (!shouldPoll) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const refreshDetail = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(selectedJob.id, { includeLogs: false });
|
||||||
|
if (!cancelled) {
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore polling errors; user can manually refresh
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(refreshDetail, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [detailVisible, selectedJob?.id, selectedJob?.status, selectedJob?.encodePlan]);
|
||||||
|
|
||||||
|
const openDetail = async (row) => {
|
||||||
|
const jobId = Number(row?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedJob({
|
||||||
|
...row,
|
||||||
|
logs: [],
|
||||||
|
log: '',
|
||||||
|
logMeta: {
|
||||||
|
loaded: false,
|
||||||
|
total: Number(row?.log_count || 0),
|
||||||
|
returned: 0,
|
||||||
|
truncated: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDetailVisible(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(jobId, { includeLogs: false });
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshDetailIfOpen = async (jobId) => {
|
||||||
|
if (!detailVisible || !selectedJob || selectedJob.id !== jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.getJob(jobId, { includeLogs: false });
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadLog = async (job, mode = 'tail') => {
|
||||||
|
const jobId = Number(job?.id || selectedJob?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogLoadingMode(mode);
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(jobId, {
|
||||||
|
includeLogs: true,
|
||||||
|
includeAllLogs: mode === 'all',
|
||||||
|
logTailLines: mode === 'all' ? null : 800
|
||||||
|
});
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setLogLoadingMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFiles = async (row, target) => {
|
||||||
|
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? 'Movie-Datei(en)' : 'RAW + Movie';
|
||||||
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
|
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.deleteJobFiles(row.id, target);
|
||||||
|
const summary = response.summary || {};
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Dateien gelöscht',
|
||||||
|
detail: `RAW: ${summary.raw?.filesDeleted ?? 0}, MOVIE: ${summary.movie?.filesDeleted ?? 0}`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await refreshDetailIfOpen(row.id);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Löschen fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReencode = async (row) => {
|
||||||
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
|
const confirmed = window.confirm(`Re-Encode aus RAW für "${title}" starten? Der bestehende Job wird aktualisiert.`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReencodeBusyJobId(row.id);
|
||||||
|
try {
|
||||||
|
const response = await api.reencodeJob(row.id);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Re-Encode gestartet',
|
||||||
|
detail: 'Bestehender Job wurde in die Mediainfo-Prüfung gesetzt.',
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await refreshDetailIfOpen(row.id);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
} finally {
|
||||||
|
setReencodeBusyJobId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDeleteChoice = (value) => {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'raw') return 'raw';
|
||||||
|
if (normalized === 'fertig') return 'movie';
|
||||||
|
if (normalized === 'beides') return 'both';
|
||||||
|
if (normalized === 'nichts') return 'none';
|
||||||
|
if (normalized === 'movie') return 'movie';
|
||||||
|
if (normalized === 'both') return 'both';
|
||||||
|
if (normalized === 'none') return 'none';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEntry = async (row) => {
|
||||||
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
|
const choiceRaw = window.prompt(
|
||||||
|
`Was soll beim Löschen von "${title}" mit gelöscht werden?\n` +
|
||||||
|
'- raw\n' +
|
||||||
|
'- fertig\n' +
|
||||||
|
'- beides\n' +
|
||||||
|
'- nichts',
|
||||||
|
'nichts'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choiceRaw === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = mapDeleteChoice(choiceRaw);
|
||||||
|
if (!target) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Ungültige Eingabe',
|
||||||
|
detail: 'Bitte genau eine Option verwenden: raw, fertig, beides, nichts.',
|
||||||
|
life: 4200
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Historieneintrag "${title}" wirklich löschen? Auswahl: ${target === 'movie' ? 'fertig' : target}`
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteEntryBusyJobId(row.id);
|
||||||
|
try {
|
||||||
|
const response = await api.deleteJobEntry(row.id, target);
|
||||||
|
const rawDeleted = response?.fileSummary?.raw?.filesDeleted ?? 0;
|
||||||
|
const movieDeleted = response?.fileSummary?.movie?.filesDeleted ?? 0;
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Historieneintrag gelöscht',
|
||||||
|
detail: `Dateien entfernt: RAW ${rawDeleted}, Fertig ${movieDeleted}`,
|
||||||
|
life: 4200
|
||||||
|
});
|
||||||
|
if (selectedJob?.id === row.id) {
|
||||||
|
setDetailVisible(false);
|
||||||
|
setSelectedJob(null);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Löschen fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleteEntryBusyJobId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportOrphanRaw = async (row) => {
|
||||||
|
const target = row?.rawPath || row?.folderName || '-';
|
||||||
|
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrphanImportBusyPath(row.rawPath);
|
||||||
|
try {
|
||||||
|
const response = await api.importOrphanRawFolder(row.rawPath);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Job angelegt',
|
||||||
|
detail: `Historieneintrag #${response?.job?.id || '-'} wurde erstellt.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Import fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setOrphanImportBusyPath(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOmdbSearch = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await api.searchOmdb(query);
|
||||||
|
return response.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'OMDb Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMetadataAssignDialog = (row) => {
|
||||||
|
if (!row?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detectedTitle = row.title || row.detected_title || '';
|
||||||
|
const imdbId = String(row.imdb_id || '').trim();
|
||||||
|
const seedRows = imdbId
|
||||||
|
? [{
|
||||||
|
title: row.title || row.detected_title || detectedTitle || imdbId,
|
||||||
|
year: row.year || '',
|
||||||
|
imdbId,
|
||||||
|
type: 'movie',
|
||||||
|
poster: row.poster_url || null
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setMetadataDialogContext({
|
||||||
|
jobId: row.id,
|
||||||
|
detectedTitle,
|
||||||
|
selectedMetadata: {
|
||||||
|
title: row.title || row.detected_title || '',
|
||||||
|
year: row.year || '',
|
||||||
|
imdbId,
|
||||||
|
poster: row.poster_url || null
|
||||||
|
},
|
||||||
|
omdbCandidates: seedRows
|
||||||
|
});
|
||||||
|
setMetadataDialogVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetadataAssignSubmit = async (payload) => {
|
||||||
|
const jobId = Number(payload?.jobId || metadataDialogContext?.jobId || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMetadataDialogBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.assignJobOmdb(jobId, payload);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'OMDb-Zuordnung aktualisiert',
|
||||||
|
detail: `Job #${jobId} wurde aktualisiert.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
setMetadataDialogVisible(false);
|
||||||
|
await load();
|
||||||
|
if (detailVisible && selectedJob?.id === jobId && response?.job) {
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} else {
|
||||||
|
await refreshDetailIfOpen(jobId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'OMDb-Zuordnung fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setMetadataDialogBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterBody = (row) =>
|
||||||
|
row.poster_url && row.poster_url !== 'N/A' ? (
|
||||||
|
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleBody = (row) => (
|
||||||
|
<div>
|
||||||
|
<div><strong>{row.title || row.detected_title || '-'}</strong></div>
|
||||||
|
<small>{row.year || '-'} | {row.imdb_id || '-'}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateBody = (row) => <Tag value={row.status} severity={statusSeverity(row.status)} />;
|
||||||
|
const orphanTitleBody = (row) => (
|
||||||
|
<div>
|
||||||
|
<div><strong>{row.title || '-'}</strong></div>
|
||||||
|
<small>{row.year || '-'} | {row.imdbId || '-'}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const orphanPathBody = (row) => (
|
||||||
|
<div className="orphan-path-cell">
|
||||||
|
{row.rawPath}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const orphanActionBody = (row) => (
|
||||||
|
<Button
|
||||||
|
label="Job anlegen"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleImportOrphanRaw(row)}
|
||||||
|
loading={orphanImportBusyPath === row.rawPath}
|
||||||
|
disabled={Boolean(orphanImportBusyPath) || Boolean(actionBusy)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-grid">
|
||||||
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<Card title="Historie & Datenbank" subTitle="Kompakte Übersicht, Details im Job-Modal">
|
||||||
|
<div className="table-filters">
|
||||||
|
<InputText
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Suche nach Titel oder IMDb"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
value={status}
|
||||||
|
options={statusOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setStatus(event.value)}
|
||||||
|
placeholder="Status"
|
||||||
|
/>
|
||||||
|
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-scroll-wrap table-scroll-wide">
|
||||||
|
<DataTable
|
||||||
|
value={rows}
|
||||||
|
dataKey="id"
|
||||||
|
paginator
|
||||||
|
rows={10}
|
||||||
|
loading={loading}
|
||||||
|
onRowClick={(event) => openDetail(event.data)}
|
||||||
|
className="clickable-table"
|
||||||
|
emptyMessage="Keine Einträge"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" style={{ width: '6rem' }} />
|
||||||
|
<Column header="Bild" body={posterBody} style={{ width: '7rem' }} />
|
||||||
|
<Column header="Titel" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||||
|
<Column header="Status" body={stateBody} style={{ width: '11rem' }} />
|
||||||
|
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
||||||
|
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="RAW ohne Historie" subTitle="Ordner in raw_dir ohne zugehörigen Job können hier importiert werden">
|
||||||
|
<div className="table-filters">
|
||||||
|
<Button
|
||||||
|
label="RAW prüfen"
|
||||||
|
icon="pi pi-search"
|
||||||
|
onClick={loadOrphans}
|
||||||
|
loading={orphanLoading}
|
||||||
|
disabled={Boolean(orphanImportBusyPath)}
|
||||||
|
/>
|
||||||
|
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-scroll-wrap table-scroll-wide">
|
||||||
|
<DataTable
|
||||||
|
value={orphanRows}
|
||||||
|
dataKey="rawPath"
|
||||||
|
paginator
|
||||||
|
rows={5}
|
||||||
|
loading={orphanLoading}
|
||||||
|
emptyMessage="Keine verwaisten RAW-Ordner gefunden"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
>
|
||||||
|
<Column field="folderName" header="RAW-Ordner" style={{ minWidth: '18rem' }} />
|
||||||
|
<Column header="Titel" body={orphanTitleBody} style={{ minWidth: '14rem' }} />
|
||||||
|
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
|
||||||
|
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
|
||||||
|
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
|
||||||
|
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} />
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<JobDetailDialog
|
||||||
|
visible={detailVisible}
|
||||||
|
job={selectedJob}
|
||||||
|
detailLoading={detailLoading}
|
||||||
|
onLoadLog={handleLoadLog}
|
||||||
|
logLoadingMode={logLoadingMode}
|
||||||
|
onHide={() => {
|
||||||
|
setDetailVisible(false);
|
||||||
|
setDetailLoading(false);
|
||||||
|
setLogLoadingMode(null);
|
||||||
|
}}
|
||||||
|
onAssignOmdb={openMetadataAssignDialog}
|
||||||
|
onReencode={handleReencode}
|
||||||
|
onDeleteFiles={handleDeleteFiles}
|
||||||
|
onDeleteEntry={handleDeleteEntry}
|
||||||
|
omdbAssignBusy={metadataDialogBusy}
|
||||||
|
actionBusy={actionBusy}
|
||||||
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
|
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MetadataSelectionDialog
|
||||||
|
visible={metadataDialogVisible}
|
||||||
|
context={metadataDialogContext || {}}
|
||||||
|
onHide={() => setMetadataDialogVisible(false)}
|
||||||
|
onSubmit={handleMetadataAssignSubmit}
|
||||||
|
onSearch={handleOmdbSearch}
|
||||||
|
busy={metadataDialogBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/pages/HistoryPage.jsx
Normal file
197
frontend/src/pages/HistoryPage.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { DataTable } from 'primereact/datatable';
|
||||||
|
import { Column } from 'primereact/column';
|
||||||
|
import { InputText } from 'primereact/inputtext';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Alle', value: '' },
|
||||||
|
{ label: 'FINISHED', value: 'FINISHED' },
|
||||||
|
{ label: 'ERROR', value: 'ERROR' },
|
||||||
|
{ label: 'WAITING_FOR_USER_DECISION', value: 'WAITING_FOR_USER_DECISION' },
|
||||||
|
{ label: 'READY_TO_START', value: 'READY_TO_START' },
|
||||||
|
{ label: 'READY_TO_ENCODE', value: 'READY_TO_ENCODE' },
|
||||||
|
{ label: 'MEDIAINFO_CHECK', value: 'MEDIAINFO_CHECK' },
|
||||||
|
{ label: 'RIPPING', value: 'RIPPING' },
|
||||||
|
{ label: 'ENCODING', value: 'ENCODING' },
|
||||||
|
{ label: 'ANALYZING', value: 'ANALYZING' },
|
||||||
|
{ label: 'METADATA_SELECTION', value: 'METADATA_SELECTION' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveMediaType(row) {
|
||||||
|
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||||
|
return raw === 'bluray' ? 'bluray' : 'disc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const [jobs, setJobs] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [selectedJob, setSelectedJob] = useState(null);
|
||||||
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getJobs({ search, status });
|
||||||
|
setJobs(response.jobs || []);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
load();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, status]);
|
||||||
|
|
||||||
|
const openDetail = async (row) => {
|
||||||
|
const jobId = Number(row?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedJob({
|
||||||
|
...row,
|
||||||
|
logs: [],
|
||||||
|
log: '',
|
||||||
|
logMeta: {
|
||||||
|
loaded: false,
|
||||||
|
total: Number(row?.log_count || 0),
|
||||||
|
returned: 0,
|
||||||
|
truncated: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDetailVisible(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(jobId, { includeLogs: false });
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadLog = async (job, mode = 'tail') => {
|
||||||
|
const jobId = Number(job?.id || selectedJob?.id || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogLoadingMode(mode);
|
||||||
|
try {
|
||||||
|
const response = await api.getJob(jobId, {
|
||||||
|
includeLogs: true,
|
||||||
|
includeAllLogs: mode === 'all',
|
||||||
|
logTailLines: mode === 'all' ? null : 800
|
||||||
|
});
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Log konnte nicht geladen werden', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setLogLoadingMode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBody = (row) => <Tag value={row.status} />;
|
||||||
|
const mkBody = (row) => row.makemkvInfo ? `${row.makemkvInfo.status || '-'} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}` : '-';
|
||||||
|
const hbBody = (row) => row.handbrakeInfo ? `${row.handbrakeInfo.status || '-'} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}` : '-';
|
||||||
|
const mediaBody = (row) => {
|
||||||
|
const mediaType = resolveMediaType(row);
|
||||||
|
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
||||||
|
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
||||||
|
const title = mediaType === 'bluray' ? 'Blu-ray' : 'CD/sonstiges Medium';
|
||||||
|
return <img src={src} alt={alt} title={title} className="media-indicator-icon" />;
|
||||||
|
};
|
||||||
|
const posterBody = (row) =>
|
||||||
|
row.poster_url && row.poster_url !== 'N/A' ? (
|
||||||
|
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-grid">
|
||||||
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<Card title="Historie" subTitle="Alle Jobs mit Details und Logs">
|
||||||
|
<div className="table-filters">
|
||||||
|
<InputText
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Suche nach Titel oder IMDb"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
value={status}
|
||||||
|
options={statusOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setStatus(event.value)}
|
||||||
|
placeholder="Status"
|
||||||
|
/>
|
||||||
|
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-scroll-wrap table-scroll-wide">
|
||||||
|
<DataTable
|
||||||
|
value={jobs}
|
||||||
|
dataKey="id"
|
||||||
|
paginator
|
||||||
|
rows={10}
|
||||||
|
loading={loading}
|
||||||
|
onRowClick={(event) => openDetail(event.data)}
|
||||||
|
className="clickable-table"
|
||||||
|
emptyMessage="Keine Einträge"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
>
|
||||||
|
<Column field="id" header="#" style={{ width: '5rem' }} />
|
||||||
|
<Column header="Medium" body={mediaBody} style={{ width: '6rem' }} />
|
||||||
|
<Column header="Poster" body={posterBody} style={{ width: '7rem' }} />
|
||||||
|
<Column field="title" header="Titel" body={(row) => row.title || row.detected_title || '-'} />
|
||||||
|
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
||||||
|
<Column field="imdb_id" header="IMDb" style={{ width: '10rem' }} />
|
||||||
|
<Column field="status" header="Status" body={statusBody} style={{ width: '12rem' }} />
|
||||||
|
<Column header="MakeMKV" body={mkBody} style={{ width: '12rem' }} />
|
||||||
|
<Column header="HandBrake" body={hbBody} style={{ width: '12rem' }} />
|
||||||
|
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
||||||
|
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
|
||||||
|
<Column field="output_path" header="Output" />
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<JobDetailDialog
|
||||||
|
visible={detailVisible}
|
||||||
|
job={selectedJob}
|
||||||
|
detailLoading={detailLoading}
|
||||||
|
onLoadLog={handleLoadLog}
|
||||||
|
logLoadingMode={logLoadingMode}
|
||||||
|
onHide={() => {
|
||||||
|
setDetailVisible(false);
|
||||||
|
setDetailLoading(false);
|
||||||
|
setLogLoadingMode(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/pages/SettingsPage.jsx
Normal file
204
frontend/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Card } from 'primereact/card';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||||
|
|
||||||
|
function buildValuesMap(categories) {
|
||||||
|
const next = {};
|
||||||
|
for (const category of categories || []) {
|
||||||
|
for (const setting of category.settings || []) {
|
||||||
|
next[setting.key] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameValue(a, b) {
|
||||||
|
if (typeof a === 'number' && typeof b === 'number') {
|
||||||
|
return Number(a) === Number(b);
|
||||||
|
}
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testingPushover, setTestingPushover] = useState(false);
|
||||||
|
const [initialValues, setInitialValues] = useState({});
|
||||||
|
const [draftValues, setDraftValues] = useState({});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.getSettings();
|
||||||
|
const nextCategories = response.categories || [];
|
||||||
|
const values = buildValuesMap(nextCategories);
|
||||||
|
setCategories(nextCategories);
|
||||||
|
setInitialValues(values);
|
||||||
|
setDraftValues(values);
|
||||||
|
setErrors({});
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dirtyKeys = useMemo(() => {
|
||||||
|
const keys = new Set();
|
||||||
|
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (!isSameValue(initialValues[key], draftValues[key])) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}, [initialValues, draftValues]);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = dirtyKeys.size > 0;
|
||||||
|
|
||||||
|
const handleFieldChange = (key, value) => {
|
||||||
|
setDraftValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!hasUnsavedChanges) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Settings',
|
||||||
|
detail: 'Keine Änderungen zum Speichern.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
for (const key of dirtyKeys) {
|
||||||
|
patch[key] = draftValues[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await api.updateSettingsBulk(patch);
|
||||||
|
setInitialValues((prev) => ({ ...prev, ...patch }));
|
||||||
|
setErrors({});
|
||||||
|
const reviewRefresh = response?.reviewRefresh || null;
|
||||||
|
const reviewRefreshHint = reviewRefresh?.triggered
|
||||||
|
? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.'
|
||||||
|
: '';
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Settings',
|
||||||
|
detail: `${Object.keys(patch).length} Änderung(en) gespeichert.${reviewRefreshHint}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
let detail = error?.message || 'Unbekannter Fehler';
|
||||||
|
if (Array.isArray(error?.details)) {
|
||||||
|
const nextErrors = {};
|
||||||
|
for (const item of error.details) {
|
||||||
|
if (item?.key) {
|
||||||
|
nextErrors[item.key] = item.message || 'Ungültiger Wert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setErrors(nextErrors);
|
||||||
|
detail = 'Mindestens ein Feld ist ungültig.';
|
||||||
|
}
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
setDraftValues(initialValues);
|
||||||
|
setErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePushoverTest = async () => {
|
||||||
|
setTestingPushover(true);
|
||||||
|
try {
|
||||||
|
const response = await api.testPushover();
|
||||||
|
const sent = response?.result?.sent;
|
||||||
|
if (sent) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'PushOver',
|
||||||
|
detail: 'Testnachricht wurde versendet.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'PushOver',
|
||||||
|
detail: `Nicht versendet (${response?.result?.reason || 'unbekannt'}).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'PushOver Fehler', detail: error.message });
|
||||||
|
} finally {
|
||||||
|
setTestingPushover(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-grid">
|
||||||
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<Card title="Einstellungen" subTitle="Änderungen werden erst beim Speichern in die Datenbank übernommen">
|
||||||
|
<div className="actions-row">
|
||||||
|
<Button
|
||||||
|
label="Änderungen speichern"
|
||||||
|
icon="pi pi-save"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!hasUnsavedChanges}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Änderungen verwerfen"
|
||||||
|
icon="pi pi-undo"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={handleDiscard}
|
||||||
|
disabled={!hasUnsavedChanges || saving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Neu laden"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
onClick={load}
|
||||||
|
loading={loading}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="PushOver Test"
|
||||||
|
icon="pi pi-send"
|
||||||
|
severity="info"
|
||||||
|
onClick={handlePushoverTest}
|
||||||
|
loading={testingPushover}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p>Lade Settings ...</p>
|
||||||
|
) : (
|
||||||
|
<DynamicSettingsForm
|
||||||
|
categories={categories}
|
||||||
|
values={draftValues}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
974
frontend/src/styles/app.css
Normal file
974
frontend/src/styles/app.css
Normal file
@@ -0,0 +1,974 @@
|
|||||||
|
:root {
|
||||||
|
--rip-ink: #2f180f;
|
||||||
|
--rip-brown-900: #3a1d12;
|
||||||
|
--rip-brown-800: #552919;
|
||||||
|
--rip-brown-700: #6f3922;
|
||||||
|
--rip-brown-600: #8e4d2d;
|
||||||
|
--rip-gold-400: #d49c56;
|
||||||
|
--rip-gold-300: #e7c17e;
|
||||||
|
--rip-gold-200: #f2dcad;
|
||||||
|
--rip-cream-100: #fbf2df;
|
||||||
|
--rip-cream-50: #fffaf0;
|
||||||
|
--rip-border: #d9bc8d;
|
||||||
|
--rip-muted: #6a4d38;
|
||||||
|
--rip-panel: #fffaf1;
|
||||||
|
--rip-panel-soft: #fdf5e7;
|
||||||
|
|
||||||
|
/* PrimeReact theme tokens */
|
||||||
|
--primary-color: var(--rip-brown-600);
|
||||||
|
--primary-color-text: #fff7e9;
|
||||||
|
--primary-50: #fef6ec;
|
||||||
|
--primary-100: #f3d9b2;
|
||||||
|
--primary-200: #e7bd87;
|
||||||
|
--primary-300: #daa05c;
|
||||||
|
--primary-400: #cd8331;
|
||||||
|
--primary-500: #b56a2a;
|
||||||
|
--primary-600: #965724;
|
||||||
|
--primary-700: #77431c;
|
||||||
|
--primary-800: #583015;
|
||||||
|
--primary-900: #391e0d;
|
||||||
|
--green-50: #fcf7ee;
|
||||||
|
--green-100: #f4dec0;
|
||||||
|
--green-200: #ebc594;
|
||||||
|
--green-300: #e1ac68;
|
||||||
|
--green-400: #d8933d;
|
||||||
|
--green-500: #bf7832;
|
||||||
|
--green-600: #9f642a;
|
||||||
|
--green-700: #7f4f21;
|
||||||
|
--green-800: #603a18;
|
||||||
|
--green-900: #402510;
|
||||||
|
--yellow-50: #fff8e9;
|
||||||
|
--yellow-100: #f9e2b3;
|
||||||
|
--yellow-200: #f2cb7f;
|
||||||
|
--yellow-300: #ebb54c;
|
||||||
|
--yellow-400: #e49e19;
|
||||||
|
--yellow-500: #ca8616;
|
||||||
|
--yellow-600: #a66f12;
|
||||||
|
--yellow-700: #83580e;
|
||||||
|
--yellow-800: #60410a;
|
||||||
|
--yellow-900: #3d2a06;
|
||||||
|
--blue-50: #f8f4ee;
|
||||||
|
--blue-100: #e7d8c0;
|
||||||
|
--blue-200: #d7bd96;
|
||||||
|
--blue-300: #c7a16d;
|
||||||
|
--blue-400: #b78643;
|
||||||
|
--blue-500: #9d6d38;
|
||||||
|
--blue-600: #825a2e;
|
||||||
|
--blue-700: #664623;
|
||||||
|
--blue-800: #4b3319;
|
||||||
|
--blue-900: #301f0f;
|
||||||
|
--red-50: #fdf2ed;
|
||||||
|
--red-100: #f3cec0;
|
||||||
|
--red-200: #eaa995;
|
||||||
|
--red-300: #e18569;
|
||||||
|
--red-400: #d7613e;
|
||||||
|
--red-500: #bf4f33;
|
||||||
|
--red-600: #9f422a;
|
||||||
|
--red-700: #7f3521;
|
||||||
|
--red-800: #602818;
|
||||||
|
--red-900: #401b10;
|
||||||
|
--surface-ground: #f2e7d1;
|
||||||
|
--surface-section: var(--rip-cream-100);
|
||||||
|
--surface-card: var(--rip-panel);
|
||||||
|
--surface-overlay: #fffaf2;
|
||||||
|
--surface-border: var(--rip-border);
|
||||||
|
--surface-hover: #f4e7d0;
|
||||||
|
--focus-ring: 0 0 0 0.2rem rgba(182, 107, 44, 0.35);
|
||||||
|
--text-color: var(--rip-ink);
|
||||||
|
--text-color-secondary: var(--rip-muted);
|
||||||
|
--highlight-bg: var(--rip-gold-200);
|
||||||
|
--highlight-text-color: var(--rip-brown-900);
|
||||||
|
|
||||||
|
font-family: 'Source Sans 3', 'Segoe UI', sans-serif;
|
||||||
|
color: var(--rip-ink);
|
||||||
|
background: radial-gradient(circle at 12% 8%, #f8e3bc 0%, #f8ecd4 35%, #fdf8ee 68%, #fffdf8 100%);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: inherit;
|
||||||
|
color: var(--rip-ink);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 0.95rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--rip-border);
|
||||||
|
background: linear-gradient(105deg, #f1d09b 0%, #e1af6d 32%, #d89d57 60%, #be7441 100%);
|
||||||
|
box-shadow: 0 8px 18px rgba(58, 29, 18, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 5px 9px rgba(58, 29, 18, 0.28));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: var(--rip-brown-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy p {
|
||||||
|
margin: 0.28rem 0 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.p-button {
|
||||||
|
border-color: rgba(58, 29, 18, 0.36);
|
||||||
|
color: var(--rip-brown-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.p-button:not(.p-button-outlined) {
|
||||||
|
background: var(--rip-brown-700);
|
||||||
|
border-color: var(--rip-brown-700);
|
||||||
|
color: #fff8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.p-button.p-button-outlined {
|
||||||
|
background: rgba(255, 247, 232, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.p-button.p-button-outlined:hover {
|
||||||
|
background: rgba(255, 247, 232, 0.78);
|
||||||
|
border-color: var(--rip-brown-700);
|
||||||
|
color: var(--rip-brown-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn-active.p-button {
|
||||||
|
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
width: min(1280px, 96vw);
|
||||||
|
margin: 1rem auto 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-grid > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-grid .p-card,
|
||||||
|
.page-grid .p-card .p-card-body,
|
||||||
|
.page-grid .p-card .p-card-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrap {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--rip-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row:hover {
|
||||||
|
border-color: var(--rip-brown-600);
|
||||||
|
background: #fbf0df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-main strong,
|
||||||
|
.dashboard-job-row-main small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-title-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-title-line > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-indicator-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-progress .p-progressbar {
|
||||||
|
height: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-progress small {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-poster-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.56rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
background: #f6ebd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-expanded {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-expanded-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-expanded-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-waiting-box {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title-choice-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title-choice {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title-choice label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title-choice input {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-recommendation-box {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-selection-box {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-selection-box h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-candidate-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-grid {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabview {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabview .p-tabview-panels {
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sections {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section.grouped {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: #fff7ea;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--rip-brown-800);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-head small {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row .saved-tag {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #9d261b;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #9d261b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filters {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 16rem auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-wrap .p-datatable {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-wrap .p-datatable-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-wrap .p-datatable-table {
|
||||||
|
min-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-wrap.table-scroll-medium .p-datatable-table {
|
||||||
|
min-width: 54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-wrap.table-scroll-wide .p-datatable-table {
|
||||||
|
min-width: 72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-table tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta-grid > div,
|
||||||
|
.device-meta > div,
|
||||||
|
.selected-meta > div,
|
||||||
|
.pipeline-meta-inline .device-meta > div {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-head-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 11rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-json-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-box {
|
||||||
|
background: #2a140d;
|
||||||
|
color: #f8e2b8;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-thumb {
|
||||||
|
width: 46px;
|
||||||
|
height: 68px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px solid #cba266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-thumb-lg {
|
||||||
|
width: 64px;
|
||||||
|
height: 92px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px solid #cba266;
|
||||||
|
background: #f6ebd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omdb-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omdb-row > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-path-cell {
|
||||||
|
max-width: 36rem;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pipeline-meta-inline {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-large {
|
||||||
|
width: 100%;
|
||||||
|
height: 16rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #cba266;
|
||||||
|
background: #f6ebd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
background: #231109;
|
||||||
|
color: #f6ddb2;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-height: 25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-block h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-block .log-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-decision-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-decision-block h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-decision-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-job-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-job-item {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-job-item > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-decision-item {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediainfo-review-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid var(--rip-border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediainfo-review-block h3 {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-review-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-review-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.4rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-review-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title-list,
|
||||||
|
.media-track-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-track-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-action-note {
|
||||||
|
margin-left: 1.7rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title-block {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-info-box {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-segment-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: #f7ecd7;
|
||||||
|
color: var(--rip-brown-900);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-segment-toggle {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-segment-toggle > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-check-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-check-row input {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-check-row span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-track-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbrake-command-preview {
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbrake-command-preview pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: #f7ecd7;
|
||||||
|
color: var(--rip-brown-900);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-dialog .p-dialog-content,
|
||||||
|
.metadata-selection-dialog .p-dialog-content,
|
||||||
|
.disc-detected-dialog .p-dialog-content {
|
||||||
|
max-height: min(80vh, 56rem);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-dialog,
|
||||||
|
.metadata-selection-dialog,
|
||||||
|
.disc-detected-dialog {
|
||||||
|
max-width: calc(100vw - 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-dialog .p-dialog-header-title,
|
||||||
|
.metadata-selection-dialog .p-dialog-header-title,
|
||||||
|
.disc-detected-dialog .p-dialog-header-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-header {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy h1 {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy p {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-grid,
|
||||||
|
.device-meta,
|
||||||
|
.media-review-meta,
|
||||||
|
.media-track-grid,
|
||||||
|
.job-meta-grid,
|
||||||
|
.table-filters,
|
||||||
|
.job-head-row,
|
||||||
|
.job-json-grid,
|
||||||
|
.selected-meta,
|
||||||
|
.pipeline-meta-inline {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row {
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-badges {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-expanded-head {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-path-cell {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-selection-dialog .p-datatable-wrapper {
|
||||||
|
max-height: 16rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-main {
|
||||||
|
width: min(1280px, 98vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row .p-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions .p-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row .poster-thumb,
|
||||||
|
.dashboard-job-row .dashboard-job-poster-fallback {
|
||||||
|
width: 52px;
|
||||||
|
height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-row-main strong,
|
||||||
|
.dashboard-job-row-main small {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-job-title-line > span {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-dialog .p-dialog-header,
|
||||||
|
.metadata-selection-dialog .p-dialog-header,
|
||||||
|
.disc-detected-dialog .p-dialog-header {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-dialog .p-dialog-content,
|
||||||
|
.metadata-selection-dialog .p-dialog-content,
|
||||||
|
.disc-detected-dialog .p-dialog-content {
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
max-height: 78vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
frontend/vite.config.js
Normal file
51
frontend/vite.config.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
|
||||||
|
const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const allowedHosts = parsedAllowedHosts.length > 0 ? parsedAllowedHosts : true;
|
||||||
|
|
||||||
|
let hmr = undefined;
|
||||||
|
if (publicOrigin) {
|
||||||
|
const url = new URL(publicOrigin);
|
||||||
|
const defaultClientPort = url.port
|
||||||
|
? Number(url.port)
|
||||||
|
: (url.protocol === 'https:' ? 443 : 80);
|
||||||
|
|
||||||
|
hmr = {
|
||||||
|
protocol: process.env.VITE_HMR_PROTOCOL || (url.protocol === 'https:' ? 'wss' : 'ws'),
|
||||||
|
host: process.env.VITE_HMR_HOST || url.hostname,
|
||||||
|
clientPort: Number(process.env.VITE_HMR_CLIENT_PORT || defaultClientPort)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
origin: publicOrigin || undefined,
|
||||||
|
allowedHosts,
|
||||||
|
hmr,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://127.0.0.1:3001',
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true
|
||||||
|
}
|
||||||
|
});
|
||||||
133
kill.sh
Executable file
133
kill.sh
Executable file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/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 "$@"
|
||||||
124
mkdocs.yml
Normal file
124
mkdocs.yml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
site_name: Ripster
|
||||||
|
site_description: Halbautomatische Disc-Ripping-Plattform für DVDs & Blu-rays mit HandBrake-Encoding
|
||||||
|
site_author: Michael
|
||||||
|
site_url: https://mboehmlaender.github.io/ripster/
|
||||||
|
|
||||||
|
repo_name: ripster
|
||||||
|
repo_url: https://github.com/mboehmlaender/ripster
|
||||||
|
edit_uri: edit/main/docs/
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
language: de
|
||||||
|
palette:
|
||||||
|
- scheme: default
|
||||||
|
primary: deep purple
|
||||||
|
accent: purple
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-7
|
||||||
|
name: Dunkelmodus aktivieren
|
||||||
|
- scheme: slate
|
||||||
|
primary: deep purple
|
||||||
|
accent: purple
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-4
|
||||||
|
name: Hellmodus aktivieren
|
||||||
|
font:
|
||||||
|
text: Roboto
|
||||||
|
code: Roboto Mono
|
||||||
|
features:
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.tabs.sticky
|
||||||
|
- navigation.sections
|
||||||
|
- navigation.expand
|
||||||
|
- navigation.indexes
|
||||||
|
- navigation.top
|
||||||
|
- search.highlight
|
||||||
|
- search.suggest
|
||||||
|
- content.code.copy
|
||||||
|
- content.code.annotate
|
||||||
|
- content.tabs.link
|
||||||
|
- toc.integrate
|
||||||
|
icon:
|
||||||
|
repo: fontawesome/brands/github
|
||||||
|
logo: material/disc
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Home: index.md
|
||||||
|
- Erste Schritte:
|
||||||
|
- getting-started/index.md
|
||||||
|
- Voraussetzungen: getting-started/prerequisites.md
|
||||||
|
- Installation: getting-started/installation.md
|
||||||
|
- Konfiguration: getting-started/configuration.md
|
||||||
|
- Schnellstart: getting-started/quickstart.md
|
||||||
|
- Architektur:
|
||||||
|
- architecture/index.md
|
||||||
|
- Übersicht: architecture/overview.md
|
||||||
|
- Backend-Services: architecture/backend.md
|
||||||
|
- Frontend-Komponenten: architecture/frontend.md
|
||||||
|
- Datenbank: architecture/database.md
|
||||||
|
- Pipeline:
|
||||||
|
- pipeline/index.md
|
||||||
|
- Workflow & Zustände: pipeline/workflow.md
|
||||||
|
- Encode-Planung: pipeline/encoding.md
|
||||||
|
- Playlist-Analyse: pipeline/playlist-analysis.md
|
||||||
|
- API-Referenz:
|
||||||
|
- api/index.md
|
||||||
|
- Pipeline API: api/pipeline.md
|
||||||
|
- Settings API: api/settings.md
|
||||||
|
- History API: api/history.md
|
||||||
|
- WebSocket Events: api/websocket.md
|
||||||
|
- Konfiguration:
|
||||||
|
- configuration/index.md
|
||||||
|
- Alle Einstellungen: configuration/settings-reference.md
|
||||||
|
- Umgebungsvariablen: configuration/environment.md
|
||||||
|
- Deployment:
|
||||||
|
- deployment/index.md
|
||||||
|
- Entwicklungsumgebung: deployment/development.md
|
||||||
|
- Produktion: deployment/production.md
|
||||||
|
- Externe Tools:
|
||||||
|
- tools/index.md
|
||||||
|
- MakeMKV: tools/makemkv.md
|
||||||
|
- HandBrake: tools/handbrake.md
|
||||||
|
- MediaInfo: tools/mediainfo.md
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search:
|
||||||
|
lang: de
|
||||||
|
- minify:
|
||||||
|
minify_html: true
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- pymdownx.details
|
||||||
|
- pymdownx.superfences:
|
||||||
|
custom_fences:
|
||||||
|
- name: mermaid
|
||||||
|
class: mermaid
|
||||||
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
|
- pymdownx.highlight:
|
||||||
|
anchor_linenums: true
|
||||||
|
line_spans: __span
|
||||||
|
pygments_lang_class: true
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.snippets
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||||
|
- attr_list
|
||||||
|
- md_in_html
|
||||||
|
- tables
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
|
||||||
|
extra:
|
||||||
|
social:
|
||||||
|
- icon: fontawesome/brands/github
|
||||||
|
link: https://github.com/YOUR_GITHUB_USERNAME/ripster
|
||||||
|
version:
|
||||||
|
provider: mike
|
||||||
|
|
||||||
|
extra_css:
|
||||||
|
- stylesheets/extra.css
|
||||||
304
package-lock.json
generated
Normal file
304
package-lock.json
generated
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
{
|
||||||
|
"name": "ripster",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "ripster",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/concurrently": {
|
||||||
|
"version": "9.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
|
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "4.1.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"shell-quote": "1.8.3",
|
||||||
|
"supports-color": "8.1.1",
|
||||||
|
"tree-kill": "1.2.2",
|
||||||
|
"yargs": "17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"conc": "dist/bin/concurrently.js",
|
||||||
|
"concurrently": "dist/bin/concurrently.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tree-kill": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"tree-kill": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "ripster",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
"dev:frontend": "npm run dev --prefix frontend",
|
||||||
|
"start": "npm run start --prefix backend",
|
||||||
|
"build:frontend": "npm run build --prefix frontend"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
requirements-docs.txt
Normal file
4
requirements-docs.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mkdocs>=1.6.0
|
||||||
|
mkdocs-material>=9.5.0
|
||||||
|
mkdocs-minify-plugin>=0.8.0
|
||||||
|
pymdown-extensions>=10.7
|
||||||
157
start.sh
Executable file
157
start.sh
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
USE_NODE20_NPX=0
|
||||||
|
|
||||||
|
normalize_semver() {
|
||||||
|
local version="$1"
|
||||||
|
echo "${version#v}" | sed -E 's/[^0-9.].*$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
version_gte() {
|
||||||
|
local current required
|
||||||
|
local c_major c_minor c_patch r_major r_minor r_patch
|
||||||
|
|
||||||
|
current=$(normalize_semver "$1")
|
||||||
|
required=$(normalize_semver "$2")
|
||||||
|
|
||||||
|
IFS='.' read -r c_major c_minor c_patch <<< "$current"
|
||||||
|
IFS='.' read -r r_major r_minor r_patch <<< "$required"
|
||||||
|
|
||||||
|
c_major=${c_major:-0}
|
||||||
|
c_minor=${c_minor:-0}
|
||||||
|
c_patch=${c_patch:-0}
|
||||||
|
r_major=${r_major:-0}
|
||||||
|
r_minor=${r_minor:-0}
|
||||||
|
r_patch=${r_patch:-0}
|
||||||
|
|
||||||
|
if (( c_major > r_major )); then return 0; fi
|
||||||
|
if (( c_major < r_major )); then return 1; fi
|
||||||
|
if (( c_minor > r_minor )); then return 0; fi
|
||||||
|
if (( c_minor < r_minor )); then return 1; fi
|
||||||
|
if (( c_patch >= r_patch )); then return 0; fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
load_nvm_if_available() {
|
||||||
|
if command -v nvm >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
if [[ -s "$NVM_DIR/nvm.sh" ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$NVM_DIR/nvm.sh" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v nvm >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
try_use_project_node() {
|
||||||
|
local project_root="$1"
|
||||||
|
local required_version="$2"
|
||||||
|
local current_version
|
||||||
|
|
||||||
|
current_version=$(node -v 2>/dev/null || true)
|
||||||
|
if [[ -n "$current_version" ]] && version_gte "$current_version" "$required_version"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! load_nvm_if_available; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pushd "$project_root" >/dev/null
|
||||||
|
if [[ -f ".nvmrc" ]]; then
|
||||||
|
nvm use --silent >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
current_version=$(node -v 2>/dev/null || true)
|
||||||
|
if [[ -n "$current_version" ]] && version_gte "$current_version" "$required_version"; then
|
||||||
|
echo "Projekt-Node aktiviert: ${current_version}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_minimum_node_version() {
|
||||||
|
local project_root="$1"
|
||||||
|
local min_version="20.19.0"
|
||||||
|
local current_version
|
||||||
|
local switched=0
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "Node.js wurde nicht gefunden. Bitte Node.js >= ${min_version} installieren."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if try_use_project_node "$project_root" "$min_version"; then
|
||||||
|
switched=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
current_version=$(node -v 2>/dev/null || true)
|
||||||
|
if [[ -z "$current_version" ]]; then
|
||||||
|
echo "Konnte die Node.js-Version nicht ermitteln."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! version_gte "$current_version" "$min_version"; then
|
||||||
|
if (( switched == 0 )) && load_nvm_if_available; then
|
||||||
|
echo "Node.js ${current_version} erkannt. Versuche automatische Aktivierung von ${min_version} via nvm ..."
|
||||||
|
nvm install "${min_version}" >/dev/null
|
||||||
|
nvm use "${min_version}" >/dev/null
|
||||||
|
current_version=$(node -v 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! version_gte "$current_version" "$min_version"; then
|
||||||
|
if command -v npx >/dev/null 2>&1; then
|
||||||
|
echo "Node.js ${current_version} erkannt. Nutze node@20 Fallback via npx."
|
||||||
|
if npx -y node@20 -v >/dev/null 2>&1; then
|
||||||
|
USE_NODE20_NPX=1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Node.js ${current_version} erkannt. Erforderlich: >= ${min_version}."
|
||||||
|
echo "Projektlokal:"
|
||||||
|
echo "nvm install ${min_version} && nvm use"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_npm() {
|
||||||
|
if (( USE_NODE20_NPX == 1 )); then
|
||||||
|
npx -y node@20 "$(command -v npm)" "$@"
|
||||||
|
else
|
||||||
|
npm "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_minimum_node_version "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ ! -d "$ROOT_DIR/node_modules" ]]; then
|
||||||
|
echo "Installiere Abhaengigkeiten in $ROOT_DIR ..."
|
||||||
|
run_npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$ROOT_DIR/backend/node_modules" ]]; then
|
||||||
|
echo "Installiere Abhaengigkeiten in $ROOT_DIR/backend ..."
|
||||||
|
run_npm --prefix backend install
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$ROOT_DIR/frontend/node_modules" ]]; then
|
||||||
|
echo "Installiere Abhaengigkeiten in $ROOT_DIR/frontend ..."
|
||||||
|
run_npm --prefix frontend install
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starte Ripster Dev-Umgebung ..."
|
||||||
|
if (( USE_NODE20_NPX == 1 )); then
|
||||||
|
exec npx -y node@20 "$(command -v npm)" run dev
|
||||||
|
else
|
||||||
|
exec npm run dev
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user