From c67ac593de45898f957874a7a79ea1e4a875c245 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Mon, 30 Mar 2026 19:15:23 +0000 Subject: [PATCH] 0.12.0-17 Misc fixes --- .github/workflows/docs.yml | 37 ++- README.md | 74 +++-- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db/database.js | 19 ++ .../src/services/hardwareMonitorService.js | 123 +++++++- backend/src/services/omdbService.js | 93 ++++-- backend/src/services/pipelineService.js | 137 ++++++++- db/schema.sql | 4 + docs/api/converter.md | 290 ++++++++++++++++++ docs/api/downloads.md | 143 +++++++++ docs/api/index.md | 28 +- docs/api/websocket.md | 42 +++ docs/architecture/backend.md | 108 ++++++- docs/architecture/database.md | 76 ++++- docs/architecture/frontend.md | 47 ++- docs/configuration/settings-reference.md | 47 ++- docs/gui/converter.md | 91 ++++++ docs/gui/downloads.md | 53 ++++ docs/gui/index.md | 16 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/components/DynamicSettingsForm.jsx | 106 ++++++- .../components/MetadataSelectionDialog.jsx | 22 +- frontend/src/pages/DashboardPage.jsx | 106 +++++-- frontend/src/styles/app.css | 29 ++ mkdocs.yml | 4 + package-lock.json | 4 +- package.json | 2 +- 29 files changed, 1596 insertions(+), 117 deletions(-) create mode 100644 docs/api/converter.md create mode 100644 docs/api/downloads.md create mode 100644 docs/gui/converter.md create mode 100644 docs/gui/downloads.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6192b56..b246c1c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/README.md b/README.md index 6de0c68..bacee0e 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/backend/package-lock.json b/backend/package-lock.json index 61b9b2a..c9b50ef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index ef74898..0927322 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.12.0-16", + "version": "0.12.0-17", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 4158108..45346ed 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -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)` diff --git a/backend/src/services/hardwareMonitorService.js b/backend/src/services/hardwareMonitorService.js index ccec11d..ae3e533 100644 --- a/backend/src/services/hardwareMonitorService.js +++ b/backend/src/services/hardwareMonitorService.js @@ -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, diff --git a/backend/src/services/omdbService.js b/backend/src/services/omdbService.js index 025fb58..781c5c0 100644 --- a/backend/src/services/omdbService.js +++ b/backend/src/services/omdbService.js @@ -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; } diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index b1a8e75..fda8f81 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -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); } diff --git a/db/schema.sql b/db/schema.sql index fd2a7bd..3c90272 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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); diff --git a/docs/api/converter.md b/docs/api/converter.md new file mode 100644 index 0000000..8900a07 --- /dev/null +++ b/docs/api/converter.md @@ -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" } +``` diff --git a/docs/api/downloads.md b/docs/api/downloads.md new file mode 100644 index 0000000..7144bb0 --- /dev/null +++ b/docs/api/downloads.md @@ -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). diff --git a/docs/api/index.md b/docs/api/index.md index 637e681..8896e31 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -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) diff --git a/docs/api/websocket.md b/docs/api/websocket.md index 3fd802e..d3507d1 100644 --- a/docs/api/websocket.md +++ b/docs/api/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 diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 21f092b..67c5ec1 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -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) diff --git a/docs/architecture/database.md b/docs/architecture/database.md index ccf406c..15f1577 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -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; ``` diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index ed8bedc..8cbb75a 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -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` --- diff --git a/docs/configuration/settings-reference.md b/docs/configuration/settings-reference.md index 586a3fb..f0949b6 100644 --- a/docs/configuration/settings-reference.md +++ b/docs/configuration/settings-reference.md @@ -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 | diff --git a/docs/gui/converter.md b/docs/gui/converter.md new file mode 100644 index 0000000..2d96c25 --- /dev/null +++ b/docs/gui/converter.md @@ -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)` | diff --git a/docs/gui/downloads.md b/docs/gui/downloads.md new file mode 100644 index 0000000..10e79f9 --- /dev/null +++ b/docs/gui/downloads.md @@ -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__.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. diff --git a/docs/gui/index.md b/docs/gui/index.md index 3f9fcdc..ecb6013 100644 --- a/docs/gui/index.md +++ b/docs/gui/index.md @@ -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`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08289e8..fc65bde 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 251d84a..cac3071 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.12.0-16", + "version": "0.12.0-17", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx index 081b29a..e321511 100644 --- a/frontend/src/components/DynamicSettingsForm.jsx +++ b/frontend/src/components/DynamicSettingsForm.jsx @@ -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 ( +
+ {entries.length === 0 && ( +

Keine externen Speicherpfade konfiguriert.

+ )} + {entries.map((entry, idx) => ( +
+ { + const next = [...entries]; + next[idx] = { ...next[idx], name: event.target.value }; + updatePaths(next); + }} + className="external-storage-name-input" + /> + { + const next = [...entries]; + next[idx] = { ...next[idx], path: event.target.value }; + updatePaths(next); + }} + className="external-storage-input" + /> +
+ ))} +
+ ); +} + 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({ )} - {(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 ? ( ) : null} + {setting.key === EXTERNAL_STORAGE_PATHS_KEY ? ( + + ) : null} + {setting.type === 'number' ? ( 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 }) => diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index c6eb335..0b9a447 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -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({ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void handleSearch(); + } + }} placeholder="Titel suchen" /> -