0.12.0-17 Misc fixes
This commit is contained in:
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
@@ -45,8 +46,40 @@ jobs:
|
||||
- name: Install MkDocs and dependencies
|
||||
run: pip install -r requirements-docs.txt
|
||||
|
||||
- name: Build MkDocs site
|
||||
run: mkdocs build --strict --verbose
|
||||
- name: Build MkDocs site (main -> /, dev -> /dev)
|
||||
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
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
# 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
|
||||
|
||||
### Disc-Ripping & Encoding
|
||||
- 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
|
||||
- MakeMKV-Analyse und Rip (`mkv` oder `backup`) mit profilspezifischen Settings
|
||||
- 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)
|
||||
- Pipeline-Queue mit Job- und Nicht-Job-Einträgen (`script`, `chain`, `wait`)
|
||||
- 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
|
||||
- Hardware-Monitoring (CPU/RAM/GPU/Storage) im Dashboard
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
- Backend: Node.js, Express, SQLite, WebSocket (`ws`)
|
||||
- Backend: Node.js, Express, SQLite, WebSocket (`ws`) – **Plugin-Architektur**
|
||||
- Frontend: React, Vite, PrimeReact
|
||||
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`
|
||||
- Externe Tools: `makemkvcon`, `HandBrakeCLI`, `mediainfo`, `ffmpeg`/`ffprobe`, `cdparanoia`
|
||||
|
||||
## 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
|
||||
- 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`)
|
||||
|
||||
@@ -59,9 +69,9 @@ curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.
|
||||
`install.sh` übernimmt u. a.:
|
||||
|
||||
- Node.js 20 (falls nötig)
|
||||
- Basistools inkl. `mediainfo`
|
||||
- Basistools inkl. `mediainfo`, `ffmpeg`, `ffprobe`
|
||||
- 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)
|
||||
- nginx
|
||||
- Repository-Checkout, npm-Install, Frontend-Build, systemd-Service (`ripster-backend`)
|
||||
@@ -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:
|
||||
|
||||
- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Sonstiges-Varianten)
|
||||
- Tools: `MakeMKV Kommando`, `HandBrake Kommando`, `Mediainfo Kommando`
|
||||
- Pfade: `Raw Ausgabeordner`, `Film Ausgabeordner`, `Log Ordner` (jeweils mit Blu-ray/DVD/Audiobook-Varianten)
|
||||
- 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)
|
||||
- Audiobook: Output-Template, RAW-Template
|
||||
- Converter: erlaubte Endungen, Auto-Scan-Intervall, Output-Templates
|
||||
- Queue/Monitoring: `Parallele Jobs`, `Hardware Monitoring aktiviert`, `Hardware Monitoring Intervall (ms)`
|
||||
- Benachrichtigungen: PushOver
|
||||
|
||||
@@ -127,13 +140,14 @@ Hinweis: Beim DB-Init wird das Schema geprüft und fehlende Elemente werden migr
|
||||
ripster/
|
||||
backend/
|
||||
src/
|
||||
plugins/ # Plugin-System (BluRay, DVD, CD, Audiobook, Converter)
|
||||
routes/
|
||||
services/
|
||||
db/
|
||||
utils/
|
||||
frontend/
|
||||
src/
|
||||
pages/
|
||||
pages/ # Dashboard, Settings, History, Converter, Downloads, Database
|
||||
components/
|
||||
api/
|
||||
db/schema.sql
|
||||
@@ -165,6 +179,30 @@ ripster/
|
||||
- `POST /api/pipeline/queue/entry`
|
||||
- `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**
|
||||
- `GET /api/history`
|
||||
- `GET /api/history/:id`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
await db.run(
|
||||
`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_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(
|
||||
`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)`
|
||||
|
||||
@@ -26,7 +26,8 @@ const RELEVANT_SETTINGS_KEYS = new Set([
|
||||
'movie_dir',
|
||||
'movie_dir_bluray',
|
||||
'movie_dir_dvd',
|
||||
'log_dir'
|
||||
'log_dir',
|
||||
'external_storage_paths'
|
||||
]);
|
||||
|
||||
function nowIso() {
|
||||
@@ -57,6 +58,52 @@ function normalizePathSetting(value) {
|
||||
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) {
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -405,13 +452,16 @@ class HardwareMonitorService {
|
||||
const cdRawPath = normalizePathSetting(cd?.raw_dir);
|
||||
const blurayMoviePath = normalizePathSetting(bluray?.movie_dir);
|
||||
const dvdMoviePath = normalizePathSetting(dvd?.movie_dir);
|
||||
const externalStoragePaths = parseExternalStoragePaths(sourceMap.external_storage_paths);
|
||||
const monitoredPaths = [];
|
||||
|
||||
const addPath = (key, label, monitoredPath) => {
|
||||
const addPath = (key, label, monitoredPath, options = {}) => {
|
||||
monitoredPaths.push({
|
||||
key,
|
||||
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);
|
||||
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;
|
||||
}
|
||||
@@ -866,7 +929,10 @@ class HardwareMonitorService {
|
||||
async collectStorageMetrics() {
|
||||
const list = [];
|
||||
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;
|
||||
}
|
||||
@@ -897,7 +963,24 @@ class HardwareMonitorService {
|
||||
const key = String(entry?.key || '');
|
||||
const label = String(entry?.label || key || 'Pfad');
|
||||
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 (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: null,
|
||||
queryPath: null,
|
||||
exists: false
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
@@ -919,6 +1002,13 @@ class HardwareMonitorService {
|
||||
const queryPath = exists ? resolvedPath : this.findNearestExistingPath(resolvedPath);
|
||||
|
||||
if (!queryPath) {
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath: null,
|
||||
exists: false
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
@@ -942,6 +1032,13 @@ class HardwareMonitorService {
|
||||
});
|
||||
const parsed = parseDfStats(stdout);
|
||||
if (!parsed) {
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath,
|
||||
exists
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
@@ -957,6 +1054,16 @@ class HardwareMonitorService {
|
||||
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 {
|
||||
key,
|
||||
@@ -965,10 +1072,18 @@ class HardwareMonitorService {
|
||||
queryPath,
|
||||
exists,
|
||||
...parsed,
|
||||
mountPoint: mountPoint || null,
|
||||
note: exists ? null : `Pfad fehlt, Parent verwendet (${queryPath}).`,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
if (hideWhenUnavailable) {
|
||||
return hiddenResult({
|
||||
path: resolvedPath,
|
||||
queryPath,
|
||||
exists
|
||||
});
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
|
||||
@@ -37,6 +37,22 @@ function sleep(ms) {
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
|
||||
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
|
||||
@@ -89,10 +105,26 @@ class OmdbService {
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery) {
|
||||
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 apiKey = settings.omdb_api_key;
|
||||
@@ -101,28 +133,47 @@ class OmdbService {
|
||||
}
|
||||
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
|
||||
|
||||
const type = settings.omdb_default_type || 'movie';
|
||||
const url = new URL(OMDB_BASE_URL);
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('s', query.trim());
|
||||
url.searchParams.set('type', type);
|
||||
const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
|
||||
const requestSearch = async (type = null) => {
|
||||
const url = new URL(OMDB_BASE_URL);
|
||||
url.searchParams.set('apikey', apiKey);
|
||||
url.searchParams.set('s', normalizedQuery);
|
||||
if (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 });
|
||||
if (!data || typeof data !== 'object') {
|
||||
let { data, results } = await requestSearch(configuredType);
|
||||
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 [];
|
||||
}
|
||||
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 });
|
||||
|
||||
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -5187,7 +5187,7 @@ class PipelineService extends EventEmitter {
|
||||
...previousJobProgress,
|
||||
state: previousJobProgress.state || this.snapshot.state,
|
||||
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,
|
||||
context: {
|
||||
...(previousJobProgress.context && typeof previousJobProgress.context === 'object'
|
||||
@@ -5434,6 +5434,54 @@ class PipelineService extends EventEmitter {
|
||||
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) {
|
||||
const sourceRawPath = String(rawPath || '').trim();
|
||||
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) {
|
||||
const existing = this.cdDrives.get(devicePath) || {
|
||||
devicePath,
|
||||
@@ -6059,6 +6114,7 @@ class PipelineService extends EventEmitter {
|
||||
source: owner.source,
|
||||
reason: owner.reason
|
||||
});
|
||||
this._broadcastPipelineStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6080,6 +6136,7 @@ class PipelineService extends EventEmitter {
|
||||
jobId: targetJobId,
|
||||
reason: String(options?.reason || '').trim() || null
|
||||
});
|
||||
this._broadcastPipelineStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7599,8 +7656,18 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
async updateProgress(stage, percent, eta, statusText, jobIdOverride = null, options = {}) {
|
||||
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 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 progressOptions = options && typeof options === 'object' ? options : {};
|
||||
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.
|
||||
if (effectiveJobId != null) {
|
||||
const previousJobProgress = this.jobProgress.get(effectiveJobId) || {};
|
||||
const mergedContext = contextPatch
|
||||
? {
|
||||
...(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.
|
||||
const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
|
||||
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) {
|
||||
return this.startPreparedJob(jobId, { ...options, immediate: true });
|
||||
}
|
||||
@@ -9999,6 +10071,52 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
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, {
|
||||
status: 'RIPPING',
|
||||
last_state: 'RIPPING',
|
||||
@@ -12864,6 +12982,15 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }, 'rip').catch(() => null);
|
||||
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, {
|
||||
stage: 'RIPPING',
|
||||
source: 'MAKEMKV_RIP',
|
||||
@@ -14420,7 +14547,7 @@ class PipelineService extends EventEmitter {
|
||||
} else if (detail) {
|
||||
const jobEntry = this.jobProgress.get(Number(normalizedJobId));
|
||||
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);
|
||||
void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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
|
||||
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);
|
||||
|
||||
@@ -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" }
|
||||
```
|
||||
@@ -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
@@ -54,11 +54,35 @@ API-Prefix: `/api`
|
||||
|
||||
[: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)
|
||||
|
||||
|
||||
@@ -242,6 +242,48 @@ Wird ausgelöst, wenn eine Aktivität gestartet, aktualisiert oder abgeschlossen
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
# 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:
|
||||
|
||||
- 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)
|
||||
- Retry/Re-Encode/Restart-Flows
|
||||
- WebSocket-Broadcasts für State/Progress/Queue
|
||||
- Converter-Job-Verwaltung (`createConverterJobFromEntry`, `uploadConverterFiles`, `startConverterJob`)
|
||||
|
||||
Wichtige Methoden:
|
||||
|
||||
@@ -29,6 +66,8 @@ Wichtige Methoden:
|
||||
- `restartEncodeWithLastSettings()`
|
||||
- `resumeReadyToEncodeJob()`
|
||||
- `enqueueNonJobEntry()`, `reorderQueue()`, `removeQueueEntry()`
|
||||
- `createConverterJobFromEntry()`, `createConverterJobsFromSelection()`
|
||||
- `uploadConverterFiles()`, `startConverterJob()`
|
||||
|
||||
---
|
||||
|
||||
@@ -57,9 +96,10 @@ Features:
|
||||
- `getCategorizedSettings()` für UI-Form
|
||||
- `setSettingValue()` / `setSettingsBulk()`
|
||||
- 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`
|
||||
- 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`
|
||||
|
||||
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)
|
||||
- `websocketService.js` (Client-Registry + Broadcast)
|
||||
- `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)
|
||||
|
||||
---
|
||||
@@ -133,9 +232,10 @@ Vollständige API-Dokumentation: [Runtime Activities API](../api/runtime-activit
|
||||
Beim Start:
|
||||
|
||||
1. DB init/migrate
|
||||
2. Pipeline-Init
|
||||
2. Pipeline-Init (inkl. Plugin-Registry)
|
||||
3. Cron-Init
|
||||
4. Express-Routes + Error-Handler
|
||||
5. WebSocket-Server auf `/ws`
|
||||
6. Hardware-Monitoring-Init
|
||||
7. Disk-Detection-Start
|
||||
8. Converter-Scan-Service-Init (Polling falls aktiviert)
|
||||
|
||||
@@ -10,6 +10,8 @@ Ripster verwendet SQLite (`backend/data/ripster.db`).
|
||||
settings_schema
|
||||
settings_values
|
||||
jobs
|
||||
job_lineage_artifacts
|
||||
job_output_folders
|
||||
pipeline_state
|
||||
scripts
|
||||
script_chains
|
||||
@@ -17,6 +19,9 @@ script_chain_steps
|
||||
user_presets
|
||||
cron_jobs
|
||||
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`
|
||||
- Tool-Ausgaben: `makemkv_info_json`, `handbrake_info_json`, `mediainfo_info_json`, `encode_plan_json`
|
||||
- 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`
|
||||
|
||||
---
|
||||
|
||||
## `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`
|
||||
|
||||
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
|
||||
|
||||
Beim Start werden Schema und Settings-Metadaten automatisch abgeglichen.
|
||||
@@ -107,6 +179,8 @@ Bei korruptem SQLite-File:
|
||||
sqlite3 backend/data/ripster.db
|
||||
|
||||
.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 checksum, activation_bytes FROM aax_activation_bytes;
|
||||
SELECT rel_path, job_id FROM converter_scan_entries;
|
||||
```
|
||||
|
||||
@@ -13,10 +13,11 @@ Pipeline-Steuerung:
|
||||
- Status/Progress/ETA
|
||||
- Metadaten-Dialog
|
||||
- Playlist-Entscheidung
|
||||
- Review-Panel
|
||||
- Review-Panel (Track-Auswahl)
|
||||
- Queue-Interaktion (reorder/add/remove)
|
||||
- Job-Aktionen (Start/Cancel/Retry/Re-Encode)
|
||||
- Hardware-Monitoring-Anzeige
|
||||
- Aktivitäts-Tracking (Skripte, Ketten, Cron)
|
||||
|
||||
### `SettingsPage.jsx`
|
||||
|
||||
@@ -36,15 +37,46 @@ Historie:
|
||||
- OMDb-Nachzuweisung
|
||||
- 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
|
||||
|
||||
- `PipelineStatusCard.jsx`
|
||||
- `MetadataSelectionDialog.jsx`
|
||||
- `MediaInfoReviewPanel.jsx`
|
||||
- `JobDetailDialog.jsx`
|
||||
- `CronJobsTab.jsx`
|
||||
- `PipelineStatusCard.jsx` — Pipeline-Status-Anzeige mit Progress
|
||||
- `MetadataSelectionDialog.jsx` — OMDb-Metadaten-Auswahl
|
||||
- `MediaInfoReviewPanel.jsx` — Track-Auswahl-Interface (Video/Audio/Untertitel)
|
||||
- `JobDetailDialog.jsx` — Job-Detailansicht mit Logs
|
||||
- `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`
|
||||
- Disk erkannt / Disk entfernt
|
||||
- `HARDWARE_MONITOR_UPDATE`
|
||||
- `DOWNLOADS_UPDATED`
|
||||
- `CONVERTER_SCAN_UPDATE`
|
||||
- `RUNTIME_ACTIVITY_CHANGED`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,16 +13,31 @@ Viele Felder sind pro Medientyp getrennt vorhanden:
|
||||
- Blu-ray
|
||||
- DVD
|
||||
- Sonstiges
|
||||
- Audiobook (neu ab v0.12.0)
|
||||
|
||||
---
|
||||
|
||||
## Template-Platzhalter
|
||||
|
||||
Datei-/Ordner-Templates unterstützen:
|
||||
### Video-Templates
|
||||
- `${title}` — Titel
|
||||
- `${year}` — Erscheinungsjahr
|
||||
- `${imdbId}` — IMDb-ID
|
||||
|
||||
- `${title}`
|
||||
- `${year}`
|
||||
- `${imdbId}`
|
||||
### Audiobook-Templates
|
||||
- `{author}` — Autor
|
||||
- `{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`.
|
||||
|
||||
@@ -47,6 +62,17 @@ Nicht gesetzte Werte werden zu `unknown`.
|
||||
| `Eigentümer Film-Ordner (DVD)` | string | `null` |
|
||||
| `Eigentümer Film-Ordner (Sonstiges)` | string | `null` |
|
||||
| `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` |
|
||||
| `Device Pfad` | path | `/dev/sr0` | relevant bei `Explizites Device` |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
@@ -79,6 +106,8 @@ Nicht gesetzte Werte werden zu `unknown`.
|
||||
| `Mediainfo Kommando` | string | `mediainfo` |
|
||||
| `Minimale Titellaenge (Minuten)` | number | `60` |
|
||||
| `HandBrake Kommando` | string | `HandBrakeCLI` |
|
||||
| `FFmpeg Kommando` | string | `ffmpeg` |
|
||||
| `FFprobe Kommando` | string | `ffprobe` |
|
||||
| `Encode-Neustart: unvollständige Ausgabe löschen` | boolean | `true` |
|
||||
| `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
|
||||
|
||||
| Feldname in der GUI | Typ | Default |
|
||||
|
||||
@@ -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)` |
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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 |
|
||||
| [Settings](settings.md) | Konfiguration, Skripte, Ketten, Presets, Cronjobs |
|
||||
| [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 |
|
||||
|
||||
## Empfohlene Nutzung im Alltag
|
||||
|
||||
1. **Start eines neuen Jobs:** `Dashboard`
|
||||
2. **Regeln/Automatisierung anpassen:** `Settings`
|
||||
3. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie`
|
||||
4. **Sonderfälle/Recovery:** `Database`
|
||||
1. **Start eines neuen Disc-Jobs:** `Dashboard`
|
||||
2. **Dateien konvertieren oder Audiobooks verarbeiten:** `Converter`
|
||||
3. **Regeln/Automatisierung anpassen:** `Settings`
|
||||
4. **Ergebnisse prüfen oder Jobs nachbearbeiten:** `Historie`
|
||||
5. **Dateien herunterladen:** `Downloads`
|
||||
6. **Sonderfälle/Recovery:** `Database`
|
||||
|
||||
## 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`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,6 +48,7 @@ const NOTIFICATION_EVENT_TOGGLE_KEYS = new Set([
|
||||
|
||||
const PUSHOVER_ENABLED_SETTING_KEY = 'pushover_enabled';
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
const EXTERNAL_STORAGE_PATHS_KEY = 'external_storage_paths';
|
||||
const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
|
||||
'drive_device',
|
||||
'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() {
|
||||
const [drives, setDrives] = useState(null);
|
||||
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 DOWNLOAD_PATH_KEYS = ['download_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'];
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
@@ -432,7 +524,9 @@ function SettingField({
|
||||
</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
|
||||
id={setting.key}
|
||||
value={value ?? ''}
|
||||
@@ -448,6 +542,14 @@ function SettingField({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.key === EXTERNAL_STORAGE_PATHS_KEY ? (
|
||||
<ExternalStoragePathsEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
settingKey={setting.key}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{setting.type === 'number' ? (
|
||||
<InputNumber
|
||||
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 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 externalStorageSettings = list.filter((s) => EXTERNAL_STORAGE_PATH_KEYS.includes(s.key));
|
||||
const converterSettings = list.filter((s) => CONVERTER_PATH_KEYS.includes(s.key));
|
||||
|
||||
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: 'Converter', pathSettings: converterSettings },
|
||||
{ title: 'Downloads', pathSettings: downloadSettings },
|
||||
...(externalStorageSettings.length > 0 ? [{ title: 'Externe Speicher', pathSettings: externalStorageSettings }] : []),
|
||||
...(logSettings.length > 0 ? [{ title: 'Logs', pathSettings: logSettings }] : [])
|
||||
]
|
||||
.filter(({ pathSettings }) =>
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function MetadataSelectionDialog({
|
||||
const [manualYear, setManualYear] = useState('');
|
||||
const [manualImdb, setManualImdb] = useState('');
|
||||
const [extraResults, setExtraResults] = useState([]);
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
@@ -36,6 +37,7 @@ export default function MetadataSelectionDialog({
|
||||
setManualYear(defaultYear);
|
||||
setManualImdb(defaultImdb);
|
||||
setExtraResults([]);
|
||||
setSearchBusy(false);
|
||||
}, [visible, context]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
@@ -67,11 +69,17 @@ export default function MetadataSelectionDialog({
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
const trimmedQuery = String(query || '').trim();
|
||||
if (!trimmedQuery || typeof onSearch !== 'function') {
|
||||
return;
|
||||
}
|
||||
const results = await onSearch(query.trim());
|
||||
setExtraResults(results || []);
|
||||
setSearchBusy(true);
|
||||
try {
|
||||
const results = await onSearch(trimmedQuery);
|
||||
setExtraResults(Array.isArray(results) ? results : []);
|
||||
} finally {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -110,9 +118,15 @@ export default function MetadataSelectionDialog({
|
||||
<InputText
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void handleSearch();
|
||||
}
|
||||
}}
|
||||
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 className="table-scroll-wrap table-scroll-medium">
|
||||
|
||||
@@ -59,6 +59,10 @@ function normalizeJobId(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeDrivePath(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function formatPercent(value, digits = 1) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -1508,8 +1512,17 @@ export default function DashboardPage({
|
||||
};
|
||||
|
||||
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
|
||||
.filter((drv) => {
|
||||
if (lockedPaths.has(normalizeDrivePath(drv.path))) {
|
||||
return false;
|
||||
}
|
||||
const cdState = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
|
||||
if (cdState) return cdState === 'DISC_DETECTED';
|
||||
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 contextDevice = pipeline?.context?.device;
|
||||
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
|
||||
const allDrives = useMemo(() => {
|
||||
@@ -2621,7 +2654,14 @@ export default function DashboardPage({
|
||||
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
||||
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
|
||||
const pipelineState = String(drv.pipelineState || '').toUpperCase();
|
||||
const driveLockEntry = driveLocksByPath.get(normalizeDrivePath(drivePath)) || null;
|
||||
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
|
||||
? cdState === 'DISC_DETECTED'
|
||||
: (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc);
|
||||
@@ -2645,45 +2685,51 @@ export default function DashboardPage({
|
||||
<i className={`pi ${stateIconMeta.icon}${stateIconMeta.spin ? ' pi-spin' : ''}`} aria-hidden="true" />
|
||||
</span>
|
||||
) : 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 className="drive-list-actions">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
className="drive-list-action-btn"
|
||||
loading={isRescanBusy}
|
||||
disabled={isRescanBusy || isDriveActive || isCdDriveLocked}
|
||||
onClick={() => handleRescanDrive(drivePath)}
|
||||
title="Laufwerk neu lesen"
|
||||
aria-label="Laufwerk neu lesen"
|
||||
/>
|
||||
{canAnalyzeDrive && (
|
||||
{isDriveLocked ? (
|
||||
<Button
|
||||
icon="pi pi-search"
|
||||
icon="pi pi-lock"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
className="drive-list-action-btn"
|
||||
loading={isAnalyzeBusy}
|
||||
disabled={isAnalyzeBusy}
|
||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||
title="Disk analysieren"
|
||||
aria-label="Disk analysieren"
|
||||
disabled
|
||||
title={lockTitle}
|
||||
aria-label="Laufwerk gesperrt"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
className="drive-list-action-btn"
|
||||
loading={isRescanBusy}
|
||||
disabled={isRescanBusy || isDriveActive}
|
||||
onClick={() => handleRescanDrive(drivePath)}
|
||||
title="Laufwerk neu lesen"
|
||||
aria-label="Laufwerk neu lesen"
|
||||
/>
|
||||
{canAnalyzeDrive && (
|
||||
<Button
|
||||
icon="pi pi-search"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="secondary"
|
||||
className="drive-list-action-btn"
|
||||
loading={isAnalyzeBusy}
|
||||
disabled={isAnalyzeBusy}
|
||||
onClick={() => handleAnalyzeForDrive(drivePath)}
|
||||
title="Disk analysieren"
|
||||
aria-label="Disk analysieren"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{cdState === 'CD_METADATA_SELECTION' && (
|
||||
<Button
|
||||
|
||||
@@ -1856,6 +1856,35 @@ body {
|
||||
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 */
|
||||
/* Unified per-drive list (Disk-Information card) */
|
||||
.drive-list {
|
||||
|
||||
@@ -56,6 +56,8 @@ nav:
|
||||
- Dashboard: gui/dashboard.md
|
||||
- Settings: gui/settings.md
|
||||
- Historie: gui/history.md
|
||||
- Converter: gui/converter.md
|
||||
- Downloads: gui/downloads.md
|
||||
- Database (Expert): gui/database.md
|
||||
- Workflows aus Nutzersicht: workflows/index.md
|
||||
- Technischer Anhang:
|
||||
@@ -76,6 +78,8 @@ nav:
|
||||
- Settings API: api/settings.md
|
||||
- History API: api/history.md
|
||||
- Cron API: api/crons.md
|
||||
- Converter API: api/converter.md
|
||||
- Downloads API: api/downloads.md
|
||||
- Runtime Activities API: api/runtime-activities.md
|
||||
- WebSocket Events: api/websocket.md
|
||||
- Architektur:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.12.0-16",
|
||||
"version": "0.12.0-17",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user