698 lines
22 KiB
JavaScript
698 lines
22 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { getDb } = require('../db/database');
|
|
const settingsService = require('./settingsService');
|
|
const wsService = require('./websocketService');
|
|
const logger = require('./logger').child('CONVERTER_SCAN');
|
|
const {
|
|
defaultConverterRawDir
|
|
} = require('../config');
|
|
|
|
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
|
|
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
|
|
const ISO_EXTENSIONS = new Set(['iso']);
|
|
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
|
|
|
|
let _pollingTimer = null;
|
|
let _pollingEnabled = false;
|
|
let _pollingInterval = 300;
|
|
|
|
function detectMediaType(fileName) {
|
|
const ext = path.extname(String(fileName || '')).slice(1).toLowerCase();
|
|
if (ISO_EXTENSIONS.has(ext)) return 'iso';
|
|
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
|
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
|
|
return null;
|
|
}
|
|
|
|
function detectFormat(fileName) {
|
|
return path.extname(String(fileName || '')).slice(1).toLowerCase() || null;
|
|
}
|
|
|
|
function getConfiguredExtensions(settings) {
|
|
const raw = String(settings?.converter_scan_extensions || '').trim();
|
|
if (!raw) {
|
|
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
|
}
|
|
const configured = raw.split(',')
|
|
.map((ext) => ext.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext));
|
|
if (filtered.length === 0) {
|
|
return new Set(SUPPORTED_SCAN_EXTENSIONS);
|
|
}
|
|
return new Set(filtered);
|
|
}
|
|
|
|
function getFileSize(fullPath) {
|
|
try {
|
|
return fs.statSync(fullPath).size;
|
|
} catch (_err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const SCAN_MAX_DEPTH = 4;
|
|
|
|
/**
|
|
* Rekursiv alle Dateien und direkten Unterordner im rawDir scannen.
|
|
* Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück.
|
|
* Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern).
|
|
*/
|
|
function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) {
|
|
const entries = [];
|
|
|
|
if (depth >= SCAN_MAX_DEPTH) {
|
|
return entries;
|
|
}
|
|
|
|
let dirEntries;
|
|
try {
|
|
dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
|
|
} catch (_err) {
|
|
return entries;
|
|
}
|
|
|
|
for (const dirent of dirEntries) {
|
|
const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name;
|
|
const fullPath = path.join(rawDir, relPath);
|
|
|
|
if (dirent.isDirectory()) {
|
|
entries.push({
|
|
relPath,
|
|
entryType: 'directory',
|
|
fileSize: null,
|
|
detectedMediaType: null,
|
|
detectedFormat: null
|
|
});
|
|
// Rekursiv in Unterordner (mit Tiefenbegrenzung)
|
|
const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1);
|
|
entries.push(...subEntries);
|
|
} else if (dirent.isFile()) {
|
|
const ext = path.extname(dirent.name).slice(1).toLowerCase();
|
|
if (!allowedExtensions.has(ext)) {
|
|
continue;
|
|
}
|
|
entries.push({
|
|
relPath,
|
|
entryType: 'file',
|
|
fileSize: getFileSize(fullPath),
|
|
detectedMediaType: detectMediaType(dirent.name),
|
|
detectedFormat: detectFormat(dirent.name)
|
|
});
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und
|
|
* nicht mehr vorhandene Einträge ohne Job entfernen.
|
|
*/
|
|
async function persistScanResults(rawDir, entries) {
|
|
const db = await getDb();
|
|
|
|
if (entries.length > 0) {
|
|
const stmt = await db.prepare(`
|
|
INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, last_seen_at)
|
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(rel_path) DO UPDATE SET
|
|
entry_type = excluded.entry_type,
|
|
file_size = excluded.file_size,
|
|
detected_media_type = excluded.detected_media_type,
|
|
detected_format = excluded.detected_format,
|
|
last_seen_at = excluded.last_seen_at
|
|
`);
|
|
|
|
for (const entry of entries) {
|
|
await stmt.run(
|
|
entry.relPath,
|
|
entry.entryType,
|
|
entry.fileSize,
|
|
entry.detectedMediaType,
|
|
entry.detectedFormat
|
|
);
|
|
}
|
|
await stmt.finalize();
|
|
}
|
|
|
|
// Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden
|
|
const existingEntries = await db.all(
|
|
`SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL`
|
|
);
|
|
const currentRelPaths = new Set(entries.map((e) => e.relPath));
|
|
|
|
for (const row of existingEntries) {
|
|
if (!currentRelPaths.has(row.rel_path)) {
|
|
const fullPath = path.join(rawDir, row.rel_path);
|
|
if (!fs.existsSync(fullPath)) {
|
|
await db.run(
|
|
`DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`,
|
|
[row.rel_path]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden.
|
|
*/
|
|
async function scan() {
|
|
const settings = await settingsService.getSettingsMap();
|
|
const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
|
|
|
if (!rawDir) {
|
|
logger.warn('converter:scan:no-raw-dir');
|
|
return { rawDir: null, entryCount: 0 };
|
|
}
|
|
|
|
if (!fs.existsSync(rawDir)) {
|
|
logger.warn('converter:scan:dir-missing', { rawDir });
|
|
return { rawDir, entryCount: 0 };
|
|
}
|
|
|
|
const allowedExtensions = getConfiguredExtensions(settings);
|
|
logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] });
|
|
|
|
const entries = scanDirectory(rawDir, allowedExtensions);
|
|
await persistScanResults(rawDir, entries);
|
|
|
|
logger.info('converter:scan:done', { rawDir, entryCount: entries.length });
|
|
|
|
wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length });
|
|
|
|
return { rawDir, entryCount: entries.length };
|
|
}
|
|
|
|
/**
|
|
* Alle Einträge für den File-Explorer zurückgeben.
|
|
* Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses.
|
|
*/
|
|
async function getEntries(parentRelPath = null) {
|
|
const db = await getDb();
|
|
|
|
let rows;
|
|
if (!parentRelPath) {
|
|
// Root-Ebene: nur Einträge ohne '/' im rel_path
|
|
rows = await db.all(`
|
|
SELECT e.*, j.status AS job_status, j.title AS job_title
|
|
FROM converter_scan_entries e
|
|
LEFT JOIN jobs j ON j.id = e.job_id
|
|
ORDER BY e.entry_type DESC, e.rel_path ASC
|
|
`);
|
|
// Nur direkte Kinder (kein '/' im rel_path)
|
|
rows = rows.filter((r) => !String(r.rel_path).includes('/'));
|
|
} else {
|
|
const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`;
|
|
rows = await db.all(`
|
|
SELECT e.*, j.status AS job_status, j.title AS job_title
|
|
FROM converter_scan_entries e
|
|
LEFT JOIN jobs j ON j.id = e.job_id
|
|
ORDER BY e.entry_type DESC, e.rel_path ASC
|
|
`);
|
|
// Direkte Kinder des angegebenen Verzeichnisses
|
|
rows = rows.filter((r) => {
|
|
const rel = String(r.rel_path);
|
|
if (!rel.startsWith(prefix)) return false;
|
|
const remainder = rel.slice(prefix.length);
|
|
return !remainder.includes('/');
|
|
});
|
|
}
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
relPath: r.rel_path,
|
|
entryType: r.entry_type,
|
|
fileSize: r.file_size,
|
|
detectedMediaType: r.detected_media_type,
|
|
detectedFormat: r.detected_format,
|
|
jobId: r.job_id,
|
|
jobStatus: r.job_status || null,
|
|
jobTitle: r.job_title || null,
|
|
lastSeenAt: r.last_seen_at
|
|
}));
|
|
}
|
|
|
|
async function getEntryById(id) {
|
|
const db = await getDb();
|
|
const row = await db.get(
|
|
`SELECT * FROM converter_scan_entries WHERE id = ?`,
|
|
[Number(id)]
|
|
);
|
|
return row || null;
|
|
}
|
|
|
|
async function getEntryByRelPath(relPath) {
|
|
const db = await getDb();
|
|
const row = await db.get(
|
|
`SELECT * FROM converter_scan_entries WHERE rel_path = ?`,
|
|
[relPath]
|
|
);
|
|
return row || null;
|
|
}
|
|
|
|
async function setEntryJobAssignment(relPath, jobId) {
|
|
const normalizedRelPath = normalizeRelPath(relPath);
|
|
if (normalizedRelPath === null || normalizedRelPath === '') {
|
|
throw makeError('Ungültiger relPath für Job-Zuweisung.', 400);
|
|
}
|
|
const normalizedJobId = Number(jobId);
|
|
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
|
throw makeError('Ungültige jobId für Job-Zuweisung.', 400);
|
|
}
|
|
|
|
const db = await getDb();
|
|
const existing = await db.get(
|
|
`SELECT entry_type, file_size, detected_media_type, detected_format
|
|
FROM converter_scan_entries
|
|
WHERE rel_path = ?`,
|
|
[normalizedRelPath]
|
|
);
|
|
|
|
let entryType = String(existing?.entry_type || 'file').trim() || 'file';
|
|
let fileSize = existing?.file_size ?? null;
|
|
let detectedMediaType = existing?.detected_media_type ?? null;
|
|
let detectedFormat = existing?.detected_format ?? null;
|
|
|
|
const rawDir = await getRawDir();
|
|
const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null;
|
|
if (absPath && fs.existsSync(absPath)) {
|
|
try {
|
|
const stat = fs.statSync(absPath);
|
|
entryType = stat.isDirectory() ? 'directory' : 'file';
|
|
fileSize = stat.isFile() ? stat.size : null;
|
|
if (stat.isFile()) {
|
|
const fileName = path.basename(normalizedRelPath);
|
|
detectedMediaType = detectMediaType(fileName);
|
|
detectedFormat = detectFormat(fileName);
|
|
}
|
|
} catch (_err) {
|
|
// Keep existing metadata values if stat/read fails.
|
|
}
|
|
}
|
|
|
|
await db.run(
|
|
`
|
|
INSERT INTO converter_scan_entries (
|
|
rel_path,
|
|
entry_type,
|
|
file_size,
|
|
detected_media_type,
|
|
detected_format,
|
|
job_id,
|
|
last_seen_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(rel_path) DO UPDATE SET
|
|
entry_type = excluded.entry_type,
|
|
file_size = excluded.file_size,
|
|
detected_media_type = excluded.detected_media_type,
|
|
detected_format = excluded.detected_format,
|
|
job_id = excluded.job_id,
|
|
last_seen_at = excluded.last_seen_at
|
|
`,
|
|
[
|
|
normalizedRelPath,
|
|
entryType,
|
|
fileSize,
|
|
detectedMediaType,
|
|
detectedFormat,
|
|
Math.trunc(normalizedJobId)
|
|
]
|
|
);
|
|
}
|
|
|
|
async function clearEntryJobAssignment(relPath, expectedJobId = null) {
|
|
const normalizedRelPath = normalizeRelPath(relPath);
|
|
if (normalizedRelPath === null || normalizedRelPath === '') {
|
|
throw makeError('Ungültiger relPath für Job-Entfernung.', 400);
|
|
}
|
|
const db = await getDb();
|
|
const normalizedExpected = Number(expectedJobId);
|
|
if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) {
|
|
await db.run(
|
|
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`,
|
|
[normalizedRelPath, Math.trunc(normalizedExpected)]
|
|
);
|
|
return;
|
|
}
|
|
await db.run(
|
|
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`,
|
|
[normalizedRelPath]
|
|
);
|
|
}
|
|
|
|
async function clearAssignmentsForJob(jobId) {
|
|
const normalizedJobId = Number(jobId);
|
|
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
|
throw makeError('Ungültige jobId für Job-Entfernung.', 400);
|
|
}
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
|
|
[Math.trunc(normalizedJobId)]
|
|
);
|
|
}
|
|
|
|
async function assignEntriesToJob(relPaths, jobId) {
|
|
const normalizedPaths = Array.isArray(relPaths) ? relPaths : [];
|
|
for (const relPath of normalizedPaths) {
|
|
await setEntryJobAssignment(relPath, jobId);
|
|
}
|
|
}
|
|
|
|
async function markEntryAsJob(relPath, jobId) {
|
|
await setEntryJobAssignment(relPath, jobId);
|
|
}
|
|
|
|
async function getRawDir() {
|
|
const settings = await settingsService.getSettingsMap();
|
|
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
|
|
}
|
|
|
|
/**
|
|
* Polling-Loop starten.
|
|
*/
|
|
async function startPolling() {
|
|
const settings = await settingsService.getSettingsMap();
|
|
_pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true';
|
|
_pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000;
|
|
|
|
stopPolling();
|
|
|
|
if (!_pollingEnabled) {
|
|
logger.info('converter:polling:disabled');
|
|
return;
|
|
}
|
|
|
|
logger.info('converter:polling:start', { intervalMs: _pollingInterval });
|
|
|
|
const tick = async () => {
|
|
try {
|
|
await scan();
|
|
} catch (error) {
|
|
logger.error('converter:polling:error', { error: error?.message });
|
|
}
|
|
if (_pollingEnabled) {
|
|
_pollingTimer = setTimeout(tick, _pollingInterval);
|
|
}
|
|
};
|
|
|
|
_pollingTimer = setTimeout(tick, _pollingInterval);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (_pollingTimer) {
|
|
clearTimeout(_pollingTimer);
|
|
_pollingTimer = null;
|
|
}
|
|
}
|
|
|
|
async function restartPolling() {
|
|
await startPolling();
|
|
}
|
|
|
|
// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ──
|
|
|
|
/**
|
|
* Relativen Pfad normalisieren und auf Path-Traversal prüfen.
|
|
* Gibt null zurück wenn der Pfad ungültig ist.
|
|
*/
|
|
function normalizeRelPath(input) {
|
|
if (input === null || input === undefined) return '';
|
|
const raw = String(input).replace(/\\/g, '/').trim();
|
|
if (!raw || raw === '.') return '';
|
|
if (raw.startsWith('/')) return null;
|
|
const normalized = path.posix.normalize(raw);
|
|
if (normalized.startsWith('..')) return null;
|
|
if (normalized === '.') return '';
|
|
return normalized;
|
|
}
|
|
|
|
function makeError(msg, code) {
|
|
const err = new Error(msg);
|
|
err.statusCode = code;
|
|
return err;
|
|
}
|
|
|
|
/**
|
|
* Datei oder Ordner löschen. Aktualisiert DB-Einträge.
|
|
*/
|
|
async function deleteEntry(relPath) {
|
|
const rawDir = await getRawDir();
|
|
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
|
|
|
const rel = normalizeRelPath(relPath);
|
|
if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400);
|
|
|
|
const absPath = path.join(rawDir, rel);
|
|
// Traversal-Schutz
|
|
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
|
|
|
const db = await getDb();
|
|
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
|
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409);
|
|
|
|
if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404);
|
|
|
|
fs.rmSync(absPath, { recursive: true, force: true });
|
|
|
|
await db.run(
|
|
`DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
|
[rel, `${rel}/%`]
|
|
);
|
|
|
|
return { deleted: rel };
|
|
}
|
|
|
|
/**
|
|
* Datei oder Ordner umbenennen. Aktualisiert DB-Einträge.
|
|
*/
|
|
async function renameEntry(relPath, newName) {
|
|
const rawDir = await getRawDir();
|
|
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
|
|
|
const rel = normalizeRelPath(relPath);
|
|
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
|
|
|
const safeName = String(newName || '').trim();
|
|
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
|
throw makeError('Ungültiger Name.', 400);
|
|
}
|
|
|
|
const parentRel = path.posix.dirname(rel);
|
|
const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`;
|
|
|
|
const absOld = path.join(rawDir, rel);
|
|
const absNew = path.join(rawDir, newRel);
|
|
|
|
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
|
if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
|
|
|
const db = await getDb();
|
|
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
|
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409);
|
|
|
|
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
|
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
|
|
|
fs.renameSync(absOld, absNew);
|
|
|
|
const rows = await db.all(
|
|
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
|
[rel, `${rel}/%`]
|
|
);
|
|
for (const row of rows) {
|
|
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
|
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
|
}
|
|
|
|
return { oldRelPath: rel, newRelPath: newRel };
|
|
}
|
|
|
|
/**
|
|
* Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge.
|
|
*/
|
|
async function moveEntry(relPath, targetParentRelPath) {
|
|
const rawDir = await getRawDir();
|
|
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
|
|
|
const rel = normalizeRelPath(relPath);
|
|
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
|
|
|
|
const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : '');
|
|
if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400);
|
|
|
|
const name = path.posix.basename(rel);
|
|
const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`;
|
|
|
|
if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400);
|
|
if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400);
|
|
|
|
const absOld = path.join(rawDir, rel);
|
|
const absNew = path.join(rawDir, newRel);
|
|
|
|
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
|
|
if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
|
|
|
|
const db = await getDb();
|
|
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
|
|
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409);
|
|
|
|
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
|
|
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
|
|
|
|
fs.renameSync(absOld, absNew);
|
|
|
|
const rows = await db.all(
|
|
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
|
|
[rel, `${rel}/%`]
|
|
);
|
|
for (const row of rows) {
|
|
const updatedRel = newRel + row.rel_path.slice(rel.length);
|
|
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
|
|
}
|
|
|
|
return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel };
|
|
}
|
|
|
|
/**
|
|
* Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan).
|
|
*/
|
|
async function createFolder(parentRelPath, name) {
|
|
const rawDir = await getRawDir();
|
|
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
|
|
|
|
const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : '');
|
|
if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400);
|
|
|
|
const safeName = String(name || '').trim();
|
|
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
|
|
throw makeError('Ungültiger Ordnername.', 400);
|
|
}
|
|
|
|
const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`;
|
|
const absPath = path.join(rawDir, newRel);
|
|
|
|
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
|
|
if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409);
|
|
|
|
fs.mkdirSync(absPath, { recursive: true });
|
|
|
|
return { relPath: newRel };
|
|
}
|
|
|
|
// ── Reines FS-Baum-Listing (keine DB) ─────────────────────────────────────
|
|
|
|
const TREE_MAX_DEPTH = 8;
|
|
|
|
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
|
|
if (depth >= TREE_MAX_DEPTH) return [];
|
|
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
|
|
let dirents;
|
|
try {
|
|
dirents = fs.readdirSync(absDir, { withFileTypes: true });
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
|
|
dirents.sort((a, b) => {
|
|
const ad = a.isDirectory() ? 0 : 1;
|
|
const bd = b.isDirectory() ? 0 : 1;
|
|
if (ad !== bd) return ad - bd;
|
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
});
|
|
|
|
const nodes = [];
|
|
for (const dirent of dirents) {
|
|
// Versteckte Einträge überspringen
|
|
if (dirent.name.startsWith('.')) continue;
|
|
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
|
|
if (dirent.isDirectory()) {
|
|
const children = buildRawTree(rawDir, childRel, depth + 1, assignments);
|
|
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
|
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
|
|
} else if (dirent.isFile()) {
|
|
let size = 0;
|
|
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
|
|
const assignment = assignments.get(childRel) || null;
|
|
nodes.push({
|
|
name: dirent.name,
|
|
type: 'file',
|
|
path: childRel,
|
|
size,
|
|
detectedMediaType: detectMediaType(dirent.name),
|
|
detectedFormat: detectFormat(dirent.name),
|
|
jobId: assignment?.jobId || null,
|
|
jobTitle: assignment?.jobTitle || null,
|
|
jobStatus: assignment?.jobStatus || null
|
|
});
|
|
}
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
async function getTree() {
|
|
const rawDir = await getRawDir();
|
|
if (!rawDir || !fs.existsSync(rawDir)) {
|
|
return { rawDir: rawDir || null, tree: null };
|
|
}
|
|
const db = await getDb();
|
|
const rows = await db.all(`
|
|
SELECT
|
|
e.rel_path,
|
|
e.job_id,
|
|
j.title AS job_title,
|
|
j.detected_title AS job_detected_title,
|
|
j.status AS job_status
|
|
FROM converter_scan_entries e
|
|
LEFT JOIN jobs j ON j.id = e.job_id
|
|
WHERE e.job_id IS NOT NULL
|
|
`);
|
|
const assignments = new Map();
|
|
for (const row of rows) {
|
|
const rel = String(row?.rel_path || '').trim();
|
|
const jobId = Number(row?.job_id);
|
|
if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue;
|
|
assignments.set(rel, {
|
|
jobId: Math.trunc(jobId),
|
|
jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null,
|
|
jobStatus: String(row?.job_status || '').trim() || null
|
|
});
|
|
}
|
|
const children = buildRawTree(rawDir, '', 0, assignments);
|
|
const size = children.reduce((s, c) => s + (c.size || 0), 0);
|
|
return {
|
|
rawDir,
|
|
tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children }
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
scan,
|
|
getEntries,
|
|
getEntryById,
|
|
getEntryByRelPath,
|
|
markEntryAsJob,
|
|
setEntryJobAssignment,
|
|
clearEntryJobAssignment,
|
|
clearAssignmentsForJob,
|
|
assignEntriesToJob,
|
|
getRawDir,
|
|
normalizeRelPath,
|
|
getTree,
|
|
startPolling,
|
|
stopPolling,
|
|
restartPolling,
|
|
detectMediaType,
|
|
detectFormat,
|
|
deleteEntry,
|
|
renameEntry,
|
|
moveEntry,
|
|
createFolder
|
|
};
|