From 52ef155c7ce110283a44e6bdf652a0086eb074bc Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sun, 15 Mar 2026 10:48:01 +0000 Subject: [PATCH] 0.10.1-1 AAX Encode --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/routes/pipelineRoutes.js | 21 +++ backend/src/routes/settingsRoutes.js | 15 ++ .../src/services/activationBytesService.js | 67 ++------ backend/src/services/audiobookService.js | 6 +- backend/src/services/pipelineService.js | 56 ++++--- db/schema.sql | 1 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.js | 11 ++ frontend/src/pages/DashboardPage.jsx | 145 ++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- 14 files changed, 250 insertions(+), 90 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index fb953f7..350f545 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.10.1", + "version": "0.10.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.10.1", + "version": "0.10.1-1", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.7", diff --git a/backend/package.json b/backend/package.json index 26ee788..7897313 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.10.1", + "version": "0.10.1-1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 0a60179..b6b5e78 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -7,6 +7,8 @@ const pipelineService = require('../services/pipelineService'); const diskDetectionService = require('../services/diskDetectionService'); const hardwareMonitorService = require('../services/hardwareMonitorService'); const logger = require('../services/logger').child('PIPELINE_ROUTE'); +const activationBytesService = require('../services/activationBytesService'); +const { getDb } = require('../db/database'); const router = express.Router(); const audiobookUpload = multer({ @@ -155,6 +157,25 @@ router.post( }) ); +router.get( + '/audiobook/pending-activation', + asyncHandler(async (req, res) => { + const db = await getDb(); + // Jobs die eine Checksum haben, aber noch keine Activation Bytes im Cache + const pending = await db.all(` + SELECT j.id AS jobId, j.aax_checksum AS checksum + FROM jobs j + WHERE j.aax_checksum IS NOT NULL + AND j.status NOT IN ('DONE', 'ERROR', 'CANCELLED') + AND NOT EXISTS ( + SELECT 1 FROM aax_activation_bytes ab WHERE ab.checksum = j.aax_checksum + ) + ORDER BY j.created_at DESC + `); + res.json({ pending }); + }) +); + router.post( '/audiobook/start/:jobId', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 8c4ecf3..83147ea 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -385,4 +385,19 @@ router.get( }) ); +router.post( + '/activation-bytes', + asyncHandler(async (req, res) => { + const { checksum, activationBytes } = req.body || {}; + if (!checksum || !activationBytes) { + const error = new Error('checksum und activationBytes sind erforderlich'); + error.statusCode = 400; + throw error; + } + logger.debug('post:settings:activation-bytes', { reqId: req.reqId, checksum }); + const saved = await activationBytesService.saveActivationBytes(checksum, activationBytes); + res.json({ success: true, checksum, activationBytes: saved }); + }) +); + module.exports = router; diff --git a/backend/src/services/activationBytesService.js b/backend/src/services/activationBytesService.js index ff04dbf..450cd8d 100644 --- a/backend/src/services/activationBytesService.js +++ b/backend/src/services/activationBytesService.js @@ -1,13 +1,11 @@ const fs = require('fs'); const crypto = require('crypto'); -const https = require('https'); const { getDb } = require('../db/database'); const logger = require('./logger').child('ActivationBytes'); const FIXED_KEY = Buffer.from([0x77, 0x21, 0x4d, 0x4b, 0x19, 0x6a, 0x87, 0xcd, 0x52, 0x00, 0x45, 0xfd, 0x20, 0xa5, 0x1d, 0x67]); const AAX_CHECKSUM_OFFSET = 653; const AAX_CHECKSUM_LENGTH = 20; -const AUDIBLE_TOOLS_API = 'https://aaxapiserverfunction20220831180001.azurewebsites.net'; function sha1(data) { return crypto.createHash('sha1').update(data).digest(); @@ -41,69 +39,36 @@ async function lookupCached(checksum) { return row ? row.activation_bytes : null; } -async function saveToCache(checksum, activationBytes) { +async function saveActivationBytes(checksum, activationBytesHex) { + const normalized = String(activationBytesHex || '').trim().toLowerCase(); + if (!/^[0-9a-f]{8}$/.test(normalized)) { + throw new Error('Activation Bytes müssen genau 8 Hex-Zeichen (4 Bytes) sein'); + } + if (!verifyActivationBytes(normalized, checksum)) { + throw new Error('Activation Bytes passen nicht zur Checksum – bitte nochmals prüfen'); + } const db = await getDb(); await db.run( - 'INSERT OR IGNORE INTO aax_activation_bytes (checksum, activation_bytes) VALUES (?, ?)', + 'INSERT OR REPLACE INTO aax_activation_bytes (checksum, activation_bytes) VALUES (?, ?)', checksum, - activationBytes + normalized ); -} - -function fetchFromApi(checksum) { - return new Promise((resolve, reject) => { - const url = `${AUDIBLE_TOOLS_API}/api/v2/activation/${checksum}`; - https.get(url, (res) => { - let data = ''; - res.on('data', chunk => { data += chunk; }); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch { - reject(new Error('Ungültige API-Antwort')); - } - }); - }).on('error', reject); - }); + logger.info({ checksum, activationBytes: normalized }, 'Activation Bytes manuell gespeichert'); + return normalized; } async function resolveActivationBytes(filePath) { const checksum = readAaxChecksum(filePath); logger.info({ checksum }, 'AAX Checksum gelesen'); - // 1. Cache prüfen const cached = await lookupCached(checksum); if (cached) { logger.info({ checksum }, 'Activation Bytes aus lokalem Cache'); - return { checksum, activationBytes: cached, source: 'cache' }; + return { checksum, activationBytes: cached }; } - // 2. Audible-Tools API anfragen - logger.info({ checksum }, 'Frage Audible-Tools API an...'); - let activationBytes = null; - try { - const result = await fetchFromApi(checksum); - if (result.success === true && result.activationBytes) { - if (verifyActivationBytes(result.activationBytes, checksum)) { - activationBytes = result.activationBytes; - logger.info({ checksum, activationBytes }, 'Activation Bytes via API verifiziert'); - } else { - logger.warn({ checksum }, 'API-Antwort konnte nicht verifiziert werden'); - } - } else { - logger.warn({ checksum }, 'Checksum der API unbekannt'); - } - } catch (err) { - logger.warn({ checksum, err: err.message }, 'API nicht erreichbar'); - } - - if (!activationBytes) { - throw new Error(`Activation Bytes für Checksum ${checksum} nicht gefunden (API unbekannt oder nicht erreichbar)`); - } - - // 3. Lokal cachen - await saveToCache(checksum, activationBytes); - return { checksum, activationBytes, source: 'api' }; + logger.info({ checksum }, 'Keine Activation Bytes im Cache – manuelle Eingabe erforderlich'); + return { checksum, activationBytes: null }; } async function listCachedEntries() { @@ -111,4 +76,4 @@ async function listCachedEntries() { return db.all('SELECT checksum, activation_bytes, created_at FROM aax_activation_bytes ORDER BY created_at DESC'); } -module.exports = { resolveActivationBytes, readAaxChecksum, listCachedEntries }; +module.exports = { resolveActivationBytes, readAaxChecksum, saveActivationBytes, verifyActivationBytes, listCachedEntries }; diff --git a/backend/src/services/audiobookService.js b/backend/src/services/audiobookService.js index fb25682..74286d4 100644 --- a/backend/src/services/audiobookService.js +++ b/backend/src/services/audiobookService.js @@ -618,6 +618,7 @@ function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = const extra = options && typeof options === 'object' ? options : {}; const commonArgs = [ '-y', + ...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []), '-i', inputPath ]; if (extra.chapterMetadataPath) { @@ -657,11 +658,13 @@ function buildChapterEncodeCommand( formatOptions = {}, metadata = {}, chapter = {}, - chapterTotal = 1 + chapterTotal = 1, + options = {} ) { const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg'; const format = normalizeOutputFormat(outputFormat); const normalizedOptions = normalizeFormatOptions(format, formatOptions); + const extra = options && typeof options === 'object' ? options : {}; const safeChapter = normalizeChapterList([chapter], { durationMs: metadata?.durationMs, fallbackTitle: metadata?.title || 'Kapitel', @@ -679,6 +682,7 @@ function buildChapterEncodeCommand( cmd, args: [ '-y', + ...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []), '-i', inputPath, '-ss', formatSecondsArg(safeChapter?.startSeconds), '-t', formatSecondsArg(durationSeconds), diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 26fadc3..e59fc40 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -10947,25 +10947,22 @@ class PipelineService extends EventEmitter { stagedRawFilePath }); - // Activation Bytes: erst Cache prüfen, dann einmalig Azure-API anfragen und persistent speichern + // Activation Bytes: Cache prüfen und Checksum am Job speichern + let aaxChecksum = null; + let aaxNeedsActivationBytes = false; try { - const { checksum, activationBytes, source } = await activationBytesService.resolveActivationBytes(stagedRawFilePath); - await historyService.appendLog( - job.id, - 'SYSTEM', - `Activation Bytes aufgelöst (${source}): checksum=${checksum} bytes=${activationBytes}` - ); - logger.info('audiobook:upload:activation-bytes', { jobId: job.id, checksum, activationBytes, source }); + const abResult = await activationBytesService.resolveActivationBytes(stagedRawFilePath); + aaxChecksum = abResult.checksum; + await historyService.updateJob(job.id, { aax_checksum: aaxChecksum }); + if (abResult.activationBytes) { + await historyService.appendLog(job.id, 'SYSTEM', `Activation Bytes im Cache gefunden: checksum=${abResult.checksum}`); + logger.info('audiobook:upload:activation-bytes', { jobId: job.id, checksum: abResult.checksum, source: 'cache' }); + } else { + aaxNeedsActivationBytes = true; + logger.info('audiobook:upload:activation-bytes-needed', { jobId: job.id, checksum: abResult.checksum }); + } } catch (abError) { - logger.warn('audiobook:upload:activation-bytes-failed', { - jobId: job.id, - error: errorToMeta(abError) - }); - await historyService.appendLog( - job.id, - 'SYSTEM', - `Activation Bytes konnten nicht aufgelöst werden: ${abError?.message || 'unknown'}` - ).catch(() => {}); + logger.warn('audiobook:upload:activation-bytes-failed', { jobId: job.id, error: errorToMeta(abError) }); } let detectedAsin = null; @@ -11104,7 +11101,8 @@ class PipelineService extends EventEmitter { jobId: job.id, started: false, queued: false, - stage: 'READY_TO_START' + stage: 'READY_TO_START', + ...(aaxNeedsActivationBytes ? { needsActivationBytes: true, checksum: aaxChecksum } : {}) }; } @@ -11401,6 +11399,22 @@ class PipelineService extends EventEmitter { let temporaryChapterMetadataPath = null; + // Activation Bytes für AAX-Dateien aus Cache lesen + let encodeActivationBytes = null; + if (path.extname(inputPath).toLowerCase() === '.aax') { + try { + const abResult = await activationBytesService.resolveActivationBytes(inputPath); + encodeActivationBytes = abResult.activationBytes || null; + if (!encodeActivationBytes) { + throw new Error('Activation Bytes nicht im Cache – bitte zuerst über den Upload-Dialog eintragen'); + } + logger.info('audiobook:encode:activation-bytes', { jobId, checksum: abResult.checksum }); + } catch (abError) { + logger.error('audiobook:encode:activation-bytes-failed', { jobId, error: errorToMeta(abError) }); + throw abError; + } + } + try { let ffmpegRunInfo = null; if (isSplitOutput) { @@ -11451,7 +11465,8 @@ class PipelineService extends EventEmitter { formatOptions, metadata, chapter, - outputFiles.length + outputFiles.length, + { activationBytes: encodeActivationBytes } ); const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0); const scaledParser = baseParser @@ -11525,7 +11540,8 @@ class PipelineService extends EventEmitter { formatOptions, { chapterMetadataPath: temporaryChapterMetadataPath, - metadata + metadata, + activationBytes: encodeActivationBytes } ); logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args }); diff --git a/db/schema.sql b/db/schema.sql index f705ca4..193e285 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -47,6 +47,7 @@ CREATE TABLE jobs ( encode_plan_json TEXT, encode_input_path TEXT, encode_review_confirmed INTEGER DEFAULT 0, + aax_checksum TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2551bbc..37d3f47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.10.1", + "version": "0.10.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.10.1", + "version": "0.10.1-1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 47a2894..fe35a31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.10.1", + "version": "0.10.1-1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index d6c1672..06a741d 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -247,6 +247,17 @@ export const api = { forceRefresh: options.forceRefresh ?? true }); }, + async saveActivationBytes(checksum, activationBytes) { + const result = await request('/settings/activation-bytes', { + method: 'POST', + body: JSON.stringify({ checksum, activationBytes }) + }); + afterMutationInvalidate(['/settings/activation-bytes']); + return result; + }, + getPendingActivation() { + return request('/pipeline/audiobook/pending-activation'); + }, getHandBrakePresets(options = {}) { return requestCachedGet('/settings/handbrake-presets', { ttlMs: 10 * 60 * 1000, diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 9a273ef..38ea2cd 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -7,6 +7,7 @@ import { Tag } from 'primereact/tag'; import { ProgressBar } from 'primereact/progressbar'; import { Dialog } from 'primereact/dialog'; import { InputNumber } from 'primereact/inputnumber'; +import { InputText } from 'primereact/inputtext'; import { api } from '../api/client'; import PipelineStatusCard from '../components/PipelineStatusCard'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; @@ -805,6 +806,10 @@ export default function DashboardPage({ const [dashboardJobs, setDashboardJobs] = useState([]); const [expandedJobId, setExpandedJobId] = useState(undefined); const [audiobookUploadFile, setAudiobookUploadFile] = useState(null); + const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null }); + const [activationBytesInput, setActivationBytesInput] = useState(''); + const [activationBytesBusy, setActivationBytesBusy] = useState(false); + const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set()); const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false); const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set()); const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] }); @@ -912,6 +917,25 @@ export default function DashboardPage({ } setDashboardJobs(deduped); + + // Prüfen ob Audiobook-Jobs auf Activation Bytes warten + try { + const { pending } = await api.getPendingActivation(); + if (Array.isArray(pending) && pending.length > 0) { + setPendingActivationJobIds(new Set(pending.map((p) => p.jobId))); + // Modal automatisch öffnen wenn noch nicht sichtbar + setActivationBytesDialog((prev) => { + if (prev.visible) return prev; + const first = pending[0]; + setActivationBytesInput(''); + return { visible: true, checksum: first.checksum, jobId: first.jobId }; + }); + } else { + setPendingActivationJobIds(new Set()); + } + } catch (_err) { + // ignorieren + } } catch (_error) { setDashboardJobs([]); } finally { @@ -1376,8 +1400,12 @@ export default function DashboardPage({ } try { const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false }); - const uploadedJobId = normalizeJobId(response?.result?.jobId); - if (uploadedJobId) { + const result = response?.result || {}; + const uploadedJobId = normalizeJobId(result.jobId); + if (result.needsActivationBytes && result.checksum) { + setActivationBytesInput(''); + setActivationBytesDialog({ visible: true, checksum: result.checksum, jobId: uploadedJobId }); + } else if (uploadedJobId) { toastRef.current?.show({ severity: 'success', summary: 'Audiobook importiert', @@ -1391,11 +1419,42 @@ export default function DashboardPage({ } }; + const handleSaveActivationBytes = async () => { + const { checksum, jobId } = activationBytesDialog; + const bytes = activationBytesInput.trim().toLowerCase(); + setActivationBytesBusy(true); + try { + await api.saveActivationBytes(checksum, bytes); + setPendingActivationJobIds(new Set()); + setActivationBytesDialog({ visible: false, checksum: null, jobId: null }); + toastRef.current?.show({ + severity: 'success', + summary: 'Activation Bytes gespeichert', + detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.', + life: 4000 + }); + await loadDashboardJobs(); + } catch (error) { + showError(error); + } finally { + setActivationBytesBusy(false); + } + }; + const handleAudiobookStart = async (jobId, audiobookConfig) => { const normalizedJobId = normalizeJobId(jobId); if (!normalizedJobId) { return; } + if (pendingActivationJobIds.has(normalizedJobId)) { + setActivationBytesInput(''); + const pending = await api.getPendingActivation().catch(() => ({ pending: [] })); + const entry = (pending?.pending || []).find((p) => p.jobId === normalizedJobId); + if (entry) { + setActivationBytesDialog({ visible: true, checksum: entry.checksum, jobId: normalizedJobId }); + } + return; + } setJobBusy(normalizedJobId, true); try { const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {}); @@ -2604,14 +2663,26 @@ export default function DashboardPage({ ); } if (isAudiobookJob) { + const needsBytes = pendingActivationJobIds.has(jobId); return ( - handleAudiobookStart(jobId, config)} - onCancel={() => handleCancel(jobId, jobState)} - onRetry={() => handleRetry(jobId)} - busy={busyJobIds.has(jobId)} - /> + <> + {needsBytes && ( +
+ + Activation Bytes fehlen.{' '} + +
+ )} + handleAudiobookStart(jobId, config)} + onCancel={() => handleCancel(jobId, jobState)} + onRetry={() => handleRetry(jobId)} + busy={busyJobIds.has(jobId) || needsBytes} + /> + ); } return null; @@ -2916,6 +2987,62 @@ export default function DashboardPage({ ) : null} + + setActivationBytesDialog({ visible: false, checksum: null, jobId: null })} + style={{ width: '36rem', maxWidth: '96vw' }} + modal + footer={( +
+
+ )} + > +
+
+ +
+ +
+
+
+ + setActivationBytesInput(e.target.value)} + placeholder="z.B. 1a2b3c4d" + style={{ width: '100%', fontFamily: 'monospace' }} + maxLength={8} + /> +
+
+ So bekommst du die Activation Bytes: +
    +
  1. Öffne audible-tools.kamsker.at
  2. +
  3. Aktiviere den Experten-Modus
  4. +
  5. Gib die obige Checksum ein
  6. +
  7. Kopiere die zurückgegebenen Activation Bytes hier rein
  8. +
+

+ Die Bytes werden lokal gespeichert und für alle weiteren AAX-Dateien desselben Accounts wiederverwendet. +

+
+
+
); } diff --git a/package-lock.json b/package-lock.json index 4ce68fc..0cf810c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.10.1", + "version": "0.10.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.10.1", + "version": "0.10.1-1", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 4bc971d..06fbdc4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.10.1", + "version": "0.10.1-1", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",