Files
ripster/backend/src/services/historyService.js
2026-03-06 11:21:25 +00:00

1326 lines
38 KiB
JavaScript

const { getDb } = require('../db/database');
const logger = require('./logger').child('HISTORY');
const fs = require('fs');
const path = require('path');
const settingsService = require('./settingsService');
const omdbService = require('./omdbService');
const { getJobLogDir } = require('./logPathService');
function parseJsonSafe(raw, fallback = null) {
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw);
} catch (error) {
return fallback;
}
}
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
const processLogStreams = new Map();
function inspectDirectory(dirPath) {
if (!dirPath) {
return {
path: null,
exists: false,
isDirectory: false,
isEmpty: null,
entryCount: null
};
}
try {
const stat = fs.statSync(dirPath);
if (!stat.isDirectory()) {
return {
path: dirPath,
exists: true,
isDirectory: false,
isEmpty: null,
entryCount: null
};
}
const entries = fs.readdirSync(dirPath);
return {
path: dirPath,
exists: true,
isDirectory: true,
isEmpty: entries.length === 0,
entryCount: entries.length
};
} catch (error) {
return {
path: dirPath,
exists: false,
isDirectory: false,
isEmpty: null,
entryCount: null
};
}
}
function inspectOutputFile(filePath) {
if (!filePath) {
return {
path: null,
exists: false,
isFile: false,
sizeBytes: null
};
}
try {
const stat = fs.statSync(filePath);
return {
path: filePath,
exists: true,
isFile: stat.isFile(),
sizeBytes: stat.size
};
} catch (error) {
return {
path: filePath,
exists: false,
isFile: false,
sizeBytes: null
};
}
}
function parseInfoFromValue(value, fallback = null) {
if (!value) {
return fallback;
}
if (typeof value === 'object') {
return value;
}
return parseJsonSafe(value, fallback);
}
function hasBlurayStructure(rawPath) {
const basePath = String(rawPath || '').trim();
if (!basePath) {
return false;
}
const bdmvPath = path.join(basePath, 'BDMV');
const streamPath = path.join(bdmvPath, 'STREAM');
try {
if (fs.existsSync(streamPath)) {
const streamStat = fs.statSync(streamPath);
if (streamStat.isDirectory()) {
return true;
}
}
} catch (_error) {
// ignore fs errors and continue with fallback checks
}
try {
if (fs.existsSync(bdmvPath)) {
const bdmvStat = fs.statSync(bdmvPath);
if (bdmvStat.isDirectory()) {
return true;
}
}
} catch (_error) {
// ignore fs errors
}
return false;
}
function hasDvdStructure(rawPath) {
const basePath = String(rawPath || '').trim();
if (!basePath) {
return false;
}
const videoTsPath = path.join(basePath, 'VIDEO_TS');
try {
if (fs.existsSync(videoTsPath)) {
const stat = fs.statSync(videoTsPath);
if (stat.isDirectory()) {
return true;
}
}
} catch (_error) {
// ignore fs errors
}
try {
if (fs.existsSync(basePath)) {
const stat = fs.statSync(basePath);
if (stat.isDirectory()) {
const entries = fs.readdirSync(basePath);
if (entries.some((entry) => /^vts_\d{2}_\d\.(ifo|vob|bup)$/i.test(entry) || /^video_ts\.(ifo|vob|bup)$/i.test(entry))) {
return true;
}
} else if (stat.isFile()) {
return /(^|\/)video_ts\/.+\.(ifo|vob|bup)$/i.test(basePath) || /\.(ifo|vob|bup)$/i.test(basePath);
}
}
} catch (_error) {
// ignore fs errors and fallback to path checks
}
if (/(^|\/)video_ts(\/|$)/i.test(basePath)) {
return true;
}
return false;
}
function normalizeMediaTypeValue(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') {
return 'bluray';
}
if (raw === 'dvd') {
return 'dvd';
}
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
return 'other';
}
return null;
}
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
const mkInfo = parseInfoFromValue(makemkvInfo, null);
const miInfo = parseInfoFromValue(mediainfoInfo, null);
const plan = parseInfoFromValue(encodePlan, null);
const rawPath = String(job?.raw_path || '').trim();
const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim();
const profileHint = normalizeMediaTypeValue(
plan?.mediaProfile
|| mkInfo?.analyzeContext?.mediaProfile
|| mkInfo?.mediaProfile
|| miInfo?.mediaProfile
|| job?.media_type
|| job?.mediaType
);
if (profileHint === 'bluray' || profileHint === 'dvd') {
return profileHint;
}
if (hasBlurayStructure(rawPath)) {
return 'bluray';
}
if (hasDvdStructure(rawPath)) {
return 'dvd';
}
const mkSource = String(mkInfo?.source || '').trim().toLowerCase();
const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase();
if (Boolean(mkInfo?.analyzeContext?.playlistAnalysis)) {
return 'bluray';
}
if (mkRipMode === 'backup' || mkSource.includes('backup') || mkSource.includes('raw_backup')) {
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
return 'dvd';
}
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
return 'bluray';
}
}
const planMode = String(plan?.mode || '').trim().toLowerCase();
if (planMode === 'pre_rip' || Boolean(plan?.preRip)) {
return 'bluray';
}
const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase();
if (Number(miInfo?.handbrakeTitleId) > 0) {
return 'bluray';
}
if (mediainfoSource.includes('raw_backup')) {
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
return 'dvd';
}
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
return 'bluray';
}
}
if (
/(^|\/)bdmv(\/|$)/i.test(rawPath)
|| /(^|\/)bdmv(\/|$)/i.test(encodeInputPath)
|| /\.m2ts(\.|$)/i.test(encodeInputPath)
) {
return 'bluray';
}
if (
/(^|\/)video_ts(\/|$)/i.test(rawPath)
|| /(^|\/)video_ts(\/|$)/i.test(encodeInputPath)
|| /\.(ifo|vob|bup)(\.|$)/i.test(encodeInputPath)
) {
return 'dvd';
}
return profileHint || 'other';
}
function toProcessLogPath(jobId) {
const normalizedId = Number(jobId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return null;
}
return path.join(getJobLogDir(), `job-${Math.trunc(normalizedId)}.process.log`);
}
function hasProcessLogFile(jobId) {
const filePath = toProcessLogPath(jobId);
return Boolean(filePath && fs.existsSync(filePath));
}
function toProcessLogStreamKey(jobId) {
const normalizedId = Number(jobId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return null;
}
return String(Math.trunc(normalizedId));
}
function resolveEffectiveRawPath(storedPath, rawDir) {
const stored = String(storedPath || '').trim();
if (!stored || !rawDir) return stored;
const folderName = path.basename(stored);
if (!folderName) return stored;
return path.join(String(rawDir).trim(), folderName);
}
function resolveEffectiveOutputPath(storedPath, movieDir) {
const stored = String(storedPath || '').trim();
if (!stored || !movieDir) return stored;
// output_path structure: {movie_dir}/{folderName}/{fileName}
const fileName = path.basename(stored);
const folderName = path.basename(path.dirname(stored));
if (!fileName || !folderName || folderName === '.') return stored;
return path.join(String(movieDir).trim(), folderName, fileName);
}
function enrichJobRow(job, settings = null) {
const rawDir = String(settings?.raw_dir || '').trim();
const movieDir = String(settings?.movie_dir || '').trim();
const effectiveRawPath = rawDir && job.raw_path
? resolveEffectiveRawPath(job.raw_path, rawDir)
: (job.raw_path || null);
const effectiveOutputPath = movieDir && job.output_path
? resolveEffectiveOutputPath(job.output_path, movieDir)
: (job.output_path || null);
const rawStatus = inspectDirectory(effectiveRawPath);
const outputStatus = inspectOutputFile(effectiveOutputPath);
const movieDirPath = effectiveOutputPath ? path.dirname(effectiveOutputPath) : null;
const movieDirStatus = inspectDirectory(movieDirPath);
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null);
const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null);
const omdbInfo = parseJsonSafe(job.omdb_json, null);
const encodePlan = parseJsonSafe(job.encode_plan_json, null);
const mediaType = inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan);
const backupSuccess = String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
const encodeSuccess = String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
return {
...job,
raw_path: effectiveRawPath,
output_path: effectiveOutputPath,
makemkvInfo,
handbrakeInfo,
mediainfoInfo,
omdbInfo,
encodePlan,
mediaType,
backupSuccess,
encodeSuccess,
rawStatus,
outputStatus,
movieDirStatus
};
}
function resolveSafe(inputPath) {
return path.resolve(String(inputPath || ''));
}
function isPathInside(basePath, candidatePath) {
if (!basePath || !candidatePath) {
return false;
}
const base = resolveSafe(basePath);
const candidate = resolveSafe(candidatePath);
return candidate === base || candidate.startsWith(`${base}${path.sep}`);
}
function normalizeComparablePath(inputPath) {
return resolveSafe(String(inputPath || '')).replace(/[\\/]+$/, '');
}
function parseRawFolderMetadata(folderName) {
const rawName = String(folderName || '').trim();
const folderJobIdMatch = rawName.match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i);
const folderJobId = folderJobIdMatch ? Number(folderJobIdMatch[1]) : null;
let working = rawName.replace(/\s*-\s*RAW\s*-\s*job-\d+\s*$/i, '').trim();
const imdbMatch = working.match(/\[(tt\d{6,12})\]/i);
const imdbId = imdbMatch ? String(imdbMatch[1] || '').toLowerCase() : null;
if (imdbMatch) {
working = working.replace(imdbMatch[0], '').trim();
}
const yearMatch = working.match(/\((19|20)\d{2}\)/);
const year = yearMatch ? Number(String(yearMatch[0]).replace(/[()]/g, '')) : null;
if (yearMatch) {
working = working.replace(yearMatch[0], '').trim();
}
const title = working.replace(/\s{2,}/g, ' ').trim() || null;
return {
title,
year: Number.isFinite(year) ? year : null,
imdbId,
folderJobId: Number.isFinite(folderJobId) ? Math.trunc(folderJobId) : null
};
}
function buildRawPathForJobId(rawPath, jobId) {
const normalizedJobId = Number(jobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
return rawPath;
}
const absRawPath = normalizeComparablePath(rawPath);
const folderName = path.basename(absRawPath);
const replaced = folderName.replace(/(\s-\sRAW\s-\sjob-)\d+\s*$/i, `$1${Math.trunc(normalizedJobId)}`);
if (replaced === folderName) {
return absRawPath;
}
return path.join(path.dirname(absRawPath), replaced);
}
function deleteFilesRecursively(rootPath, keepRoot = true) {
const result = {
filesDeleted: 0,
dirsRemoved: 0
};
const visit = (current, isRoot = false) => {
if (!fs.existsSync(current)) {
return;
}
const stat = fs.lstatSync(current);
if (stat.isDirectory()) {
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(abs, false);
} else {
fs.unlinkSync(abs);
result.filesDeleted += 1;
}
}
const remaining = fs.readdirSync(current);
if (remaining.length === 0 && (!isRoot || !keepRoot)) {
fs.rmdirSync(current);
result.dirsRemoved += 1;
}
return;
}
fs.unlinkSync(current);
result.filesDeleted += 1;
};
visit(rootPath, true);
return result;
}
class HistoryService {
async createJob({ discDevice = null, status = 'ANALYZING', detectedTitle = null }) {
const db = await getDb();
const startTime = new Date().toISOString();
const result = await db.run(
`
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[discDevice, status, startTime, detectedTitle, status]
);
logger.info('job:created', {
jobId: result.lastID,
discDevice,
status,
detectedTitle
});
return this.getJobById(result.lastID);
}
async updateJob(jobId, patch) {
const db = await getDb();
const fields = [];
const values = [];
for (const [key, value] of Object.entries(patch)) {
fields.push(`${key} = ?`);
values.push(value);
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(jobId);
await db.run(`UPDATE jobs SET ${fields.join(', ')} WHERE id = ?`, values);
logger.debug('job:updated', { jobId, patchKeys: Object.keys(patch) });
return this.getJobById(jobId);
}
async updateJobStatus(jobId, status, extra = {}) {
return this.updateJob(jobId, {
status,
last_state: status,
...extra
});
}
appendLog(jobId, source, message) {
this.appendProcessLog(jobId, source, message);
}
appendProcessLog(jobId, source, message) {
const filePath = toProcessLogPath(jobId);
const streamKey = toProcessLogStreamKey(jobId);
if (!filePath || !streamKey) {
return;
}
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
let stream = processLogStreams.get(streamKey);
if (!stream) {
stream = fs.createWriteStream(filePath, {
flags: 'a',
encoding: 'utf-8'
});
stream.on('error', (error) => {
logger.warn('job:process-log:stream-error', {
jobId,
source,
error: error?.message || String(error)
});
});
processLogStreams.set(streamKey, stream);
}
const line = `[${new Date().toISOString()}] [${source}] ${String(message || '')}\n`;
stream.write(line);
} catch (error) {
logger.warn('job:process-log:append-failed', {
jobId,
source,
error: error?.message || String(error)
});
}
}
async closeProcessLog(jobId) {
const streamKey = toProcessLogStreamKey(jobId);
if (!streamKey) {
return;
}
const stream = processLogStreams.get(streamKey);
if (!stream) {
return;
}
processLogStreams.delete(streamKey);
await new Promise((resolve) => {
stream.end(resolve);
});
}
async resetProcessLog(jobId) {
await this.closeProcessLog(jobId);
const filePath = toProcessLogPath(jobId);
if (!filePath || !fs.existsSync(filePath)) {
return;
}
try {
fs.unlinkSync(filePath);
} catch (error) {
logger.warn('job:process-log:reset-failed', {
jobId,
path: filePath,
error: error?.message || String(error)
});
}
}
async readProcessLogLines(jobId, options = {}) {
const includeAll = Boolean(options.includeAll);
const parsedTail = Number(options.tailLines);
const tailLines = Number.isFinite(parsedTail) && parsedTail > 0
? Math.trunc(parsedTail)
: 800;
const filePath = toProcessLogPath(jobId);
if (!filePath || !fs.existsSync(filePath)) {
return {
exists: false,
lines: [],
returned: 0,
total: 0,
truncated: false
};
}
if (includeAll) {
const raw = await fs.promises.readFile(filePath, 'utf-8');
const lines = String(raw || '')
.split(/\r\n|\n|\r/)
.filter((line) => line.length > 0);
return {
exists: true,
lines,
returned: lines.length,
total: lines.length,
truncated: false
};
}
const stat = await fs.promises.stat(filePath);
if (!stat.isFile() || stat.size <= 0) {
return {
exists: true,
lines: [],
returned: 0,
total: 0,
truncated: false
};
}
const readBytes = Math.min(stat.size, PROCESS_LOG_TAIL_MAX_BYTES);
const start = Math.max(0, stat.size - readBytes);
const handle = await fs.promises.open(filePath, 'r');
let buffer = Buffer.alloc(0);
try {
buffer = Buffer.alloc(readBytes);
const { bytesRead } = await handle.read(buffer, 0, readBytes, start);
buffer = buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
let text = buffer.toString('utf-8');
if (start > 0) {
const parts = text.split(/\r\n|\n|\r/);
parts.shift();
text = parts.join('\n');
}
let lines = text.split(/\r\n|\n|\r/).filter((line) => line.length > 0);
let truncated = start > 0;
if (lines.length > tailLines) {
lines = lines.slice(-tailLines);
truncated = true;
}
return {
exists: true,
lines,
returned: lines.length,
total: lines.length,
truncated
};
}
async getJobById(jobId) {
const db = await getDb();
return db.get('SELECT * FROM jobs WHERE id = ?', [jobId]);
}
async getJobs(filters = {}) {
const db = await getDb();
const where = [];
const values = [];
if (filters.status) {
where.push('status = ?');
values.push(filters.status);
}
if (filters.search) {
where.push('(title LIKE ? OR imdb_id LIKE ? OR detected_title LIKE ?)');
values.push(`%${filters.search}%`, `%${filters.search}%`, `%${filters.search}%`);
}
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const [jobs, settings] = await Promise.all([
db.all(
`
SELECT j.*
FROM jobs j
${whereClause}
ORDER BY j.created_at DESC
LIMIT 500
`,
values
),
settingsService.getSettingsMap()
]);
return jobs.map((job) => ({
...enrichJobRow(job, settings),
log_count: hasProcessLogFile(job.id) ? 1 : 0
}));
}
async getJobsByIds(jobIds = []) {
const ids = Array.isArray(jobIds)
? jobIds
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value))
: [];
if (ids.length === 0) {
return [];
}
const [rows, settings] = await Promise.all([
(async () => {
const db = await getDb();
const placeholders = ids.map(() => '?').join(', ');
return db.all(`SELECT * FROM jobs WHERE id IN (${placeholders})`, ids);
})(),
settingsService.getSettingsMap()
]);
const byId = new Map(rows.map((row) => [Number(row.id), row]));
return ids
.map((id) => byId.get(id))
.filter(Boolean)
.map((job) => ({
...enrichJobRow(job, settings),
log_count: hasProcessLogFile(job.id) ? 1 : 0
}));
}
async getRunningJobs() {
const db = await getDb();
const [rows, settings] = await Promise.all([
db.all(
`
SELECT *
FROM jobs
WHERE status IN ('RIPPING', 'ENCODING')
ORDER BY updated_at ASC, id ASC
`
),
settingsService.getSettingsMap()
]);
return rows.map((job) => ({
...enrichJobRow(job, settings),
log_count: hasProcessLogFile(job.id) ? 1 : 0
}));
}
async getRunningEncodeJobs() {
const db = await getDb();
const [rows, settings] = await Promise.all([
db.all(
`
SELECT *
FROM jobs
WHERE status = 'ENCODING'
ORDER BY updated_at ASC, id ASC
`
),
settingsService.getSettingsMap()
]);
return rows.map((job) => ({
...enrichJobRow(job, settings),
log_count: hasProcessLogFile(job.id) ? 1 : 0
}));
}
async getJobWithLogs(jobId, options = {}) {
const db = await getDb();
const [job, settings] = await Promise.all([
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
settingsService.getSettingsMap()
]);
if (!job) {
return null;
}
const parsedTail = Number(options.logTailLines);
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
? Math.trunc(parsedTail)
: 800;
const includeLiveLog = Boolean(options.includeLiveLog);
const includeLogs = Boolean(options.includeLogs);
const includeAllLogs = Boolean(options.includeAllLogs);
const shouldLoadLogs = includeLiveLog || includeLogs;
const hasProcessLog = hasProcessLogFile(jobId);
const baseLogCount = hasProcessLog ? 1 : 0;
if (!shouldLoadLogs) {
return {
...enrichJobRow(job, settings),
log_count: baseLogCount,
logs: [],
log: '',
logMeta: {
loaded: false,
total: baseLogCount,
returned: 0,
truncated: false
}
};
}
const processLog = await this.readProcessLogLines(jobId, {
includeAll: includeAllLogs,
tailLines: logTailLines
});
return {
...enrichJobRow(job, settings),
log_count: processLog.exists ? processLog.total : 0,
logs: [],
log: processLog.lines.join('\n'),
logMeta: {
loaded: true,
total: includeAllLogs ? processLog.total : processLog.returned,
returned: processLog.returned,
truncated: processLog.truncated
}
};
}
async getDatabaseRows(filters = {}) {
const jobs = await this.getJobs(filters);
return jobs.map((job) => ({
...job,
rawFolderName: job.raw_path ? path.basename(job.raw_path) : null
}));
}
async getOrphanRawFolders() {
const settings = await settingsService.getSettingsMap();
const rawDir = String(settings.raw_dir || '').trim();
if (!rawDir) {
const error = new Error('raw_dir ist nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
const rawDirInfo = inspectDirectory(rawDir);
if (!rawDirInfo.exists || !rawDirInfo.isDirectory) {
return {
rawDir,
rows: []
};
}
const db = await getDb();
const linkedRows = await db.all(
`
SELECT id, raw_path, status
FROM jobs
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
`
);
const linkedPathMap = new Map();
for (const row of linkedRows) {
const normalized = normalizeComparablePath(row.raw_path);
if (!normalized) {
continue;
}
if (!linkedPathMap.has(normalized)) {
linkedPathMap.set(normalized, []);
}
linkedPathMap.get(normalized).push({
id: row.id,
status: row.status
});
}
const dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
const orphanRows = [];
for (const entry of dirEntries) {
if (!entry.isDirectory()) {
continue;
}
const rawPath = path.join(rawDir, entry.name);
const normalizedPath = normalizeComparablePath(rawPath);
if (linkedPathMap.has(normalizedPath)) {
continue;
}
const dirInfo = inspectDirectory(rawPath);
if (!dirInfo.exists || !dirInfo.isDirectory || dirInfo.isEmpty) {
continue;
}
const stat = fs.statSync(rawPath);
const metadata = parseRawFolderMetadata(entry.name);
orphanRows.push({
rawPath,
folderName: entry.name,
title: metadata.title,
year: metadata.year,
imdbId: metadata.imdbId,
folderJobId: metadata.folderJobId,
entryCount: Number(dirInfo.entryCount || 0),
hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')),
lastModifiedAt: stat.mtime.toISOString()
});
}
orphanRows.sort((a, b) => String(b.lastModifiedAt).localeCompare(String(a.lastModifiedAt)));
return {
rawDir,
rows: orphanRows
};
}
async importOrphanRawFolder(rawPath) {
const settings = await settingsService.getSettingsMap();
const rawDir = String(settings.raw_dir || '').trim();
const requestedRawPath = String(rawPath || '').trim();
if (!requestedRawPath) {
const error = new Error('rawPath fehlt.');
error.statusCode = 400;
throw error;
}
if (!rawDir) {
const error = new Error('raw_dir ist nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
if (!isPathInside(rawDir, requestedRawPath)) {
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${requestedRawPath}`);
error.statusCode = 400;
throw error;
}
const absRawPath = normalizeComparablePath(requestedRawPath);
const dirInfo = inspectDirectory(absRawPath);
if (!dirInfo.exists || !dirInfo.isDirectory) {
const error = new Error(`RAW-Pfad existiert nicht als Verzeichnis: ${absRawPath}`);
error.statusCode = 400;
throw error;
}
if (dirInfo.isEmpty) {
const error = new Error(`RAW-Pfad ist leer: ${absRawPath}`);
error.statusCode = 400;
throw error;
}
const db = await getDb();
const linkedRows = await db.all(
`
SELECT id, raw_path
FROM jobs
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
`
);
const existing = linkedRows.find((row) => normalizeComparablePath(row.raw_path) === absRawPath);
if (existing) {
const error = new Error(`Für RAW-Pfad existiert bereits Job #${existing.id}.`);
error.statusCode = 409;
throw error;
}
const folderName = path.basename(absRawPath);
const metadata = parseRawFolderMetadata(folderName);
let omdbById = null;
if (metadata.imdbId) {
try {
omdbById = await omdbService.fetchByImdbId(metadata.imdbId);
} catch (error) {
logger.warn('job:import-orphan-raw:omdb-fetch-failed', {
rawPath: absRawPath,
imdbId: metadata.imdbId,
message: error.message
});
}
}
const effectiveTitle = omdbById?.title || metadata.title || folderName;
const importedAt = new Date().toISOString();
const created = await this.createJob({
discDevice: null,
status: 'FINISHED',
detectedTitle: effectiveTitle
});
let finalRawPath = absRawPath;
const renamedRawPath = buildRawPathForJobId(absRawPath, created.id);
const shouldRenameRawFolder = normalizeComparablePath(renamedRawPath) !== absRawPath;
if (shouldRenameRawFolder) {
if (fs.existsSync(renamedRawPath)) {
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
const error = new Error(`RAW-Ordner für neue Job-ID existiert bereits: ${renamedRawPath}`);
error.statusCode = 409;
throw error;
}
try {
fs.renameSync(absRawPath, renamedRawPath);
finalRawPath = normalizeComparablePath(renamedRawPath);
} catch (error) {
await db.run('DELETE FROM jobs WHERE id = ?', [created.id]);
const wrapped = new Error(`RAW-Ordner konnte nicht auf neue Job-ID umbenannt werden: ${error.message}`);
wrapped.statusCode = 500;
throw wrapped;
}
}
await this.updateJob(created.id, {
status: 'FINISHED',
last_state: 'FINISHED',
title: omdbById?.title || metadata.title || null,
year: Number.isFinite(Number(omdbById?.year)) ? Number(omdbById.year) : metadata.year,
imdb_id: omdbById?.imdbId || metadata.imdbId || null,
poster_url: omdbById?.poster || null,
omdb_json: omdbById?.raw ? JSON.stringify(omdbById.raw) : null,
selected_from_omdb: omdbById ? 1 : 0,
raw_path: finalRawPath,
output_path: null,
handbrake_info_json: null,
mediainfo_info_json: null,
encode_plan_json: null,
encode_input_path: null,
encode_review_confirmed: 0,
error_message: null,
end_time: importedAt,
makemkv_info_json: JSON.stringify({
status: 'SUCCESS',
source: 'orphan_raw_import',
importedAt,
rawPath: finalRawPath
})
});
await this.appendLog(
created.id,
'SYSTEM',
shouldRenameRawFolder
? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${absRawPath} -> ${finalRawPath}`
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}`
);
if (metadata.imdbId) {
await this.appendLog(
created.id,
'SYSTEM',
omdbById
? `OMDb-Zuordnung via IMDb-ID übernommen: ${omdbById.imdbId} (${omdbById.title || '-'})`
: `OMDb-Zuordnung via IMDb-ID fehlgeschlagen: ${metadata.imdbId}`
);
}
logger.info('job:import-orphan-raw', {
jobId: created.id,
rawPath: absRawPath
});
const imported = await this.getJobById(created.id);
return enrichJobRow(imported, settings);
}
async assignOmdbMetadata(jobId, payload = {}) {
const job = await this.getJobById(jobId);
if (!job) {
const error = new Error('Job nicht gefunden.');
error.statusCode = 404;
throw error;
}
const imdbIdInput = String(payload.imdbId || '').trim().toLowerCase();
let omdb = null;
if (imdbIdInput) {
omdb = await omdbService.fetchByImdbId(imdbIdInput);
if (!omdb) {
const error = new Error(`OMDb Eintrag für ${imdbIdInput} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
}
const manualTitle = String(payload.title || '').trim();
const manualYearRaw = Number(payload.year);
const manualYear = Number.isFinite(manualYearRaw) ? Math.trunc(manualYearRaw) : null;
const manualPoster = String(payload.poster || '').trim() || null;
const hasManual = manualTitle.length > 0 || manualYear !== null || imdbIdInput.length > 0;
if (!omdb && !hasManual) {
const error = new Error('Keine OMDb-/Metadaten zum Aktualisieren angegeben.');
error.statusCode = 400;
throw error;
}
const title = omdb?.title || manualTitle || job.title || job.detected_title || null;
const year = Number.isFinite(Number(omdb?.year))
? Number(omdb.year)
: (manualYear !== null ? manualYear : (job.year ?? null));
const imdbId = omdb?.imdbId || (imdbIdInput || job.imdb_id || null);
const posterUrl = omdb?.poster || manualPoster || job.poster_url || null;
const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0);
await this.updateJob(jobId, {
title,
year,
imdb_id: imdbId,
poster_url: posterUrl,
omdb_json: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null),
selected_from_omdb: selectedFromOmdb
});
await this.appendLog(
jobId,
'USER_ACTION',
omdb
? `OMDb-Zuordnung aktualisiert: ${omdb.imdbId} (${omdb.title || '-'})`
: `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"`
);
const [updated, settings] = await Promise.all([
this.getJobById(jobId),
settingsService.getSettingsMap()
]);
return enrichJobRow(updated, settings);
}
async deleteJobFiles(jobId, target = 'both') {
const allowedTargets = new Set(['raw', 'movie', 'both']);
if (!allowedTargets.has(target)) {
const error = new Error(`Ungültiges target '${target}'. Erlaubt: raw, movie, both.`);
error.statusCode = 400;
throw error;
}
const job = await this.getJobById(jobId);
if (!job) {
const error = new Error('Job nicht gefunden.');
error.statusCode = 404;
throw error;
}
const settings = await settingsService.getSettingsMap();
const effectiveRawPath = settings.raw_dir && job.raw_path
? resolveEffectiveRawPath(job.raw_path, settings.raw_dir)
: job.raw_path;
const effectiveOutputPath = settings.movie_dir && job.output_path
? resolveEffectiveOutputPath(job.output_path, settings.movie_dir)
: job.output_path;
const summary = {
target,
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
movie: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }
};
if (target === 'raw' || target === 'both') {
summary.raw.attempted = true;
if (!effectiveRawPath) {
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
} else if (!isPathInside(settings.raw_dir, effectiveRawPath)) {
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${effectiveRawPath}`);
error.statusCode = 400;
throw error;
} else if (!fs.existsSync(effectiveRawPath)) {
summary.raw.reason = 'RAW-Pfad existiert nicht.';
} else {
const result = deleteFilesRecursively(effectiveRawPath, true);
summary.raw.deleted = true;
summary.raw.filesDeleted = result.filesDeleted;
summary.raw.dirsRemoved = result.dirsRemoved;
}
}
if (target === 'movie' || target === 'both') {
summary.movie.attempted = true;
if (!effectiveOutputPath) {
summary.movie.reason = 'Kein output_path im Job gesetzt.';
} else if (!isPathInside(settings.movie_dir, effectiveOutputPath)) {
const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${effectiveOutputPath}`);
error.statusCode = 400;
throw error;
} else if (!fs.existsSync(effectiveOutputPath)) {
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
} else {
const outputPath = normalizeComparablePath(effectiveOutputPath);
const movieRoot = normalizeComparablePath(settings.movie_dir);
const stat = fs.lstatSync(outputPath);
if (stat.isDirectory()) {
const keepRoot = outputPath === movieRoot;
const result = deleteFilesRecursively(outputPath, keepRoot ? true : false);
summary.movie.deleted = true;
summary.movie.filesDeleted = result.filesDeleted;
summary.movie.dirsRemoved = result.dirsRemoved;
} else {
const parentDir = normalizeComparablePath(path.dirname(outputPath));
const canDeleteParentDir = parentDir
&& parentDir !== movieRoot
&& isPathInside(movieRoot, parentDir)
&& fs.existsSync(parentDir)
&& fs.lstatSync(parentDir).isDirectory();
if (canDeleteParentDir) {
const result = deleteFilesRecursively(parentDir, false);
summary.movie.deleted = true;
summary.movie.filesDeleted = result.filesDeleted;
summary.movie.dirsRemoved = result.dirsRemoved;
} else {
fs.unlinkSync(outputPath);
summary.movie.deleted = true;
summary.movie.filesDeleted = 1;
summary.movie.dirsRemoved = 0;
}
}
}
}
await this.appendLog(
jobId,
'USER_ACTION',
`Dateien gelöscht (${target}) - raw=${JSON.stringify(summary.raw)} movie=${JSON.stringify(summary.movie)}`
);
logger.info('job:delete-files', { jobId, summary });
const [updated, enrichSettings] = await Promise.all([
this.getJobById(jobId),
settingsService.getSettingsMap()
]);
return {
summary,
job: enrichJobRow(updated, enrichSettings)
};
}
async deleteJob(jobId, fileTarget = 'none') {
const allowedTargets = new Set(['none', 'raw', 'movie', 'both']);
if (!allowedTargets.has(fileTarget)) {
const error = new Error(`Ungültiges target '${fileTarget}'. Erlaubt: none, raw, movie, both.`);
error.statusCode = 400;
throw error;
}
const existing = await this.getJobById(jobId);
if (!existing) {
const error = new Error('Job nicht gefunden.');
error.statusCode = 404;
throw error;
}
let fileSummary = null;
if (fileTarget !== 'none') {
const fileResult = await this.deleteJobFiles(jobId, fileTarget);
fileSummary = fileResult.summary;
}
const db = await getDb();
const pipelineRow = await db.get(
'SELECT state, active_job_id FROM pipeline_state WHERE id = 1'
);
const isActivePipelineJob = Number(pipelineRow?.active_job_id || 0) === Number(jobId);
const runningStates = new Set(['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING']);
if (isActivePipelineJob && runningStates.has(String(pipelineRow?.state || ''))) {
const error = new Error('Aktiver Pipeline-Job kann nicht gelöscht werden. Bitte zuerst abbrechen.');
error.statusCode = 409;
throw error;
}
await db.exec('BEGIN');
try {
if (isActivePipelineJob) {
await db.run(
`
UPDATE pipeline_state
SET
state = 'IDLE',
active_job_id = NULL,
progress = 0,
eta = NULL,
status_text = 'Bereit',
context_json = '{}',
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`
);
} else {
await db.run(
`
UPDATE pipeline_state
SET
active_job_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1 AND active_job_id = ?
`,
[jobId]
);
}
await db.run('DELETE FROM jobs WHERE id = ?', [jobId]);
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
await this.closeProcessLog(jobId);
const processLogPath = toProcessLogPath(jobId);
if (processLogPath && fs.existsSync(processLogPath)) {
try {
fs.unlinkSync(processLogPath);
} catch (error) {
logger.warn('job:process-log:delete-failed', {
jobId,
path: processLogPath,
error: error?.message || String(error)
});
}
}
logger.warn('job:deleted', {
jobId,
fileTarget,
pipelineStateReset: isActivePipelineJob,
filesDeleted: fileSummary
? {
raw: fileSummary.raw?.filesDeleted ?? 0,
movie: fileSummary.movie?.filesDeleted ?? 0
}
: { raw: 0, movie: 0 }
});
return {
deleted: true,
jobId,
fileTarget,
fileSummary
};
}
}
module.exports = new HistoryService();