diff --git a/backend/package-lock.json b/backend/package-lock.json index 02c8d2b..d089a81 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.1-7", + "version": "0.13.1-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1-7", + "version": "0.13.1-8", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 1dfac8e..5283669 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.1-7", + "version": "0.13.1-8", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 7493541..077fc51 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -425,6 +425,17 @@ router.post( }) ); +router.post( + '/jobs/:jobId/raw-decision', + asyncHandler(async (req, res) => { + const jobId = Number(req.params.jobId); + const { decision } = req.body; + logger.info('post:raw-decision', { reqId: req.reqId, jobId, decision }); + const result = await pipelineService.submitRawDecision(jobId, decision); + res.json({ result }); + }) +); + router.post( '/start/:jobId', asyncHandler(async (req, res) => { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 5b110a8..25f8cba 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -13692,6 +13692,83 @@ class PipelineService extends EventEmitter { return this.runMediainfoReviewForJob(jobId, rawPath, options); } + + async submitRawDecision(jobId, decision) { + const historyService = require('./historyService'); // already required at top? wait, pipelineService requires historyService. We can just use historyService directly. + const job = await historyService.getJobById(jobId); + if (!job) throw new Error('Job nicht gefunden.'); + + const isWaiting = ( + String(job.status || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' || + String(job.last_state || '').trim().toUpperCase() === 'WAITING_FOR_USER_DECISION' + ); + if (!isWaiting) { + throw new Error(`Job ist nicht im Status WAITING_FOR_USER_DECISION (${job.status})`); + } + + if (decision === 'delete') { + const fsOptions = { recursive: true, force: true }; + if (job.raw_path && require('fs').existsSync(job.raw_path)) { + await require('fs').promises.rm(job.raw_path, fsOptions); + await historyService.appendLog(jobId, 'SYSTEM', `RAW-Verzeichnis wurde auf Benutzerwunsch gelöscht: ${job.raw_path}`); + } + } else if (decision === 'continue') { + await historyService.appendLog(jobId, 'SYSTEM', `Benutzer hat "Mit vorhandenem RAW weiter machen" gewählt.`); + } else if (decision === 'cancel') { + await historyService.appendLog(jobId, 'SYSTEM', `Benutzer hat auf Grund vorhandenem RAW abgebrochen.`); + await this.cancelJob(jobId); + return historyService.getJobById(jobId); + } else { + throw new Error(`Unbekannte Entscheidung: ${decision}`); + } + + const mkInfo = this.safeParseJson(job.makemkv_info_json); + const currentAnalyzeContext = mkInfo?.analyzeContext || {}; + const playlistDecisionRequired = Boolean(currentAnalyzeContext.playlistDecisionRequired); + const selectedTitleId = currentAnalyzeContext.selectedTitleId ?? null; + const requiresManualPlaylistSelection = Boolean(playlistDecisionRequired && selectedTitleId === null); + + const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; + + await this.setState(nextStatus, { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : (decision === 'continue' + ? 'Metadaten übernommen - vorhandenes RAW erkannt' + : 'Metadaten übernommen - bereit zum Start'), + context: { + ...(this.snapshot.context || {}), + waitingForRawDecision: false, + manualDecisionState: requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : null + } + }); + + if (requiresManualPlaylistSelection) { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor Backup/Encoding gestartet wird.' + ); + return historyService.getJobById(jobId); + } + + if (decision === 'continue') { + await historyService.appendLog(jobId, 'SYSTEM', `Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${job.raw_path}`); + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + + await historyService.appendLog(jobId, 'SYSTEM', 'RAW gelöscht oder ignoriert. Starte Backup/Rip automatisch.'); + await this.startPreparedJob(jobId); + return historyService.getJobById(jobId); + } + + async selectMetadata({ jobId, title, @@ -14515,11 +14592,12 @@ class PipelineService extends EventEmitter { recommendation: null } : basePlaylistDecision; + const isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; const requiresManualPlaylistSelection = Boolean( playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null ); - const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; - const isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import'; + const requiresRawDecision = shouldAutoReviewFromRaw && !isRawImportMetadataJob; + const nextStatus = (requiresManualPlaylistSelection || requiresRawDecision) ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ ...mkInfo, @@ -14605,11 +14683,13 @@ class PipelineService extends EventEmitter { activeJobId: jobId, progress: 0, eta: null, - statusText: requiresManualPlaylistSelection - ? 'waiting_for_manual_playlist_selection' - : (shouldAutoReviewFromRaw - ? 'Metadaten übernommen - vorhandenes RAW erkannt' - : 'Metadaten übernommen - bereit zum Start'), + statusText: requiresRawDecision + ? 'waiting_for_raw_decision' + : (requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : (shouldAutoReviewFromRaw + ? 'Metadaten übernommen - vorhandenes RAW erkannt' + : 'Metadaten übernommen - bereit zum Start')), context: { ...(this.snapshot.context || {}), jobId, @@ -14622,9 +14702,12 @@ class PipelineService extends EventEmitter { selectedPlaylist: playlistDecision.selectedPlaylist || null, selectedTitleId: playlistDecision.selectedTitleId ?? null, waitingForManualPlaylistSelection: requiresManualPlaylistSelection, - manualDecisionState: requiresManualPlaylistSelection - ? 'waiting_for_manual_playlist_selection' - : null + waitingForRawDecision: requiresRawDecision, + manualDecisionState: requiresRawDecision + ? 'waiting_for_raw_decision' + : (requiresManualPlaylistSelection + ? 'waiting_for_manual_playlist_selection' + : null) } }); } else { @@ -14636,6 +14719,15 @@ class PipelineService extends EventEmitter { ); } + if (requiresRawDecision) { + await historyService.appendLog( + jobId, + 'SYSTEM', + `Vorhandenes RAW zur Disk gefunden: ${updatedRawPath}. Warte auf Entscheidung.` + ); + return historyService.getJobById(jobId); + } + if (requiresManualPlaylistSelection) { const playlistFiles = playlistDecision.candidatePlaylists .map((item) => item.playlistFile) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eabc805..1f8161b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.1-7", + "version": "0.13.1-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1-7", + "version": "0.13.1-8", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index d80fa41..0ea4379 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.1-7", + "version": "0.13.1-8", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 1d10f4f..9eec20f 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -625,6 +625,14 @@ export const api = { afterMutationInvalidate(['/history', '/pipeline/queue']); return result; }, + async submitRawDecision(jobId, decision) { + const result = await request(`/pipeline/jobs/${jobId}/raw-decision`, { + method: 'POST', + body: JSON.stringify({ decision }) + }); + afterMutationInvalidate(['/history', '/pipeline/queue']); + return result; + }, async startJob(jobId) { const result = await request(`/pipeline/start/${jobId}`, { method: 'POST' diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 3bf1cf0..f9109ef 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -1126,6 +1126,7 @@ export default function PipelineStatusCard({ onConfirmReview, onSelectPlaylist, onSelectHandBrakeTitle, + onSubmitRawDecision, onCancel, onRetry, onDeleteJob, @@ -2131,7 +2132,9 @@ export default function PipelineStatusCard({ }; }, [running, retryJobId]); - const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired; + const manualDecisionState = pipeline?.context?.manualDecisionState || null; + const rawDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && manualDecisionState === 'waiting_for_raw_decision'; + const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired && !rawDecisionRequiredBeforeStart; const isSeriesDvdReview = useMemo(() => { if (jobMediaProfile !== 'dvd') { return false; @@ -3103,6 +3106,40 @@ export default function PipelineStatusCard({ ) : null} + {rawDecisionRequiredBeforeStart && !queueLocked ? ( +