0.12.0-17 Misc fixes
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-03-30 19:15:23 +00:00
parent 5284dd2a43
commit c67ac593de
29 changed files with 1596 additions and 117 deletions
+35 -2
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- main - main
- dev
paths: paths:
- 'docs/**' - 'docs/**'
- 'mkdocs.yml' - 'mkdocs.yml'
@@ -45,8 +46,40 @@ jobs:
- name: Install MkDocs and dependencies - name: Install MkDocs and dependencies
run: pip install -r requirements-docs.txt run: pip install -r requirements-docs.txt
- name: Build MkDocs site - name: Build MkDocs site (main -> /, dev -> /dev)
run: mkdocs build --strict --verbose env:
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Ensure both refs are available locally.
git fetch origin main dev
MAIN_REF="origin/main"
DEV_REF="origin/dev"
if [ "${REF_NAME}" = "main" ]; then
MAIN_REF="${SHA}"
fi
if [ "${REF_NAME}" = "dev" ]; then
DEV_REF="${SHA}"
fi
# Build each branch in its own worktree and merge into one Pages artifact:
# - main docs at site/
# - dev docs at site/dev/
rm -rf .worktrees site
mkdir -p .worktrees
git worktree add --detach .worktrees/main "${MAIN_REF}"
git worktree add --detach .worktrees/dev "${DEV_REF}"
mkdocs build --strict --verbose \
--config-file .worktrees/main/mkdocs.yml \
--site-dir site
mkdocs build --strict --verbose \
--config-file .worktrees/dev/mkdocs.yml \
--site-dir site/dev
- name: Upload Pages artifact - name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
+55 -17
View File
@@ -1,35 +1,44 @@
# Ripster # Ripster
Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit MakeMKV + HandBrake inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie. Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping, Audiobook-Verarbeitung und Datei-Konvertierung. Plugin-basierte Architektur mit MakeMKV + HandBrake + FFmpeg, inklusive Metadaten-Auswahl, Track-Review, Queue, Skripten/Ketten und Job-Historie.
--- ---
## Statushinweis: CD-Ripping (experimentell)
- Die grundlegende CD-Ripping-Funktion im ersten Durchgang funktioniert.
- Das Frontend ist dafür noch nicht vollständig angepasst.
- Funktionen wie Restart, bestimmte Ansichten und Folge-Workflows können aktuell eingeschränkt sein oder fehlschlagen.
- CD-Ripping wird weiterentwickelt und sollte derzeit als experimentell betrachtet werden.
## Was Ripster kann ## Was Ripster kann
### Disc-Ripping & Encoding
- Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket) - Disc-Erkennung mit Pipeline-Status in Echtzeit (WebSocket)
- Medienprofil-Erkennung (Blu-ray/DVD/Sonstiges) aus Device-/Filesystem-Heuristik - Medienprofil-Erkennung (Blu-ray/DVD/CD/Sonstiges) aus Device-/Filesystem-Heuristik
- Metadaten-Suche und Zuordnung über OMDb - Metadaten-Suche und Zuordnung über OMDb
- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings - MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings
- HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args - HandBrake-Review und Encoding mit Track-Auswahl, User-Presets, Extra-Args
- **Audio-CD-Ripping** mit cdparanoia + Encoding nach FLAC/MP3/Opus/Vorbis (experimentell)
### Audiobook-Verarbeitung
- **AAX/Audible-Dateien** verarbeiten (Activation Bytes, DRM-Handling)
- FFmpeg-basiertes Kapitel-Splitting
- Ausgabeformate: M4B, MP3, FLAC mit Metadaten
- MusicBrainz-Metadaten-Lookup
### Datei-Converter
- **Generische Audio/Video-Dateien** konvertieren (MKV, MP4, FLAC, MP3, u. v. m.)
- Datei-Explorer mit Upload, Umbenennen, Verschieben, Löschen
- Automatischer Verzeichnis-Scan (Polling konfigurierbar)
### Automatisierung & Verwaltung
- Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten) - Pre- und Post-Encode-Ausführungen (Skripte und/oder Skript-Ketten)
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`) - Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
- Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung) - Cron-Jobs für Skripte/Ketten (inkl. Logs und manueller Auslösung)
- **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen (Skripte, Ketten, Cron, Tasks) in Echtzeit im Dashboard - **Aktivitäts-Tracking**: Laufende und abgeschlossene Aktionen in Echtzeit im Dashboard
- Download-Queue: Ausgabedateien als ZIP herunterladen
- Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import - Historie mit Re-Encode, Review-Neustart, File-/Job-Löschung und Orphan-Import
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard - Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard
## Tech-Stack ## Tech-Stack
- Backend: Node.js, Express, SQLite, WebSocket (`ws`) - Backend: Node.js, Express, SQLite, WebSocket (`ws`) **Plugin-Architektur**
- Frontend: React, Vite, PrimeReact - Frontend: React, Vite, PrimeReact
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo` - Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia`
## Dokumentation ## Dokumentation
@@ -39,7 +48,8 @@ Ripster ist eine lokale Web-Anwendung für halbautomatisches Disc-Ripping mit Ma
- Debian 11/12 oder Ubuntu 22.04/24.04 - Debian 11/12 oder Ubuntu 22.04/24.04
- root-Rechte + Internetzugang - root-Rechte + Internetzugang
- optisches Laufwerk (oder gemountete Quelle) - optisches Laufwerk (oder gemountete Quelle) für Disc-Ripping
- Netzwerk-Zugang für Audiobook-Upload und Datei-Converter optional
## Schnellstart (`install.sh`) ## Schnellstart (`install.sh`)
@@ -59,7 +69,7 @@ curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.
`install.sh` übernimmt u. a.: `install.sh` übernimmt u. a.:
- Node.js 20 (falls nötig) - Node.js 20 (falls nötig)
- Basistools inkl. `mediainfo` - Basistools inkl. `mediainfo`, `ffmpeg`, `ffprobe`
- CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`) - CD-Ripping-Tools (`cdparanoia`, `flac`, `lame`, `opus-tools`, `vorbis-tools`)
- MakeMKV - MakeMKV
- HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding) - HandBrake CLI (Auswahl Standard/CPU oder GPU/NVDEC-Binary für HW-Encoding)
@@ -89,9 +99,12 @@ sudo bash install.sh --help # Hilfe anzeigen
Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert: Die meisten Einstellungen werden in der App unter `Settings` gepflegt und in SQLite gespeichert:
- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Sonstiges-Varianten) - Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Audiobook-Varianten)
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando` - Pfade Converter: `Converter Raw-Ordner`, `Converter Ausgabe (Video)`, `Converter Ausgabe (Audio)`
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`, `FFmpeg Kommando`, `FFprobe Kommando`
- Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat) - Profile: medientyp-spezifische Felder für Blu-ray/DVD/Sonstiges (z. B. Preset, Zusatzargumente, Ausgabeformat)
- Audiobook: Output-Template, RAW-Template
- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates
- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)` - Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)`
- Benachrichtigungen: PushOver - Benachrichtigungen: PushOver
@@ -127,13 +140,14 @@ Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migr
ripster/ ripster/
backend/ backend/
src/ src/
plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter)
routes/ routes/
services/ services/
db/ db/
utils/ utils/
frontend/ frontend/
src/ src/
pages/ pages/ # Dashboard, Settings, History, Converter, Downloads, Database
components/ components/
api/ api/
db/schema.sql db/schema.sql
@@ -165,6 +179,30 @@ ripster/
- `POST /api/pipeline/queue/entry` - `POST /api/pipeline/queue/entry`
- `DELETE /api/pipeline/queue/entry/:entryId` - `DELETE /api/pipeline/queue/entry/:entryId`
**Converter**
- `GET /api/converter/tree`
- `GET /api/converter/browse`
- `POST /api/converter/scan`
- `POST /api/converter/create-jobs`
- `POST /api/converter/upload`
- `POST /api/converter/jobs/from-selection`
- `GET /api/converter/jobs`
- `GET /api/converter/jobs/:jobId`
- `POST /api/converter/jobs/:jobId/start`
- `POST /api/converter/jobs/:jobId/cancel`
- `DELETE /api/converter/jobs/:jobId`
- `DELETE /api/converter/files`
- `POST /api/converter/files/rename`
- `POST /api/converter/files/move`
- `POST /api/converter/files/folder`
**Downloads**
- `GET /api/downloads`
- `GET /api/downloads/summary`
- `POST /api/downloads/history/:jobId`
- `GET /api/downloads/:id/file`
- `DELETE /api/downloads/:id`
**History** **History**
- `GET /api/history` - `GET /api/history`
- `GET /api/history/:id` - `GET /api/history/:id`
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.12.0-16", "version": "0.12.0-17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.12.0-16", "version": "0.12.0-17",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.12.0-16", "version": "0.12.0-17",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+19
View File
@@ -898,6 +898,19 @@ async function migrateSettingsSchemaMetadata(db) {
}); });
} }
const externalStorageDescription = 'Wird links unter "Freie Speicher" angezeigt.';
const externalStorageDescResult = await db.run(
`UPDATE settings_schema
SET description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'external_storage_paths' AND description != ?`,
[externalStorageDescription, externalStorageDescription]
);
if (externalStorageDescResult?.changes > 0) {
logger.info('migrate:settings-schema-external-storage-description-updated', {
key: 'external_storage_paths'
});
}
// Migrate raw_dir_cd_owner label // Migrate raw_dir_cd_owner label
await db.run( await db.run(
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP `UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
@@ -1034,6 +1047,12 @@ async function migrateSettingsSchemaMetadata(db) {
); );
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]')`);
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{\"path\":\"/dev/sr0\",\"makemkvIndex\":0},{\"path\":\"/dev/sr1\",\"makemkvIndex\":1}]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)` VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{\"path\":\"/dev/sr0\",\"makemkvIndex\":0},{\"path\":\"/dev/sr1\",\"makemkvIndex\":1}]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)`
+119 -4
View File
@@ -26,7 +26,8 @@ const RELEVANT_SETTINGS_KEYS = new Set([
'movie_dir', 'movie_dir',
'movie_dir_bluray', 'movie_dir_bluray',
'movie_dir_dvd', 'movie_dir_dvd',
'log_dir' 'log_dir',
'external_storage_paths'
]); ]);
function nowIso() { function nowIso() {
@@ -57,6 +58,52 @@ function normalizePathSetting(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
function parseExternalStoragePaths(rawValue) {
const normalized = String(rawValue || '').trim();
if (!normalized) {
return [];
}
let parsed = [];
try {
parsed = JSON.parse(normalized);
} catch (_error) {
return [];
}
const list = Array.isArray(parsed) ? parsed : [];
const unique = [];
const seen = new Set();
for (const item of list) {
const pathCandidate = typeof item === 'string'
? item
: (item && typeof item === 'object' ? item.path : '');
const labelCandidate = item && typeof item === 'object'
? (item.name ?? item.label ?? '')
: '';
const pathValue = normalizePathSetting(pathCandidate);
const labelValue = normalizePathSetting(labelCandidate);
if (!pathValue || seen.has(pathValue)) {
continue;
}
seen.add(pathValue);
unique.push({
path: pathValue,
label: labelValue || null
});
}
return unique;
}
function isRootPath(inputPath) {
const normalized = String(inputPath || '').trim();
if (!normalized) {
return false;
}
const resolved = path.resolve(normalized);
return resolved === path.parse(resolved).root;
}
function clampIntervalMs(rawValue) { function clampIntervalMs(rawValue) {
const parsed = Number(rawValue); const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
@@ -405,13 +452,16 @@ class HardwareMonitorService {
const cdRawPath = normalizePathSetting(cd?.raw_dir); const cdRawPath = normalizePathSetting(cd?.raw_dir);
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir); const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir); const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
const externalStoragePaths = parseExternalStoragePaths(sourceMap.external_storage_paths);
const monitoredPaths = []; const monitoredPaths = [];
const addPath = (key, label, monitoredPath) => { const addPath = (key, label, monitoredPath, options = {}) => {
monitoredPaths.push({ monitoredPaths.push({
key, key,
label, label,
path: normalizePathSetting(monitoredPath) path: normalizePathSetting(monitoredPath),
hideWhenUnavailable: Boolean(options?.hideWhenUnavailable),
requireDedicatedMount: Boolean(options?.requireDedicatedMount)
}); });
}; };
@@ -431,6 +481,19 @@ class HardwareMonitorService {
} }
addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir); addPath('log_dir', 'Log-Verzeichnis', sourceMap.log_dir);
externalStoragePaths.forEach((externalEntry, index) => {
const externalPath = normalizePathSetting(externalEntry?.path);
const externalLabel = normalizePathSetting(externalEntry?.label) || `Externer Speicher ${index + 1}`;
addPath(
`external_storage_path_${index}`,
externalLabel,
externalPath,
{
hideWhenUnavailable: true,
requireDedicatedMount: true
}
);
});
return monitoredPaths; return monitoredPaths;
} }
@@ -866,7 +929,10 @@ class HardwareMonitorService {
async collectStorageMetrics() { async collectStorageMetrics() {
const list = []; const list = [];
for (const entry of this.monitoredPaths) { for (const entry of this.monitoredPaths) {
list.push(await this.collectStorageForPath(entry)); const metric = await this.collectStorageForPath(entry);
if (metric && !metric.hidden) {
list.push(metric);
}
} }
return list; return list;
} }
@@ -897,7 +963,24 @@ class HardwareMonitorService {
const key = String(entry?.key || ''); const key = String(entry?.key || '');
const label = String(entry?.label || key || 'Pfad'); const label = String(entry?.label || key || 'Pfad');
const rawPath = String(entry?.path || '').trim(); const rawPath = String(entry?.path || '').trim();
const hideWhenUnavailable = Boolean(entry?.hideWhenUnavailable);
const requireDedicatedMount = Boolean(entry?.requireDedicatedMount);
const hiddenResult = (base = {}) => ({
key,
label,
hidden: true,
...base
});
if (!rawPath) { if (!rawPath) {
if (hideWhenUnavailable) {
return hiddenResult({
path: null,
queryPath: null,
exists: false
});
}
return { return {
key, key,
label, label,
@@ -919,6 +1002,13 @@ class HardwareMonitorService {
const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath); const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath);
if (!queryPath) { if (!queryPath) {
if (hideWhenUnavailable) {
return hiddenResult({
path: resolvedPath,
queryPath: null,
exists: false
});
}
return { return {
key, key,
label, label,
@@ -942,6 +1032,13 @@ class HardwareMonitorService {
}); });
const parsed = parseDfStats(stdout); const parsed = parseDfStats(stdout);
if (!parsed) { if (!parsed) {
if (hideWhenUnavailable) {
return hiddenResult({
path: resolvedPath,
queryPath,
exists
});
}
return { return {
key, key,
label, label,
@@ -957,6 +1054,16 @@ class HardwareMonitorService {
error: 'Dateisystemdaten konnten nicht geparst werden.' error: 'Dateisystemdaten konnten nicht geparst werden.'
}; };
} }
const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint;
const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint);
const shouldShowExternal = exists || hasDedicatedMount;
if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) {
return hiddenResult({
path: resolvedPath,
queryPath,
exists
});
}
return { return {
key, key,
@@ -965,10 +1072,18 @@ class HardwareMonitorService {
queryPath, queryPath,
exists, exists,
...parsed, ...parsed,
mountPoint: mountPoint || null,
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`, note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
error: null error: null
}; };
} catch (error) { } catch (error) {
if (hideWhenUnavailable) {
return hiddenResult({
path: resolvedPath,
queryPath,
exists
});
}
return { return {
key, key,
label, label,
+69 -18
View File
@@ -37,6 +37,22 @@ function sleep(ms) {
} }
class OmdbService { class OmdbService {
mapSearchResults(data) {
if (!data || typeof data !== 'object') {
return [];
}
if (data.Response === 'False' || !Array.isArray(data.Search)) {
return [];
}
return data.Search.map((item) => ({
title: item.Title,
year: item.Year,
imdbId: item.imdbID,
type: item.Type,
poster: item.Poster
}));
}
async requestJson(url, meta = {}, options = {}) { async requestJson(url, meta = {}, options = {}) {
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs); const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS))); const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
@@ -89,10 +105,26 @@ class OmdbService {
} }
async search(query) { async search(query) {
if (!query || query.trim().length === 0) { const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) {
return []; return [];
} }
logger.info('search:start', { query }); logger.info('search:start', { query: normalizedQuery });
// Allow direct IMDb-ID lookups in the search field (useful for History remapping).
const imdbLike = normalizedQuery.toLowerCase();
if (/^tt\d{6,12}$/.test(imdbLike)) {
const byId = await this.fetchByImdbId(imdbLike);
return byId
? [{
title: byId.title,
year: byId.year != null ? String(byId.year) : null,
imdbId: byId.imdbId,
type: byId.type,
poster: byId.poster
}]
: [];
}
const settings = await settingsService.getSettingsMap(); const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key; const apiKey = settings.omdb_api_key;
@@ -101,28 +133,47 @@ class OmdbService {
} }
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms); const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const type = settings.omdb_default_type || 'movie'; const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
const requestSearch = async (type = null) => {
const url = new URL(OMDB_BASE_URL); const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey); url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', query.trim()); url.searchParams.set('s', normalizedQuery);
if (type) {
url.searchParams.set('type', type); url.searchParams.set('type', type);
}
const data = await this.requestJson(
url,
{ query: normalizedQuery, action: 'search', type: type || null },
{ timeoutMs }
);
return {
data,
results: this.mapSearchResults(data)
};
};
const data = await this.requestJson(url, { query, action: 'search' }, { timeoutMs }); let { data, results } = await requestSearch(configuredType);
if (!data || typeof data !== 'object') { if (results.length === 0 && configuredType) {
logger.info('search:fallback-no-type', {
query: normalizedQuery,
configuredType,
response: data?.Response || null,
error: data?.Error || null
});
({ data, results } = await requestSearch(null));
}
if (results.length === 0) {
logger.warn('search:no-results', {
query: normalizedQuery,
response: data?.Response || null,
error: data?.Error || null,
configuredType
});
return []; return [];
} }
if (data.Response === 'False' || !Array.isArray(data.Search)) {
logger.warn('search:no-results', { query, response: data.Response, error: data.Error }); logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
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; return results;
} }
+132 -5
View File
@@ -5187,7 +5187,7 @@ class PipelineService extends EventEmitter {
...previousJobProgress, ...previousJobProgress,
state: previousJobProgress.state || this.snapshot.state, state: previousJobProgress.state || this.snapshot.state,
progress: previousJobProgress.progress ?? this.snapshot.progress ?? 0, progress: previousJobProgress.progress ?? this.snapshot.progress ?? 0,
eta: previousJobProgress.eta ?? this.snapshot.eta ?? null, eta: previousJobProgress.eta ?? null,
statusText: previousJobProgress.statusText ?? this.snapshot.statusText ?? null, statusText: previousJobProgress.statusText ?? this.snapshot.statusText ?? null,
context: { context: {
...(previousJobProgress.context && typeof previousJobProgress.context === 'object' ...(previousJobProgress.context && typeof previousJobProgress.context === 'object'
@@ -5434,6 +5434,54 @@ class PipelineService extends EventEmitter {
return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs); return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs);
} }
async resolveUsableRawInputForReadyJob(jobId, job, encodePlan = null) {
const plan = encodePlan && typeof encodePlan === 'object'
? encodePlan
: this.safeParseJson(job?.encode_plan_json);
const mkInfo = this.safeParseJson(job?.makemkv_info_json);
const mediaProfile = this.resolveMediaProfileForJob(job, {
makemkvInfo: mkInfo,
encodePlan: plan,
rawPath: job?.raw_path
});
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
const resolvedRawPath = this.resolveCurrentRawPathForSettings(
settings,
mediaProfile,
job?.raw_path
) || String(job?.raw_path || '').trim() || null;
if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) {
return {
mediaProfile,
resolvedRawPath,
inputPath: null,
hasUsableRawInput: false
};
}
let inputPath = null;
try {
if (hasBluRayBackupStructure(resolvedRawPath)) {
inputPath = resolvedRawPath;
} else {
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
inputPath = findPreferredRawInput(resolvedRawPath, {
playlistAnalysis: playlistDecision.playlistAnalysis,
selectedPlaylistId: playlistDecision.selectedPlaylist
})?.path || null;
}
} catch (_error) {
inputPath = null;
}
return {
mediaProfile,
resolvedRawPath,
inputPath,
hasUsableRawInput: Boolean(inputPath)
};
}
async alignRawFolderJobId(rawPath, targetJobId) { async alignRawFolderJobId(rawPath, targetJobId) {
const sourceRawPath = String(rawPath || '').trim(); const sourceRawPath = String(rawPath || '').trim();
const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId); const normalizedTargetJobId = this.normalizeQueueJobId(targetJobId);
@@ -5856,6 +5904,13 @@ class PipelineService extends EventEmitter {
}; };
} }
_broadcastPipelineStateChanged() {
const snapshotPayload = this.getSnapshot();
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
this.emit('stateChanged', snapshotPayload);
return snapshotPayload;
}
_setCdDriveState(devicePath, updates) { _setCdDriveState(devicePath, updates) {
const existing = this.cdDrives.get(devicePath) || { const existing = this.cdDrives.get(devicePath) || {
devicePath, devicePath,
@@ -6059,6 +6114,7 @@ class PipelineService extends EventEmitter {
source: owner.source, source: owner.source,
reason: owner.reason reason: owner.reason
}); });
this._broadcastPipelineStateChanged();
return true; return true;
} }
@@ -6080,6 +6136,7 @@ class PipelineService extends EventEmitter {
jobId: targetJobId, jobId: targetJobId,
reason: String(options?.reason || '').trim() || null reason: String(options?.reason || '').trim() || null
}); });
this._broadcastPipelineStateChanged();
return true; return true;
} }
@@ -7599,8 +7656,18 @@ class PipelineService extends EventEmitter {
async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) { async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) {
const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId; const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId;
const previousJobProgress = effectiveJobId != null
? (this.jobProgress.get(effectiveJobId) || {})
: null;
const effectiveProgress = percent ?? this.snapshot.progress; const effectiveProgress = percent ?? this.snapshot.progress;
const effectiveEta = eta ?? this.snapshot.eta; let effectiveEta;
if (eta !== undefined) {
effectiveEta = eta;
} else if (previousJobProgress && previousJobProgress.eta !== undefined) {
effectiveEta = previousJobProgress.eta ?? null;
} else {
effectiveEta = this.snapshot.eta ?? null;
}
const effectiveStatusText = statusText ?? this.snapshot.statusText; const effectiveStatusText = statusText ?? this.snapshot.statusText;
const progressOptions = options && typeof options === 'object' ? options : {}; const progressOptions = options && typeof options === 'object' ? options : {};
const contextPatch = progressOptions.contextPatch && typeof progressOptions.contextPatch === 'object' const contextPatch = progressOptions.contextPatch && typeof progressOptions.contextPatch === 'object'
@@ -7610,7 +7677,6 @@ class PipelineService extends EventEmitter {
// Update per-job progress so concurrent jobs don't overwrite each other. // Update per-job progress so concurrent jobs don't overwrite each other.
if (effectiveJobId != null) { if (effectiveJobId != null) {
const previousJobProgress = this.jobProgress.get(effectiveJobId) || {};
const mergedContext = contextPatch const mergedContext = contextPatch
? { ? {
...(previousJobProgress.context && typeof previousJobProgress.context === 'object' ...(previousJobProgress.context && typeof previousJobProgress.context === 'object'
@@ -9926,7 +9992,13 @@ class PipelineService extends EventEmitter {
// Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode. // Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode.
const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase(); const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase();
const willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip); let willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip);
if (willRipFirst) {
const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, preloadedJob, jobEncodePlan);
if (rawReuse.hasUsableRawInput) {
willRipFirst = false;
}
}
if (willRipFirst) { if (willRipFirst) {
return this.startPreparedJob(jobId, { ...options, immediate: true }); return this.startPreparedJob(jobId, { ...options, immediate: true });
} }
@@ -9999,6 +10071,52 @@ class PipelineService extends EventEmitter {
} }
if (isPreRipReadyState) { if (isPreRipReadyState) {
const rawReuse = await this.resolveUsableRawInputForReadyJob(jobId, job, encodePlanForReadyState);
if (rawReuse.hasUsableRawInput) {
const updatePayload = {
status: 'ENCODING',
last_state: 'ENCODING',
error_message: null,
end_time: null
};
if (
rawReuse.resolvedRawPath
&& normalizeComparablePath(rawReuse.resolvedRawPath) !== normalizeComparablePath(job.raw_path)
) {
updatePayload.raw_path = rawReuse.resolvedRawPath;
}
if (
rawReuse.inputPath
&& normalizeComparablePath(rawReuse.inputPath) !== normalizeComparablePath(job.encode_input_path)
) {
updatePayload.encode_input_path = rawReuse.inputPath;
}
await historyService.updateJob(jobId, updatePayload);
await historyService.appendLog(
jobId,
'SYSTEM',
`Pre-Rip Auswahl erkannt, nutzbares RAW gefunden (${rawReuse.resolvedRawPath}). Starte Encode direkt aus RAW; Laufwerk wird nicht angesprochen.`
);
this.startEncodingFromPrepared(jobId).catch((error) => {
logger.error('startPreparedJob:encode-background-from-prerip-raw-failed', {
jobId,
error: errorToMeta(error)
});
if (error?.jobAlreadyFailed) {
return;
}
this.failJob(jobId, 'ENCODING', error).catch((failError) => {
logger.error('startPreparedJob:encode-background-from-prerip-raw-failJob-failed', {
jobId,
error: errorToMeta(failError)
});
});
});
return { started: true, stage: 'ENCODING', reusedRaw: true };
}
await historyService.updateJob(jobId, { await historyService.updateJob(jobId, {
status: 'RIPPING', status: 'RIPPING',
last_state: 'RIPPING', last_state: 'RIPPING',
@@ -12864,6 +12982,15 @@ class PipelineService extends EventEmitter {
} }
const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null); const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null);
if (devicePath) { if (devicePath) {
const existingLock = this._getDriveLockByPath(devicePath);
const existingLockJobId = Number(existingLock?.jobId || existingLock?.owner?.jobId || 0) || null;
const lockedByOtherJob = (
(diskDetectionService.isDeviceLocked(devicePath) || Boolean(existingLock))
&& existingLockJobId !== Number(jobId)
);
if (lockedByOtherJob) {
throw this._buildDriveLockedError(devicePath, existingLock);
}
this._acquireDriveLockForJob(devicePath, jobId, { this._acquireDriveLockForJob(devicePath, jobId, {
stage: 'RIPPING', stage: 'RIPPING',
source: 'MAKEMKV_RIP', source: 'MAKEMKV_RIP',
@@ -14420,7 +14547,7 @@ class PipelineService extends EventEmitter {
} else if (detail) { } else if (detail) {
const jobEntry = this.jobProgress.get(Number(normalizedJobId)); const jobEntry = this.jobProgress.get(Number(normalizedJobId));
const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0); const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0);
const currentEta = jobEntry?.eta ?? this.snapshot.eta; const currentEta = jobEntry?.eta ?? runInfo.eta ?? null;
const statusText = composeStatusText(stage, currentProgress, runInfo.lastDetail); const statusText = composeStatusText(stage, currentProgress, runInfo.lastDetail);
void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId); void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId);
} }
+4
View File
@@ -269,6 +269,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120); VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]');
-- Monitoring -- Monitoring
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('hardware_monitoring_enabled', 'Monitoring', 'Hardware Monitoring aktiviert', 'boolean', 1, 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).', 'true', '[]', '{}', 130); VALUES ('hardware_monitoring_enabled', 'Monitoring', 'Hardware Monitoring aktiviert', 'boolean', 1, 'Master-Schalter: aktiviert/deaktiviert das komplette Hardware-Monitoring (Polling + Berechnung + WebSocket-Updates).', 'true', '[]', '{}', 130);
+290
View File
@@ -0,0 +1,290 @@
# Converter API
Endpunkte für den Datei-Converter: Datei-Explorer, Upload, Job-Verwaltung.
Basis-Pfad: `/api/converter`
---
## Scan & Explorer
### `GET /api/converter/tree`
Vollständiger Verzeichnisbaum des `converter_raw_dir` (FS-basiert, keine DB).
**Response:**
```json
{
"tree": [
{
"name": "filme",
"relPath": "filme",
"isDir": true,
"children": [
{
"name": "film.mkv",
"relPath": "filme/film.mkv",
"isDir": false,
"size": 10485760
}
]
}
]
}
```
---
### `GET /api/converter/browse?parent=relPath`
DB-basierter Datei-Explorer. Gibt Einträge für einen Unterordner zurück.
**Query-Parameter:**
| Parameter | Typ | Beschreibung |
|---|---|---|
| `parent` | string | Relativer Pfad zum Unterordner (leer = Root) |
**Response:**
```json
{
"entries": [
{
"id": 1,
"rel_path": "filme/film.mkv",
"is_dir": false,
"size": 10485760,
"job_id": null
}
],
"rawDir": "/data/output/converter-raw"
}
```
---
### `POST /api/converter/scan`
Manuellen Scan des `converter_raw_dir` auslösen. Aktualisiert `converter_scan_entries` in der DB.
**Response:**
```json
{ "result": { "added": 3, "removed": 1 } }
```
---
## Jobs erstellen
### `POST /api/converter/jobs/from-selection`
Jobs aus im Datei-Explorer ausgewählten Dateien erstellen.
**Body:**
```json
{
"relPaths": ["filme/film.mkv", "musik/track.flac"],
"audioMode": "individual"
}
```
| Feld | Typ | Beschreibung |
|---|---|---|
| `relPaths` | string[] | Relative Pfade der ausgewählten Dateien |
| `audioMode` | string | `individual` (ein Job pro Datei) oder `shared` (ein gemeinsamer Job) |
**Response:**
```json
{ "jobs": [{ "id": 42, "status": "READY_TO_START" }] }
```
---
### `POST /api/converter/create-jobs`
Jobs aus DB-Scan-Einträgen erstellen.
**Body:**
```json
{
"entries": [
{ "relPath": "filme/film.mkv", "converterMediaType": "video" }
]
}
```
---
### `POST /api/converter/upload`
Dateien hochladen (Multipart, max. 50 Dateien).
**Form-Felder:**
| Feld | Typ | Beschreibung |
|---|---|---|
| `files` | File[] | Hochzuladende Dateien |
| `folderName` | string | (Optional) Ziel-Unterordner |
**Response:**
```json
{
"folders": [{ "folderRelPath": "upload-2026-03-30", "fileCount": 2 }]
}
```
---
## Job-Verwaltung
### `GET /api/converter/jobs`
Alle Converter-Jobs zurückgeben.
**Response:**
```json
{ "jobs": [{ "id": 42, "status": "READY_TO_START", "title": "film.mkv" }] }
```
---
### `GET /api/converter/jobs/:jobId`
Einzelnen Converter-Job abrufen.
---
### `POST /api/converter/jobs/:jobId/config`
Konfigurationsentwurf für einen Job speichern (persistiert in `encode_plan_json`).
**Body:** Partielles Konfig-Objekt (Ausgabeformat, Presets, Metadaten, Track-Auswahl).
---
### `POST /api/converter/jobs/:jobId/assign-files`
Dateien einem bestehenden (noch nicht gestarteten) Job hinzufügen.
**Body:**
```json
{ "relPaths": ["musik/track2.flac"] }
```
---
### `POST /api/converter/jobs/:jobId/remove-file`
Datei aus einem Job entfernen (per relativer Pfad).
**Body:**
```json
{ "relPath": "musik/track2.flac" }
```
---
### `POST /api/converter/jobs/:jobId/remove-input`
Datei aus einem Job entfernen (per absolutem Eingabepfad).
**Body:**
```json
{ "inputPath": "/data/output/converter-raw/musik/track2.flac" }
```
---
### `POST /api/converter/jobs/:jobId/start`
Job mit finaler Konfiguration starten.
**Body:**
```json
{
"converterMediaType": "video",
"outputFormat": "mkv",
"userPreset": "H.264 MKV 1080p30",
"trackSelection": {},
"handBrakeTitleId": 1,
"audioFormatOptions": {}
}
```
---
### `POST /api/converter/jobs/:jobId/cancel`
Laufenden Job abbrechen.
---
### `DELETE /api/converter/jobs/:jobId`
Job aus der DB löschen.
---
## Datei-Operationen
Alle Datei-Operationen arbeiten direkt auf dem Dateisystem (ohne DB-Aktualisierung). Ein anschließender Scan-Aufruf synchronisiert die DB.
### `DELETE /api/converter/files`
Datei oder Ordner löschen.
**Body:**
```json
{ "relPath": "filme/film.mkv" }
```
---
### `POST /api/converter/files/rename`
Datei oder Ordner umbenennen.
**Body:**
```json
{ "relPath": "filme/film.mkv", "newName": "film-neu.mkv" }
```
---
### `POST /api/converter/files/move`
Datei oder Ordner verschieben.
**Body:**
```json
{ "relPath": "filme/film.mkv", "targetParentRelPath": "archiv" }
```
`targetParentRelPath = ""` verschiebt in das Root-Verzeichnis.
---
### `POST /api/converter/files/folder`
Neuen Ordner anlegen.
**Body:**
```json
{ "parentRelPath": "filme", "name": "neu" }
```
+143
View File
@@ -0,0 +1,143 @@
# Downloads API
Endpunkte für die Download-Queue. Ausgabedateien aus der Job-Historie können als ZIP heruntergeladen werden.
Basis-Pfad: `/api/downloads`
---
## `GET /api/downloads`
Liste aller Download-Einträge plus Zusammenfassung.
**Response:**
```json
{
"items": [
{
"id": "dl-42-raw",
"jobId": 42,
"target": "raw",
"status": "ready",
"archiveName": "Job_42_raw.zip",
"outputPath": null,
"createdAt": "2026-03-30T10:00:00.000Z",
"updatedAt": "2026-03-30T10:01:00.000Z",
"errorMessage": null
}
],
"summary": {
"total": 3,
"pending": 1,
"processing": 0,
"ready": 1,
"failed": 1
}
}
```
**Status-Werte:**
| Status | Beschreibung |
|---|---|
| `pending` | In der Queue, noch nicht gestartet |
| `processing` | ZIP wird gerade erstellt |
| `ready` | ZIP fertig, Download verfügbar |
| `failed` | Fehler bei der Erstellung |
---
## `GET /api/downloads/summary`
Nur die Zusammenfassung der Download-Queue (ohne Item-Liste).
**Response:**
```json
{
"summary": {
"total": 3,
"pending": 1,
"processing": 0,
"ready": 1,
"failed": 1
}
}
```
---
## `POST /api/downloads/history/:jobId`
Job-Ausgabe in die Download-Queue einreihen.
**Parameter:**
| Parameter | Typ | Beschreibung |
|---|---|---|
| `jobId` | number | ID des Jobs in der Historie |
**Body:**
```json
{
"target": "raw",
"outputPath": null
}
```
| Feld | Typ | Default | Beschreibung |
|---|---|---|---|
| `target` | string | `raw` | `raw` (RAW-Dateien) oder `movie` (Ausgabedateien) |
| `outputPath` | string | `null` | Optionaler expliziter Ausgabepfad |
**Response (201 Created):**
```json
{
"created": true,
"id": "dl-42-raw",
"status": "pending",
"summary": { "total": 1, "pending": 1, "processing": 0, "ready": 0, "failed": 0 }
}
```
Ist der Eintrag bereits vorhanden, wird `201` durch `200` ersetzt und `created: false` zurückgegeben.
---
## `GET /api/downloads/:id/file`
Fertige ZIP-Datei herunterladen.
**Parameter:**
| Parameter | Typ | Beschreibung |
|---|---|---|
| `id` | string | Download-ID (z. B. `dl-42-raw`) |
**Response:** Datei-Download (`Content-Disposition: attachment`).
Schlägt fehl mit `404`, wenn kein Download-Eintrag mit dieser ID existiert oder die Datei noch nicht bereit ist.
---
## `DELETE /api/downloads/:id`
Download-Eintrag löschen (auch fertige ZIP-Dateien werden vom Dateisystem entfernt).
**Response:**
```json
{
"ok": true,
"summary": { "total": 2, "pending": 0, "processing": 0, "ready": 2, "failed": 0 }
}
```
---
## WebSocket
Statusänderungen werden über `DOWNLOADS_UPDATED` in Echtzeit gemeldet. Vollständige Dokumentation: [WebSocket Events](websocket.md#downloads_updated).
+26 -2
View File
@@ -54,11 +54,35 @@ API-Prefix: `/api`
[:octicons-arrow-right-24: Cron API](crons.md) [:octicons-arrow-right-24: Cron API](crons.md)
- :material-lightning-bolt: **WebSocket Events** - :material-swap-horizontal: **Converter API**
--- ---
Pipeline-, Queue-, Disk-, Settings-, Cron- und Monitoring-Events. Datei-Explorer, Upload, Job-Verwaltung für den Converter.
[:octicons-arrow-right-24: Converter API](converter.md)
- :material-download: **Downloads API**
---
Download-Queue für Ausgabedateien aus der Job-Historie.
[:octicons-arrow-right-24: Downloads API](downloads.md)
- :material-lightning-bolt: **Runtime Activities API**
---
Laufende und abgeschlossene Aktivitäten (Skripte, Ketten, Cron, Tasks).
[:octicons-arrow-right-24: Runtime Activities](runtime-activities.md)
- :material-websocket: **WebSocket Events**
---
Pipeline-, Queue-, Disk-, Settings-, Cron-, Converter- und Download-Events.
[:octicons-arrow-right-24: WebSocket](websocket.md) [:octicons-arrow-right-24: WebSocket](websocket.md)
+42
View File
@@ -242,6 +242,48 @@ Wird ausgelöst, wenn eine Aktivität gestartet, aktualisiert oder abgeschlossen
Vollständige Feldbeschreibung: [Runtime Activities API](runtime-activities.md). Vollständige Feldbeschreibung: [Runtime Activities API](runtime-activities.md).
### CONVERTER_SCAN_UPDATE
Wird nach einem Scan des Converter-Eingangsordners gesendet (manuell oder per Polling).
```json
{
"type": "CONVERTER_SCAN_UPDATE",
"payload": {
"entryCount": 12
}
}
```
### DOWNLOADS_UPDATED
Wird bei jeder Statusänderung in der Download-Queue gesendet.
```json
{
"type": "DOWNLOADS_UPDATED",
"payload": {
"reason": "ready",
"item": {
"id": "dl-1",
"jobId": 42,
"status": "ready",
"archiveName": "Job_42_movie.zip",
"createdAt": "2026-03-30T10:00:00.000Z"
},
"summary": {
"total": 3,
"pending": 1,
"processing": 0,
"ready": 1,
"failed": 1
}
}
}
```
Mögliche `reason`-Werte: `queued`, `processing`, `ready`, `failed`, `deleted`.
--- ---
## Reconnect-Verhalten ## Reconnect-Verhalten
+104 -4
View File
@@ -1,6 +1,42 @@
# Backend-Services # Backend-Services
Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden. Das Backend ist in Services aufgeteilt, die von Express-Routen orchestriert werden. Seit v0.12.0 erfolgt die Medienverarbeitung über ein **Plugin-System**.
---
## Plugin-System (`src/plugins/`)
Ab v0.12.0 ist die Pipeline modular aufgebaut. Jeder Medientyp wird von einem eigenen Plugin verwaltet.
### Basisklassen
- **`PluginBase.js`** — Abstrakte Basisklasse `SourcePlugin` mit Lifecycle-Methoden
- **`PluginRegistry.js`** — Dynamische Plugin-Erkennung, prioritätsbasierte Auswahl, Settings-Schema-Aggregation
- **`PluginContext.js`** — Ausführungskontext (Logger, Callbacks, Signals) für Plugin-Aufrufe
### Plugin-Lifecycle
Jedes Plugin implementiert die folgenden Methoden:
| Methode | Beschreibung |
|---|---|
| `detect(discInfo)` | Medientyp-Erkennung |
| `analyze(devicePath, job, ctx)` | Analyse / Metadaten-Extraktion |
| `rip(job, ctx)` | Rohdaten-Extraktion |
| `review(job, ctx)` | Optionale Vorschau-Vorbereitung |
| `encode(job, ctx)` | Finales Encoding/Konvertierung |
| `finalize(job, ctx)` | Nachbearbeitung |
| `onCancel(job, ctx)` | Bereinigung bei Abbruch |
| `onRetry(job, ctx)` | Zustand-Reset vor Retry |
### Implementierte Plugins
- **`BluRayPlugin.js`** — Blu-ray via MakeMKV (Backup-Modus)
- **`DVDPlugin.js`** — DVD via MakeMKV (MKV-Modus)
- **`CdPlugin.js`** — Audio-CD via cdparanoia + FFmpeg (experimentell)
- **`AudiobookPlugin.js`** — AAX/M4B via FFmpeg mit Kapitel-Splitting
- **`ConverterPlugin.js`** — Generische Audio/Video-Konvertierung via FFmpeg
- **`VideoDiscPlugin.js`** — Gemeinsame Basis für Blu-ray/DVD (MediaInfo-Parsing)
--- ---
@@ -11,10 +47,11 @@ Zentrale Workflow-Orchestrierung.
Aufgaben: Aufgaben:
- Pipeline-State-Machine + Persistenz (`pipeline_state`) - Pipeline-State-Machine + Persistenz (`pipeline_state`)
- Disc-Analyse/Rip/Review/Encode - Disc-Analyse/Rip/Review/Encode via Plugin-Delegation
- Queue-Management (Jobs + `script|chain|wait` Einträge) - Queue-Management (Jobs + `script|chain|wait` Einträge)
- Retry/Re-Encode/Restart-Flows - Retry/Re-Encode/Restart-Flows
- WebSocket-Broadcasts für State/Progress/Queue - WebSocket-Broadcasts für State/Progress/Queue
- Converter-Job-Verwaltung (`createConverterJobFromEntry`, `uploadConverterFiles`, `startConverterJob`)
Wichtige Methoden: Wichtige Methoden:
@@ -29,6 +66,8 @@ Wichtige Methoden:
- `restartEncodeWithLastSettings()` - `restartEncodeWithLastSettings()`
- `resumeReadyToEncodeJob()` - `resumeReadyToEncodeJob()`
- `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()` - `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()`
- `createConverterJobFromEntry()`, `createConverterJobsFromSelection()`
- `uploadConverterFiles()`, `startConverterJob()`
--- ---
@@ -57,9 +96,10 @@ Features:
- `getCategorizedSettings()` für UI-Form - `getCategorizedSettings()` für UI-Form
- `setSettingValue()` / `setSettingsBulk()` - `setSettingValue()` / `setSettingsBulk()`
- profilspezifische Auflösung (`resolveEffectiveToolSettings`) - profilspezifische Auflösung (`resolveEffectiveToolSettings`)
- CLI-Config-Building für MakeMKV/HandBrake/MediaInfo - CLI-Config-Building für MakeMKV/HandBrake/MediaInfo/FFmpeg
- HandBrake-Preset-Liste via `HandBrakeCLI -z` - HandBrake-Preset-Liste via `HandBrakeCLI -z`
- MakeMKV-Registration-Command aus `makemkv_registration_key` - MakeMKV-Registration-Command aus `makemkv_registration_key`
- Pfadauflösung für alle Medienprofile inkl. Audiobook und Converter
--- ---
@@ -77,6 +117,59 @@ Features:
--- ---
## `audiobookService.js`
Audiobook-Verarbeitung (AAX/M4B).
Features:
- FFprobe-basierte Analyse (Kapitel, Metadaten)
- FFmpeg-basiertes Encoding mit Kapitel-Splitting
- Ausgabeformate: M4B, MP3, FLAC
- Template-basierte Pfade (`{author}`, `{title}`, `{year}`, `{narrator}`, `{series}`, `{part}`)
---
## `activationBytesService.js`
Verwaltung von Audible-Activation-Bytes für AAX-DRM-Handling.
Features:
- SHA-1-Prüfsumme der AAX-Datei berechnen
- Activation Bytes in `aax_activation_bytes`-Tabelle cachen
- Lookup via `audnexService` (wenn konfiguriert)
---
## `converterScanService.js`
Dateisystem-Überwachung des Converter-Eingangsordners.
Features:
- Vollständiger FS-Baum (`getTree()`)
- DB-basierter Datei-Explorer (`getEntries()`)
- Manueller und automatischer Scan (Polling)
- Pfad-Normalisierung mit Traversal-Schutz
- WebSocket-Broadcast: `CONVERTER_SCAN_UPDATE`
---
## `downloadService.js`
Download-Queue für Ausgabedateien aus der Historie.
Features:
- Job in Download-Queue einreihen (`enqueueHistoryJob`)
- ZIP-Archiv aus Ausgabedateien erstellen (`archiver`)
- Datei-Streaming via `res.download()`
- WebSocket-Broadcast: `DOWNLOADS_UPDATED` bei Status-Änderungen
- Download-Item löschen
---
## `cronService.js` ## `cronService.js`
Integriertes Cron-System ohne externe Parser-Library. Integriertes Cron-System ohne externe Parser-Library.
@@ -124,6 +217,12 @@ Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activit
- `hardwareMonitorService.js` (CPU/RAM/GPU/Storage) - `hardwareMonitorService.js` (CPU/RAM/GPU/Storage)
- `websocketService.js` (Client-Registry + Broadcast) - `websocketService.js` (Client-Registry + Broadcast)
- `notificationService.js` (PushOver) - `notificationService.js` (PushOver)
- `cdRipService.js` (CD-Ripping mit cdparanoia)
- `musicBrainzService.js` (MusicBrainz-Metadaten-Lookup)
- `audnexService.js` (Audnex-API für Audiobook-Metadaten)
- `coverArtRecoveryService.js` (Cover-Art-Wiederherstellung)
- `thumbnailService.js` (Vorschaubild-Generierung)
- `omdbService.js` (OMDb-Metadaten-Suche)
- `logger.js` (rotierende Datei-Logs) - `logger.js` (rotierende Datei-Logs)
--- ---
@@ -133,9 +232,10 @@ Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activit
Beim Start: Beim Start:
1. DB init/migrate 1. DB init/migrate
2. Pipeline-Init 2. Pipeline-Init (inkl. Plugin-Registry)
3. Cron-Init 3. Cron-Init
4. Express-Routes + Error-Handler 4. Express-Routes + Error-Handler
5. WebSocket-Server auf `/ws` 5. WebSocket-Server auf `/ws`
6. Hardware-Monitoring-Init 6. Hardware-Monitoring-Init
7. Disk-Detection-Start 7. Disk-Detection-Start
8. Converter-Scan-Service-Init (Polling falls aktiviert)
+75 -1
View File
@@ -10,6 +10,8 @@ Ripster verwendet SQLite (`backend/data/ripster.db`).
settings_schema settings_schema
settings_values settings_values
jobs jobs
job_lineage_artifacts
job_output_folders
pipeline_state pipeline_state
scripts scripts
script_chains script_chains
@@ -17,6 +19,9 @@ script_chain_steps
user_presets user_presets
cron_jobs cron_jobs
cron_run_logs cron_run_logs
aax_activation_bytes
user_prefs
converter_scan_entries
``` ```
--- ---
@@ -32,10 +37,38 @@ Zentrale Felder:
- Pfade: `raw_path`, `output_path`, `encode_input_path` - Pfade: `raw_path`, `output_path`, `encode_input_path`
- Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json` - Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json`
- Kontrolle: `encode_review_confirmed`, `rip_successful`, `error_message` - Kontrolle: `encode_review_confirmed`, `rip_successful`, `error_message`
- Medientyp: `media_type` (`bluray|dvd|cd|audiobook|converter`)
- Prüfsumme: `aax_checksum` (für AAX-Dateien)
- Audit: `created_at`, `updated_at` - Audit: `created_at`, `updated_at`
--- ---
## `job_lineage_artifacts`
Verknüpft Jobs mit ihren Quell-Jobs (z. B. Re-Encode aus einem vorherigen Rip).
Felder:
- `job_id` — der abgeleitete Job
- `source_job_id` — der Quell-Job
- `artifact_type` — Art der Verknüpfung (z. B. `reencode`, `restart_review`)
- `created_at`
---
## `job_output_folders`
Verfolgt Ausgabepfade pro Job (mehrere Ausgaben möglich, z. B. bei Kapitel-Splitting).
Felder:
- `job_id`
- `folder_path`
- `label` — optionaler Bezeichner
- `created_at`
---
## `pipeline_state` ## `pipeline_state`
Singleton-Tabelle (`id = 1`) für aktiven Snapshot: Singleton-Tabelle (`id = 1`) für aktiven Snapshot:
@@ -89,6 +122,45 @@ Benannte HandBrake-Preset-Sets:
--- ---
## `aax_activation_bytes`
Cache für Audible-Activation-Bytes (AAX-DRM).
Felder:
- `checksum` — SHA-1-Prüfsumme der AAX-Datei (Primärschlüssel)
- `activation_bytes` — Hex-String der Activation Bytes
- `created_at`
---
## `user_prefs`
Benutzer-spezifische Einstellungen (Key-Value).
Felder:
- `key`
- `value`
- `updated_at`
---
## `converter_scan_entries`
DB-seitige Erfassung gescannter Dateien im Converter-Eingangsordner.
Felder:
- `rel_path` — relativer Pfad zum `converter_raw_dir`
- `is_dir` — Verzeichnis-Flag
- `size` — Dateigröße in Bytes
- `modified_at` — Datei-Änderungsdatum
- `job_id` — zugewiesener Converter-Job (falls vorhanden)
- `scanned_at`
---
## Migration/Recovery ## Migration/Recovery
Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen. Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen.
@@ -107,6 +179,8 @@ Bei korruptem SQLite-File:
sqlite3 backend/data/ripster.db sqlite3 backend/data/ripster.db
.mode table .mode table
SELECT id, status, title, created_at FROM jobs ORDER BY created_at DESC; SELECT id, status, title, media_type, created_at FROM jobs ORDER BY created_at DESC;
SELECT key, value FROM settings_values ORDER BY key; SELECT key, value FROM settings_values ORDER BY key;
SELECT checksum, activation_bytes FROM aax_activation_bytes;
SELECT rel_path, job_id FROM converter_scan_entries;
``` ```
+41 -6
View File
@@ -13,10 +13,11 @@ Pipeline-Steuerung:
- Status/Progress/ETA - Status/Progress/ETA
- Metadaten-Dialog - Metadaten-Dialog
- Playlist-Entscheidung - Playlist-Entscheidung
- Review-Panel - Review-Panel (Track-Auswahl)
- Queue-Interaktion (reorder/add/remove) - Queue-Interaktion (reorder/add/remove)
- Job-Aktionen (Start/Cancel/Retry/Re-Encode) - Job-Aktionen (Start/Cancel/Retry/Re-Encode)
- Hardware-Monitoring-Anzeige - Hardware-Monitoring-Anzeige
- Aktivitäts-Tracking (Skripte, Ketten, Cron)
### `SettingsPage.jsx` ### `SettingsPage.jsx`
@@ -36,15 +37,46 @@ Historie:
- OMDb-Nachzuweisung - OMDb-Nachzuweisung
- Re-Encode/Restart-Workflows - Re-Encode/Restart-Workflows
### `ConverterPage.jsx`
Datei-Converter:
- Datei-Explorer des Converter-Eingangsordners (Baum-Ansicht)
- Upload von Audio/Video-Dateien (bis zu 50 Dateien gleichzeitig)
- Dateiverwaltung: Umbenennen, Verschieben, Löschen, Ordner erstellen
- Jobs aus Dateiauswahl erstellen
- Converter-Job-Konfiguration (Ausgabeformat, Preset, Tracks)
- Job-Start, Abbruch, Löschung
- Automatischer Scan-Status
### `DownloadsPage.jsx`
Download-Queue:
- Ausgabedateien aus der Job-Historie in die Queue einreihen
- Download-Status verfolgen (ausstehend, wird verarbeitet, bereit, fehlgeschlagen)
- Dateien als ZIP herunterladen
- Download-Einträge löschen
### `DatabasePage.jsx`
Expert-Modus:
- Tabellarische Rohsicht der Job-Datenbank
- Orphan-RAW-Import
--- ---
## Wichtige Komponenten ## Wichtige Komponenten
- `PipelineStatusCard.jsx` - `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress
- `MetadataSelectionDialog.jsx` - `MetadataSelectionDialog.jsx` — OMDb-Metadaten-Auswahl
- `MediaInfoReviewPanel.jsx` - `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel)
- `JobDetailDialog.jsx` - `JobDetailDialog.jsx` — Job-Detailansicht mit Logs
- `CronJobsTab.jsx` - `CronJobsTab.jsx` — Cron-Job-Verwaltung
- `DynamicSettingsForm.jsx` — Schema-gesteuertes Einstellungsformular
- `ConverterJobCard.jsx` — Converter-Job-Darstellung
- `CdRipConfigPanel.jsx` — Konfiguration für Audio-CD-Ripping
--- ---
@@ -68,6 +100,9 @@ In `App.jsx` werden u. a. verarbeitet:
- `PIPELINE_QUEUE_CHANGED` - `PIPELINE_QUEUE_CHANGED`
- Disk erkannt / Disk entfernt - Disk erkannt / Disk entfernt
- `HARDWARE_MONITOR_UPDATE` - `HARDWARE_MONITOR_UPDATE`
- `DOWNLOADS_UPDATED`
- `CONVERTER_SCAN_UPDATE`
- `RUNTIME_ACTIVITY_CHANGED`
--- ---
+43 -4
View File
@@ -13,16 +13,31 @@ Viele Felder sind pro Medientyp getrennt vorhanden:
- Blu-ray - Blu-ray
- DVD - DVD
- Sonstiges - Sonstiges
- Audiobook (neu ab v0.12.0)
--- ---
## Template-Platzhalter ## Template-Platzhalter
Datei-/Ordner-Templates unterstützen: ### Video-Templates
- `${title}` — Titel
- `${year}` — Erscheinungsjahr
- `${imdbId}` — IMDb-ID
- `${title}` ### Audiobook-Templates
- `${year}` - `{author}` — Autor
- `${imdbId}` - `{title}` — Titel
- `{year}` — Erscheinungsjahr
- `{narrator}` — Sprecher
- `{series}` — Buchreihe
- `{part}` — Teil-Nummer
- `{format}` — Ausgabeformat
### Converter-Templates (Video)
- `{title}`, `{year}`
### Converter-Templates (Audio)
- `{artist}`, `{title}`, `{album}`, `{year}`, `{trackNr}`
Nicht gesetzte Werte werden zu `unknown`. Nicht gesetzte Werte werden zu `unknown`.
@@ -47,6 +62,17 @@ Nicht gesetzte Werte werden zu `unknown`.
| `Eigentümer Film-Ordner (DVD)` | string | `null` | | `Eigentümer Film-Ordner (DVD)` | string | `null` |
| `Eigentümer Film-Ordner (Sonstiges)` | string | `null` | | `Eigentümer Film-Ordner (Sonstiges)` | string | `null` |
| `Log Ordner` | path | `data/logs` | | `Log Ordner` | path | `data/logs` |
| `Audiobook RAW-Ordner` | path | `null` |
| `Eigentümer Audiobook RAW-Ordner` | string | `null` |
| `Audiobook Output-Ordner` | path | `null` |
| `Eigentümer Audiobook Output-Ordner` | string | `null` |
| `Output Template (Audiobook)` | string | `{author}/{author} - {title} ({year})` |
| `Audiobook RAW Template` | string | `{author} - {title} ({year})` |
| `Converter Raw-Ordner` | path | `null` |
| `Converter Ausgabe (Video)` | path | `null` |
| `Converter Ausgabe (Audio)` | path | `null` |
| `Output-Template (Video)` | string | `{title}` |
| `Output-Template (Audio)` | string | `{artist} - {title}` |
--- ---
@@ -57,6 +83,7 @@ Nicht gesetzte Werte werden zu `unknown`.
| `Laufwerksmodus` | select | `auto` | `Auto Discovery` oder `Explizites Device` | | `Laufwerksmodus` | select | `auto` | `Auto Discovery` oder `Explizites Device` |
| `Device Pfad` | path | `/dev/sr0` | relevant bei `Explizites Device` | | `Device Pfad` | path | `/dev/sr0` | relevant bei `Explizites Device` |
| `MakeMKV Source Index` | number | `0` | Disc-Index im Auto-Modus | | `MakeMKV Source Index` | number | `0` | Disc-Index im Auto-Modus |
| `Disc Auto-Erkennung aktiviert` | boolean | `true` | Automatische Disc-Erkennung per Polling |
| `Polling Intervall (ms)` | number | `4000` | 1000..60000 | | `Polling Intervall (ms)` | number | `4000` | 1000..60000 |
--- ---
@@ -79,6 +106,8 @@ Nicht gesetzte Werte werden zu `unknown`.
| `Mediainfo Kommando` | string | `mediainfo` | | `Mediainfo Kommando` | string | `mediainfo` |
| `Minimale Titellaenge (Minuten)` | number | `60` | | `Minimale Titellaenge (Minuten)` | number | `60` |
| `HandBrake Kommando` | string | `HandBrakeCLI` | | `HandBrake Kommando` | string | `HandBrakeCLI` |
| `FFmpeg Kommando` | string | `ffmpeg` |
| `FFprobe Kommando` | string | `ffprobe` |
| `Encode-Neustart: unvollständige Ausgabe löschen` | boolean | `true` | | `Encode-Neustart: unvollständige Ausgabe löschen` | boolean | `true` |
| `Parallele Jobs` | number | `1` | | `Parallele Jobs` | number | `1` |
@@ -112,6 +141,16 @@ Nicht gesetzte Werte werden zu `unknown`.
--- ---
## Kategorie: Converter
| Feldname in der GUI | Typ | Default | Hinweis |
|---|---|---|---|
| `Erlaubte Datei-Endungen` | string | `mkv,mp4,m2ts,iso,avi,mov,flac,mp3,aac,wav,m4a,ogg,opus` | Komma-getrennt, ohne Punkt |
| `Auto-Scan (Polling)` | boolean | `false` | Converter-Ordner automatisch scannen |
| `Polling-Intervall (Sekunden)` | number | `300` | 30..86400 |
---
## Kategorie: Metadaten ## Kategorie: Metadaten
| Feldname in der GUI | Typ | Default | | Feldname in der GUI | Typ | Default |
+91
View File
@@ -0,0 +1,91 @@
# Converter
Die Converter-Seite ermöglicht das Konvertieren vorhandener Audio- und Video-Dateien sowie das Verarbeiten von Audiobooks (AAX/M4B).
---
## Überblick
Der Converter arbeitet mit einem konfigurierbaren Eingangsordner (`converter_raw_dir`). Dateien werden dort abgelegt (manuell oder per Upload) und dann als Converter-Jobs in die Pipeline eingereiht.
---
## Datei-Explorer
Der Datei-Explorer zeigt den Inhalt des `converter_raw_dir` als Baumstruktur.
### Verfügbare Aktionen
| Aktion | Beschreibung |
|---|---|
| **Upload** | Dateien direkt aus dem Browser hochladen (bis zu 50 Dateien) |
| **Ordner erstellen** | Neuen Unterordner anlegen |
| **Umbenennen** | Datei oder Ordner umbenennen |
| **Verschieben** | Datei oder Ordner in einen anderen Unterordner verschieben |
| **Löschen** | Datei oder Ordner löschen (unwiderruflich) |
| **Scannen** | Eingangsordner manuell neu scannen |
### Dateiauswahl
Dateien können per Checkbox ausgewählt werden. Aus der Auswahl lassen sich Jobs erstellen:
- **Ein Job pro Datei** — jede Datei wird als eigenständiger Job angelegt
- **Gemeinsamer Job (Audio)** — mehrere Audio-Dateien werden zu einem gemeinsamen Job zusammengefasst
Erlaubte Dateiendungen werden in den Settings unter `Converter > Erlaubte Datei-Endungen` konfiguriert.
---
## Jobs erstellen und konfigurieren
Nach der Auswahl und Job-Erstellung erscheinen die Jobs in der Job-Liste.
### Job-Konfiguration
Jeder Job kann konfiguriert werden:
- **Medientyp:** Video, Audio oder Audiobook
- **Ausgabeformat:** z. B. `mkv`, `mp4`, `m4b`, `mp3`, `flac`
- **HandBrake-Preset:** für Video-Konvertierung
- **Track-Auswahl:** Video-, Audio- und Untertitel-Spuren
- **Metadaten:** Titel, Autor, Jahr (für Audiobooks)
- **MusicBrainz-Suche:** automatische Metadaten-Suche für Audiodateien
### Job-Start
Jobs starten sofort oder reihen sich in die Queue ein (abhängig von `Parallele Jobs`).
---
## Auto-Scan (Polling)
Wenn in den Settings `Converter > Auto-Scan (Polling)` aktiviert ist, wird der Eingangsordner automatisch in regelmäßigen Abständen gescannt. Neue Dateien werden in der DB erfasst und im Explorer angezeigt.
Das Scan-Intervall wird unter `Converter > Polling-Intervall (Sekunden)` konfiguriert (Standard: 300 Sekunden).
---
## Audiobook-Verarbeitung
Für AAX-Dateien (Audible) ist folgendes erforderlich:
1. AAX-Datei in den Eingangsordner hochladen
2. Job mit Medientyp `Audiobook` erstellen
3. Activation Bytes müssen verfügbar sein (werden beim ersten Mal abgefragt und gecacht)
4. Ausgabeformat wählen: M4B (empfohlen), MP3 oder FLAC
5. Kapitel-Splitting konfigurieren (optional)
---
## Einstellungen
| Setting | Pfad in GUI |
|---|---|
| Eingangsordner | `Settings > Pfade > Converter Raw-Ordner` |
| Videoausgabe | `Settings > Pfade > Converter Ausgabe (Video)` |
| Audioausgabe | `Settings > Pfade > Converter Ausgabe (Audio)` |
| Erlaubte Endungen | `Settings > Converter > Erlaubte Datei-Endungen` |
| Auto-Scan | `Settings > Converter > Auto-Scan (Polling)` |
| Scan-Intervall | `Settings > Converter > Polling-Intervall (Sekunden)` |
| Output-Template Video | `Settings > Pfade > Output-Template (Video)` |
| Output-Template Audio | `Settings > Pfade > Output-Template (Audio)` |
+53
View File
@@ -0,0 +1,53 @@
# Downloads
Die Downloads-Seite ermöglicht es, Ausgabedateien aus abgeschlossenen Jobs als ZIP-Archiv herunterzuladen.
---
## Überblick
Jobs, die in der Historie abgeschlossen sind, können in die Download-Queue eingereiht werden. Ripster erstellt daraus ein ZIP-Archiv, das direkt im Browser heruntergeladen werden kann.
---
## Download-Queue
Die Queue zeigt alle aktuellen Download-Einträge mit ihrem Status:
| Status | Beschreibung |
|---|---|
| **Ausstehend** | In der Queue, ZIP-Erstellung noch nicht gestartet |
| **Wird verarbeitet** | ZIP-Archiv wird gerade erstellt |
| **Bereit** | ZIP fertig, Download-Button verfügbar |
| **Fehlgeschlagen** | Fehler bei der ZIP-Erstellung |
---
## Download einreihen
Downloads können über die **Historie** eingereiht werden:
1. Job in der Historie öffnen
2. **Download einreihen** wählen
3. Ziel auswählen: `RAW-Dateien` oder `Ausgabedateien`
4. Den Eintrag in der Downloads-Queue verfolgen
---
## Download starten
Sobald der Status **Bereit** angezeigt wird, kann die ZIP-Datei mit dem Download-Button heruntergeladen werden.
ZIP-Dateinamen folgen dem Muster: `Job_<id>_<target>.zip`
---
## Einträge löschen
Abgeschlossene oder fehlerhafte Einträge können einzeln gelöscht werden. Die dazugehörige ZIP-Datei wird dabei ebenfalls vom Server entfernt.
---
## Echtzeit-Updates
Die Downloads-Seite aktualisiert sich automatisch per WebSocket (`DOWNLOADS_UPDATED`). Statusänderungen (z. B. von `Wird verarbeitet` zu `Bereit`) werden ohne Seiten-Reload angezeigt.
+10 -6
View File
@@ -1,6 +1,6 @@
# GUI-Seiten # GUI-Seiten
Ripster hat drei Hauptseiten in der Navigation plus eine Expert-Seite. Ripster hat fünf Hauptseiten in der Navigation plus eine Expert-Seite.
## Seitenüberblick ## Seitenüberblick
@@ -9,16 +9,20 @@ Ripster hat drei Hauptseiten in der Navigation plus eine Expert-Seite.
| [Dashboard](dashboard.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos | | [Dashboard](dashboard.md) | Live-Betrieb: Pipeline, Queue, Aktivitäten, Disc-Infos |
| [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs | | [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs |
| [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten | | [Historie](history.md) | abgeschlossene/laufende Jobs durchsuchen und nachbearbeiten |
| [Converter](converter.md) | Audio/Video-Dateien konvertieren, Datei-Explorer, Upload |
| [Downloads](downloads.md) | Ausgabedateien als ZIP herunterladen |
| [Database (Expert)](database.md) | tabellarische Rohsicht inkl. Orphan-RAW-Import | | [Database (Expert)](database.md) | tabellarische Rohsicht inkl. Orphan-RAW-Import |
## Empfohlene Nutzung im Alltag ## Empfohlene Nutzung im Alltag
1. **Start eines neuen Jobs:** `Dashboard` 1. **Start eines neuen Disc-Jobs:** `Dashboard`
2. **Regeln/Automatisierung anpassen:** `Settings` 2. **Dateien konvertieren oder Audiobooks verarbeiten:** `Converter`
3. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie` 3. **Regeln/Automatisierung anpassen:** `Settings`
4. **Sonderfälle/Recovery:** `Database` 4. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie`
5. **Dateien herunterladen:** `Downloads`
6. **Sonderfälle/Recovery:** `Database`
## Hinweise zur Navigation ## Hinweise zur Navigation
- `Dashboard`, `Settings`, `Historie` sind direkt in der Kopfnavigation. - `Dashboard`, `Settings`, `Historie`, `Converter` und `Downloads` sind direkt in der Kopfnavigation.
- `Database` ist als Expert-Route verfügbar: `/database`. - `Database` ist als Expert-Route verfügbar: `/database`.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.12.0-16", "version": "0.12.0-17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.12.0-16", "version": "0.12.0-17",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.12.0-16", "version": "0.12.0-17",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+105 -1
View File
@@ -48,6 +48,7 @@ const NOTIFICATION_EVENT_TOGGLE_KEYS = new Set([
const PUSHOVER_ENABLED_SETTING_KEY = 'pushover_enabled'; const PUSHOVER_ENABLED_SETTING_KEY = 'pushover_enabled';
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode'; const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
const EXTERNAL_STORAGE_PATHS_KEY = 'external_storage_paths';
const ALWAYS_HIDDEN_SETTING_KEYS = new Set([ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
'drive_device', 'drive_device',
'makemkv_source_index', // auto-derived from drive path — shown as read-only in drive info 'makemkv_source_index', // auto-derived from drive path — shown as read-only in drive info
@@ -201,6 +202,96 @@ function DriveDevicesEditor({ value, onChange, settingKey }) {
); );
} }
function ExternalStoragePathsEditor({ value, onChange, settingKey }) {
const parsePaths = (rawValue) => {
try {
const parsed = JSON.parse(rawValue || '[]');
if (!Array.isArray(parsed)) return [];
const normalized = [];
for (const entry of parsed) {
if (typeof entry === 'string') {
normalized.push({
name: '',
path: String(entry || '')
});
continue;
}
if (entry && typeof entry === 'object') {
normalized.push({
name: String(entry.name || entry.label || ''),
path: String(entry.path || '')
});
}
}
return normalized;
} catch (_error) {
return [];
}
};
const entries = parsePaths(value);
const updatePaths = (nextPaths) => {
const normalized = (Array.isArray(nextPaths) ? nextPaths : [])
.map((entry) => ({
name: String(entry?.name || ''),
path: String(entry?.path || '')
}));
onChange?.(settingKey, JSON.stringify(normalized));
};
return (
<div className="external-storage-editor">
{entries.length === 0 && (
<p className="external-storage-empty">Keine externen Speicherpfade konfiguriert.</p>
)}
{entries.map((entry, idx) => (
<div key={`external-storage-${idx}`} className="external-storage-row">
<InputText
value={entry.name}
placeholder="Name (z.B. USB HDD)"
onChange={(event) => {
const next = [...entries];
next[idx] = { ...next[idx], name: event.target.value };
updatePaths(next);
}}
className="external-storage-name-input"
/>
<InputText
value={entry.path}
placeholder="/mnt/external-disk"
onChange={(event) => {
const next = [...entries];
next[idx] = { ...next[idx], path: event.target.value };
updatePaths(next);
}}
className="external-storage-input"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
onClick={() => updatePaths(entries.filter((_, entryIndex) => entryIndex !== idx))}
type="button"
aria-label="Externen Speicherpfad entfernen"
/>
</div>
))}
<Button
icon="pi pi-plus"
label="Pfad hinzufügen"
severity="secondary"
text
size="small"
onClick={() => updatePaths([...entries, { name: '', path: '' }])}
type="button"
/>
</div>
);
}
function DetectedDrivesInfo() { function DetectedDrivesInfo() {
const [drives, setDrives] = useState(null); const [drives, setDrives] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -351,6 +442,7 @@ const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template']; const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
const DOWNLOAD_PATH_KEYS = ['download_dir']; const DOWNLOAD_PATH_KEYS = ['download_dir'];
const LOG_PATH_KEYS = ['log_dir']; const LOG_PATH_KEYS = ['log_dir'];
const EXTERNAL_STORAGE_PATH_KEYS = [EXTERNAL_STORAGE_PATHS_KEY];
const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio']; const CONVERTER_PATH_KEYS = ['converter_raw_dir', 'converter_movie_dir', 'converter_audio_dir', 'converter_output_template_video', 'converter_output_template_audio'];
function buildSectionsForCategory(categoryName, settings) { function buildSectionsForCategory(categoryName, settings) {
@@ -432,7 +524,9 @@ function SettingField({
</label> </label>
)} )}
{(setting.type === 'string' || setting.type === 'path') && setting.key !== 'drive_devices' ? ( {(setting.type === 'string' || setting.type === 'path')
&& setting.key !== 'drive_devices'
&& setting.key !== EXTERNAL_STORAGE_PATHS_KEY ? (
<InputText <InputText
id={setting.key} id={setting.key}
value={value ?? ''} value={value ?? ''}
@@ -448,6 +542,14 @@ function SettingField({
/> />
) : null} ) : null}
{setting.key === EXTERNAL_STORAGE_PATHS_KEY ? (
<ExternalStoragePathsEditor
value={value}
onChange={onChange}
settingKey={setting.key}
/>
) : null}
{setting.type === 'number' ? ( {setting.type === 'number' ? (
<InputNumber <InputNumber
id={setting.key} id={setting.key}
@@ -540,6 +642,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', '')))); const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', '')))); const downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key)); const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
const externalStorageSettings = list.filter((s) => EXTERNAL_STORAGE_PATH_KEYS.includes(s.key));
const converterSettings = list.filter((s) => CONVERTER_PATH_KEYS.includes(s.key)); const converterSettings = list.filter((s) => CONVERTER_PATH_KEYS.includes(s.key));
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw'; const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
@@ -675,6 +778,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
{ title: 'Audiobook', pathSettings: audiobookSettings }, { title: 'Audiobook', pathSettings: audiobookSettings },
{ title: 'Converter', pathSettings: converterSettings }, { title: 'Converter', pathSettings: converterSettings },
{ title: 'Downloads', pathSettings: downloadSettings }, { title: 'Downloads', pathSettings: downloadSettings },
...(externalStorageSettings.length > 0 ? [{ title: 'Externe Speicher', pathSettings: externalStorageSettings }] : []),
...(logSettings.length > 0 ? [{ title: 'Logs', pathSettings: logSettings }] : []) ...(logSettings.length > 0 ? [{ title: 'Logs', pathSettings: logSettings }] : [])
] ]
.filter(({ pathSettings }) => .filter(({ pathSettings }) =>
@@ -19,6 +19,7 @@ export default function MetadataSelectionDialog({
const [manualYear, setManualYear] = useState(''); const [manualYear, setManualYear] = useState('');
const [manualImdb, setManualImdb] = useState(''); const [manualImdb, setManualImdb] = useState('');
const [extraResults, setExtraResults] = useState([]); const [extraResults, setExtraResults] = useState([]);
const [searchBusy, setSearchBusy] = useState(false);
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
@@ -36,6 +37,7 @@ export default function MetadataSelectionDialog({
setManualYear(defaultYear); setManualYear(defaultYear);
setManualImdb(defaultImdb); setManualImdb(defaultImdb);
setExtraResults([]); setExtraResults([]);
setSearchBusy(false);
}, [visible, context]); }, [visible, context]);
const rows = useMemo(() => { const rows = useMemo(() => {
@@ -67,11 +69,17 @@ export default function MetadataSelectionDialog({
); );
const handleSearch = async () => { const handleSearch = async () => {
if (!query.trim()) { const trimmedQuery = String(query || '').trim();
if (!trimmedQuery || typeof onSearch !== 'function') {
return; return;
} }
const results = await onSearch(query.trim()); setSearchBusy(true);
setExtraResults(results || []); try {
const results = await onSearch(trimmedQuery);
setExtraResults(Array.isArray(results) ? results : []);
} finally {
setSearchBusy(false);
}
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -110,9 +118,15 @@ export default function MetadataSelectionDialog({
<InputText <InputText
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void handleSearch();
}
}}
placeholder="Titel suchen" placeholder="Titel suchen"
/> />
<Button label="OMDb Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} /> <Button label="OMDb Suche" icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div> </div>
<div className="table-scroll-wrap table-scroll-medium"> <div className="table-scroll-wrap table-scroll-medium">
+57 -11
View File
@@ -59,6 +59,10 @@ function normalizeJobId(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function normalizeDrivePath(value) {
return String(value || '').trim();
}
function formatPercent(value, digits = 1) { function formatPercent(value, digits = 1) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
@@ -1508,8 +1512,17 @@ export default function DashboardPage({
}; };
const handleAnalyzeAll = async () => { const handleAnalyzeAll = async () => {
const lockedPaths = new Set(
(Array.isArray(pipeline?.driveLocks) ? pipeline.driveLocks : [])
.filter((lock) => Number(lock?.count || 0) > 0)
.map((lock) => normalizeDrivePath(lock?.path))
.filter(Boolean)
);
const drivesToAnalyze = allDrives const drivesToAnalyze = allDrives
.filter((drv) => { .filter((drv) => {
if (lockedPaths.has(normalizeDrivePath(drv.path))) {
return false;
}
const cdState = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null; const cdState = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
if (cdState) return cdState === 'DISC_DETECTED'; if (cdState) return cdState === 'DISC_DETECTED';
if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return true; if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return true;
@@ -2299,6 +2312,26 @@ export default function DashboardPage({
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null; const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
const contextDevice = pipeline?.context?.device; const contextDevice = pipeline?.context?.device;
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null); const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
const driveLocksByPath = useMemo(() => {
const map = new Map();
const locks = Array.isArray(pipeline?.driveLocks) ? pipeline.driveLocks : [];
for (const lock of locks) {
const path = normalizeDrivePath(lock?.path);
const count = Number(lock?.count || 0);
if (!path || !Number.isFinite(count) || count <= 0) {
continue;
}
const owners = Array.isArray(lock?.owners) ? lock.owners : [];
const owner = owners.length > 0 ? owners[owners.length - 1] : null;
map.set(path, {
path,
count,
owner,
owners
});
}
return map;
}, [pipeline?.driveLocks]);
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state // Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
const allDrives = useMemo(() => { const allDrives = useMemo(() => {
@@ -2621,7 +2654,14 @@ export default function DashboardPage({
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {}; const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null; const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
const pipelineState = String(drv.pipelineState || '').toUpperCase(); const pipelineState = String(drv.pipelineState || '').toUpperCase();
const driveLockEntry = driveLocksByPath.get(normalizeDrivePath(drivePath)) || null;
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false; const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
const isDriveLocked = Boolean(driveLockEntry) || isCdDriveLocked;
const lockedByJobId = normalizeJobId(driveLockEntry?.owner?.jobId);
const lockStage = String(driveLockEntry?.owner?.stage || '').trim().toUpperCase();
const lockTitle = lockedByJobId
? `Laufwerk ist gesperrt (Job #${lockedByJobId}${lockStage ? `, ${lockStage}` : ''}).`
: 'Laufwerk ist gesperrt, bis Rip/Encode abgeschlossen ist.';
const canAnalyzeDrive = cdState const canAnalyzeDrive = cdState
? cdState === 'DISC_DETECTED' ? cdState === 'DISC_DETECTED'
: (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc); : (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc);
@@ -2645,18 +2685,22 @@ export default function DashboardPage({
<i className={`pi ${stateIconMeta.icon}${stateIconMeta.spin ? ' pi-spin' : ''}`} aria-hidden="true" /> <i className={`pi ${stateIconMeta.icon}${stateIconMeta.spin ? ' pi-spin' : ''}`} aria-hidden="true" />
</span> </span>
) : null} ) : null}
{isCdDriveLocked ? (
<span
className="drive-list-lock-indicator"
title="Laufwerk ist gesperrt, bis Rip/Encode abgeschlossen ist."
aria-label="Laufwerk gesperrt"
>
<i className="pi pi-lock" aria-hidden="true" />
<span>gesperrt</span>
</span>
) : null}
</div> </div>
<div className="drive-list-actions"> <div className="drive-list-actions">
{isDriveLocked ? (
<Button
icon="pi pi-lock"
text
rounded
size="small"
severity="secondary"
className="drive-list-action-btn"
disabled
title={lockTitle}
aria-label="Laufwerk gesperrt"
/>
) : (
<>
<Button <Button
icon="pi pi-refresh" icon="pi pi-refresh"
text text
@@ -2665,7 +2709,7 @@ export default function DashboardPage({
severity="secondary" severity="secondary"
className="drive-list-action-btn" className="drive-list-action-btn"
loading={isRescanBusy} loading={isRescanBusy}
disabled={isRescanBusy || isDriveActive || isCdDriveLocked} disabled={isRescanBusy || isDriveActive}
onClick={() => handleRescanDrive(drivePath)} onClick={() => handleRescanDrive(drivePath)}
title="Laufwerk neu lesen" title="Laufwerk neu lesen"
aria-label="Laufwerk neu lesen" aria-label="Laufwerk neu lesen"
@@ -2685,6 +2729,8 @@ export default function DashboardPage({
aria-label="Disk analysieren" aria-label="Disk analysieren"
/> />
)} )}
</>
)}
{cdState === 'CD_METADATA_SELECTION' && ( {cdState === 'CD_METADATA_SELECTION' && (
<Button <Button
icon="pi pi-list" icon="pi pi-list"
+29
View File
@@ -1856,6 +1856,35 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
} }
.external-storage-editor {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.external-storage-empty {
color: var(--rip-muted);
font-size: 0.85rem;
margin: 0;
}
.external-storage-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.external-storage-name-input {
width: 14rem;
min-width: 10rem;
}
.external-storage-input {
flex: 1;
font-family: monospace;
font-size: 0.9rem;
}
/* Per-drive CD sections in Dashboard */ /* Per-drive CD sections in Dashboard */
/* Unified per-drive list (Disk-Information card) */ /* Unified per-drive list (Disk-Information card) */
.drive-list { .drive-list {
+4
View File
@@ -56,6 +56,8 @@ nav:
- Dashboard: gui/dashboard.md - Dashboard: gui/dashboard.md
- Settings: gui/settings.md - Settings: gui/settings.md
- Historie: gui/history.md - Historie: gui/history.md
- Converter: gui/converter.md
- Downloads: gui/downloads.md
- Database (Expert): gui/database.md - Database (Expert): gui/database.md
- Workflows aus Nutzersicht: workflows/index.md - Workflows aus Nutzersicht: workflows/index.md
- Technischer Anhang: - Technischer Anhang:
@@ -76,6 +78,8 @@ nav:
- Settings API: api/settings.md - Settings API: api/settings.md
- History API: api/history.md - History API: api/history.md
- Cron API: api/crons.md - Cron API: api/crons.md
- Converter API: api/converter.md
- Downloads API: api/downloads.md
- Runtime Activities API: api/runtime-activities.md - Runtime Activities API: api/runtime-activities.md
- WebSocket Events: api/websocket.md - WebSocket Events: api/websocket.md
- Architektur: - Architektur:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.12.0-16", "version": "0.12.0-17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.12.0-16", "version": "0.12.0-17",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.12.0-16", "version": "0.12.0-17",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",