0.16.1-7 Release-Bugfixes
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled

This commit is contained in:
2026-05-08 08:10:34 +02:00
parent 024a6305e2
commit 9b437fe5e5
52 changed files with 5815 additions and 2600 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.16.1-6",
"version": "0.16.1-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.16.1-6",
"version": "0.16.1-7",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.16.1-6",
"version": "0.16.1-7",
"private": true,
"type": "commonjs",
"scripts": {
+1
View File
@@ -18,6 +18,7 @@ module.exports = {
port: process.env.PORT ? Number(process.env.PORT) : 3001,
dbPath: resolvedDbPath,
dataDir,
tempDir: resolveOutputPath(process.env.DEFAULT_TEMP_DIR, 'temp'),
corsOrigin: process.env.CORS_ORIGIN || '*',
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
logLevel: process.env.LOG_LEVEL || 'info',
+124 -17
View File
@@ -528,6 +528,7 @@ async function openAndPrepareDatabase() {
await migrateLegacyProfiledToolSettings(dbInstance);
await migrateOutputTemplates(dbInstance);
await removeDeprecatedSettings(dbInstance);
await migrateTmdbMigrationFlags(dbInstance);
await migrateSettingsSchemaMetadata(dbInstance);
await ensurePipelineStateRow(dbInstance);
await backfillJobOutputFolders(dbInstance);
@@ -811,7 +812,9 @@ async function removeDeprecatedSettings(db) {
'use_plugin_architecture_audiobook_analyze',
'use_plugin_architecture_audiobook_encode',
'dvd_series_plugin_enabled',
'dvd_series_prescan_enabled'
'dvd_series_prescan_enabled',
'dvd_series_reuse_disc_profiles',
'musicbrainz_enabled'
];
for (const key of deprecatedKeys) {
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
@@ -828,6 +831,64 @@ async function removeDeprecatedSettings(db) {
);
}
async function migrateTmdbMigrationFlags(db) {
// Keep this migration idempotent. It helps initialize the new migrate_tmdb flag
// for existing rows after OMDb -> TMDb transition.
const nullableReset = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 0
WHERE migrate_tmdb IS NULL
`
);
// Audio-only domains are outside TMDb migration scope.
const nonVideoMarked = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 1
WHERE COALESCE(migrate_tmdb, 0) = 0
AND LOWER(TRIM(COALESCE(media_type, ''))) IN ('cd', 'audiobook')
`
);
// Rows with obvious TMDb assignment can be marked as migrated.
const obviousTmdbMarked = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 1
WHERE COALESCE(migrate_tmdb, 0) = 0
AND (
makemkv_info_json LIKE '%"metadataProvider":"tmdb"%'
OR makemkv_info_json LIKE '%"tmdbId":%'
OR encode_plan_json LIKE '%"metadataProvider":"tmdb"%'
OR encode_plan_json LIKE '%"tmdbId":%'
)
`
);
// Explicit OMDb footprints must stay pending for manual migration.
const explicitOmdbPending = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 0
WHERE (
COALESCE(selected_from_omdb, 0) = 1
OR (omdb_json IS NOT NULL AND TRIM(omdb_json) <> '')
OR makemkv_info_json LIKE '%"metadataProvider":"omdb"%'
OR encode_plan_json LIKE '%"metadataProvider":"omdb"%'
)
`
);
logger.info('migrate:tmdb-flag:done', {
nullableReset: Number(nullableReset?.changes || 0),
nonVideoMarked: Number(nonVideoMarked?.changes || 0),
obviousTmdbMarked: Number(obviousTmdbMarked?.changes || 0),
explicitOmdbPending: Number(explicitOmdbPending?.changes || 0)
});
}
// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten.
const SETTINGS_SCHEMA_METADATA_UPDATES = [
@@ -849,6 +910,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
validation_json: '{}'
},
{
key: 'dvd_series_language',
required: 1,
description: 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.',
validation_json: '{"minLength":2}'
},
{
key: 'dvd_series_fallback_languages',
required: 1,
description: 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.',
validation_json: '{"minLength":2}'
},
{
key: 'output_template_bluray_series_episode',
required: 1,
@@ -973,6 +1046,41 @@ async function migrateSettingsSchemaMetadata(db) {
});
}
const metadataLanguageLabel = 'Film-Sprache';
const metadataLanguageDescription = 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.';
const metadataLanguageResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'dvd_series_language' AND (label != ? OR description != ?)`,
[metadataLanguageLabel, metadataLanguageDescription, metadataLanguageLabel, metadataLanguageDescription]
);
if (metadataLanguageResult?.changes > 0) {
logger.info('migrate:settings-schema-language-updated', {
key: 'dvd_series_language',
label: metadataLanguageLabel
});
}
const metadataFallbackLanguageLabel = 'Fallback Sprache';
const metadataFallbackLanguageDescription = 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.';
const metadataFallbackLanguageResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'dvd_series_fallback_languages' AND (label != ? OR description != ?)`,
[
metadataFallbackLanguageLabel,
metadataFallbackLanguageDescription,
metadataFallbackLanguageLabel,
metadataFallbackLanguageDescription
]
);
if (metadataFallbackLanguageResult?.changes > 0) {
logger.info('migrate:settings-schema-fallback-language-updated', {
key: 'dvd_series_fallback_languages',
label: metadataFallbackLanguageLabel
});
}
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
@@ -1063,9 +1171,18 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)`
VALUES ('series_playall_tolerance_lower_pct', 'Tools', 'Serie: PlayAll Untergrenze (%)', 'number', 1, 'Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 234)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true')`);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_lower_pct', '10')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_playall_tolerance_upper_pct', 'Tools', 'Serie: PlayAll Obergrenze (%)', 'number', 1, 'Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 235)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_upper_pct', '10')`);
await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
@@ -1226,30 +1343,20 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
VALUES ('dvd_series_language', 'Metadaten', 'Film-Sprache', 'string', 1, 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'Fallback Sprache', 'string', 1, 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);
await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
await db.run(`DELETE FROM settings_values WHERE key = 'dvd_series_order_preference'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'dvd_series_order_preference'`);
// Converter-Pfade und Eigentümer
await db.run(
+23
View File
@@ -26,6 +26,7 @@ function truncate(value, maxLen = 1500) {
module.exports = function requestLogger(req, res, next) {
const reqId = randomUUID();
const startedAt = Date.now();
let completionLogged = false;
req.reqId = reqId;
@@ -38,15 +39,37 @@ module.exports = function requestLogger(req, res, next) {
body: truncate(req.body)
});
const logCompletion = (kind) => {
if (completionLogged) {
return;
}
completionLogged = true;
logger.info('request:end', {
reqId,
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startedAt,
completion: kind
});
};
res.on('finish', () => {
logCompletion('finish');
});
res.on('close', () => {
if (!res.writableEnded) {
logger.warn('request:aborted', {
reqId,
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startedAt
});
return;
}
logCompletion('close');
});
next();
+14 -10
View File
@@ -2,7 +2,7 @@
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService');
const tmdbService = require('../services/tmdbService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files');
@@ -11,7 +11,7 @@ const { ensureDir } = require('../utils/files');
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
*
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
* analyze() → OMDB-Suche + Disc-Metadaten vorbereiten
* analyze() → TMDb-Suche + Disc-Metadaten vorbereiten
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
* rip() → makemkvcon backup/mkv
* encode() → HandBrakeCLI
@@ -54,13 +54,13 @@ class VideoDiscPlugin extends SourcePlugin {
}
/**
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, omdbCandidates } zurück.
* Führt eine TMDb-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, metadataCandidates } zurück.
*
* @param {string} devicePath
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
* @param {PluginContext} ctx
* @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>}
* @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
@@ -76,8 +76,8 @@ class VideoDiscPlugin extends SourcePlugin {
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, {
jobId: job?.id,
error: error?.message || String(error)
});
@@ -87,10 +87,10 @@ class VideoDiscPlugin extends SourcePlugin {
ctx.logger.info(`${this.id}:analyze:done`, {
jobId: job?.id,
detectedTitle,
omdbCandidates: omdbCandidates.length
metadataCandidates: metadataCandidates.length
});
return { detectedTitle, omdbCandidates };
return { detectedTitle, metadataCandidates };
}
/**
@@ -277,6 +277,9 @@ class VideoDiscPlugin extends SourcePlugin {
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
const makeMkvLineFilter = typeof ctx.extra?.makeMkvLineFilter === 'function'
? ctx.extra.makeMkvLineFilter
: null;
let runInfo;
if (runCommandFn) {
runInfo = await runCommandFn({
@@ -285,7 +288,8 @@ class VideoDiscPlugin extends SourcePlugin {
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
parser: parseMakeMkvProgress,
lineFilter: makeMkvLineFilter
});
} else {
runInfo = await _spawnAndWait({
+4 -2
View File
@@ -2,7 +2,6 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
@@ -10,6 +9,7 @@ const pipelineService = require('../services/pipelineService');
const converterScanService = require('../services/converterScanService');
const historyService = require('../services/historyService');
const logger = require('../services/logger').child('CONVERTER_ROUTE');
const { tempDir } = require('../config');
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
function resolveTarget(rawDir, input) {
@@ -20,9 +20,11 @@ function resolveTarget(rawDir, input) {
}
const router = express.Router();
const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads');
fs.mkdirSync(converterUploadDir, { recursive: true });
const converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads')
dest: converterUploadDir
});
// ── Scan ──────────────────────────────────────────────────────────────────
+95 -9
View File
@@ -66,6 +66,42 @@ router.get(
})
);
router.get(
'/tmdb-migration/pending',
asyncHandler(async (req, res) => {
const parsedLimit = Number(req.query.limit);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.trunc(parsedLimit)
: 500;
logger.info('get:tmdb-migration:pending', {
reqId: req.reqId,
limit
});
const jobs = await historyService.getTmdbMigrationPendingJobs({
limit,
includeFsChecks: false
});
res.json({ jobs });
})
);
router.post(
'/:id/tmdb-migration',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const migrated = ['1', 'true', 'yes', 'on'].includes(
String(req.body?.migrated ?? req.body?.migrateTMDB ?? '').trim().toLowerCase()
);
logger.info('post:tmdb-migration:flag', {
reqId: req.reqId,
id,
migrated
});
const job = await historyService.setTmdbMigrationFlag(id, migrated);
res.json({ job });
})
);
router.post(
'/orphan-raw/import',
asyncHandler(async (req, res) => {
@@ -104,11 +140,21 @@ router.post(
);
router.post(
'/:id/omdb/assign',
'/orphan-raw/delete',
asyncHandler(async (req, res) => {
const rawPath = String(req.body?.rawPath || '').trim();
logger.warn('post:orphan-raw:delete', { reqId: req.reqId, rawPath });
const result = await historyService.deleteOrphanRawFolder(rawPath);
res.json(result);
})
);
router.post(
'/:id/metadata/assign',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const payload = req.body || {};
logger.info('post:job:omdb:assign', {
logger.info('post:job:metadata:assign', {
reqId: req.reqId,
id,
imdbId: payload?.imdbId || null,
@@ -116,11 +162,11 @@ router.post(
hasYear: Boolean(payload?.year)
});
const job = await historyService.assignOmdbMetadata(id, payload);
const job = await historyService.assignMetadata(id, payload);
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => {
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message });
logger.warn('post:job:metadata:assign:rename-failed', { id, error: err.message });
});
res.json({ job });
@@ -141,7 +187,15 @@ router.post(
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
});
const job = await historyService.assignCdMetadata(id, payload);
const job = await pipelineService.selectCdMetadata({
jobId: id,
title: payload?.title,
artist: payload?.artist,
year: payload?.year,
mbId: payload?.mbId,
coverUrl: payload?.coverUrl,
tracks: payload?.tracks
});
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => {
@@ -250,9 +304,10 @@ router.post(
});
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined;
const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !requestedResetDriveState;
: null;
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
? req.body.selectedRawPaths
.map((item) => String(item || '').trim())
@@ -284,12 +339,35 @@ router.post(
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
))
);
const selectedDeleteJobIds = Array.isArray(preview?.selectedJobIds)
? preview.selectedJobIds
: [id];
const selectedDeleteJobs = await historyService.getJobsByIds(selectedDeleteJobIds).catch(() => []);
const autoResetDriveStateForPhysicalCd = Array.isArray(selectedDeleteJobs) && selectedDeleteJobs.some((job) => {
const mediaType = String(job?.media_type || job?.mediaType || '').trim().toLowerCase();
const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
const status = String(job?.status || '').trim().toUpperCase();
const lastState = String(job?.last_state || '').trim().toUpperCase();
const isCdJob = mediaType === 'cd'
|| jobKind === 'cd'
|| status.startsWith('CD_')
|| lastState.startsWith('CD_');
if (!isCdJob) {
return false;
}
const discDevice = String(job?.disc_device || job?.discDevice || '').trim();
return Boolean(discDevice) && !discDevice.startsWith('__virtual__');
});
const requestedResetDriveStateEffective = requestedResetDriveState || autoResetDriveStateForPhysicalCd;
const resetDriveState = containsOrphanRawImportJob
? false
: requestedResetDriveState;
: requestedResetDriveStateEffective;
const keepDetectedDevice = containsOrphanRawImportJob
? true
: requestedKeepDetectedDevice;
: (hasRequestedKeepDetectedDevice
? requestedKeepDetectedDevice
: !requestedResetDriveStateEffective);
if (containsOrphanRawImportJob && requestedResetDriveState) {
logger.info('post:delete-job:orphan-drive-reset-skipped', {
@@ -298,6 +376,14 @@ router.post(
});
}
if (autoResetDriveStateForPhysicalCd && !requestedResetDriveState) {
logger.info('post:delete-job:auto-drive-reset-cd', {
reqId: req.reqId,
id,
selectedJobIds: selectedDeleteJobIds
});
}
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.filter((row) => Boolean(row?.selected))
+46 -114
View File
@@ -1,6 +1,5 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
@@ -11,86 +10,16 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
const settingsService = require('../services/settingsService');
const logger = require('../services/logger').child('PIPELINE_ROUTE');
const activationBytesService = require('../services/activationBytesService');
const { defaultAudiobookDir } = require('../config');
const { tempDir } = require('../config');
const { getDb } = require('../db/database');
const router = express.Router();
const audiobookUploadDir = path.join(tempDir, 'ripster-audiobook-uploads');
fs.mkdirSync(audiobookUploadDir, { recursive: true });
const audiobookUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
dest: audiobookUploadDir
});
const AUDIOBOOK_TREE_MAX_DEPTH = 8;
function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) {
if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) {
return [];
}
const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
let dirents = [];
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_error) {
return [];
}
dirents.sort((left, right) => {
const leftOrder = left.isDirectory() ? 0 : 1;
const rightOrder = right.isDirectory() ? 0 : 1;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) {
continue;
}
const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name;
const childAbsPath = path.join(rootDir, childRelPath);
if (dirent.isDirectory()) {
const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
let mtime = null;
try {
mtime = fs.statSync(childAbsPath).mtime.toISOString();
} catch (_error) {
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'folder',
path: childRelPath,
size,
mtime,
children
});
continue;
}
if (dirent.isFile()) {
let size = 0;
let mtime = null;
try {
const stat = fs.statSync(childAbsPath);
size = Number(stat?.size || 0);
mtime = stat?.mtime ? stat.mtime.toISOString() : null;
} catch (_error) {
size = 0;
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'file',
path: childRelPath,
size,
mtime
});
}
}
return nodes;
}
router.get(
'/state',
asyncHandler(async (req, res) => {
@@ -146,11 +75,11 @@ router.post(
);
router.get(
'/omdb/search',
'/tmdb/movie/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
logger.info('get:omdb:search', { reqId: req.reqId, query });
const results = await pipelineService.searchOmdb(String(query));
logger.info('get:tmdb:movie-search', { reqId: req.reqId, query });
const results = await pipelineService.searchTmdbMovies(String(query));
res.json({ results });
})
);
@@ -170,8 +99,12 @@ router.get(
'/cd/musicbrainz/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
const results = await pipelineService.searchMusicBrainz(String(query));
const trackCountRaw = Number(req.query.trackCount);
const trackCount = Number.isFinite(trackCountRaw) && trackCountRaw > 0
? Math.trunc(trackCountRaw)
: null;
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query, trackCount });
const results = await pipelineService.searchMusicBrainz(String(query), { trackCount });
res.json({ results });
})
);
@@ -318,47 +251,19 @@ router.get(
})
);
router.get(
'/audiobook/output-tree',
asyncHandler(async (_req, res) => {
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
const configuredOutputDir = String(settings?.movie_dir || '').trim();
const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null;
const outputDir = rawOutputDir
? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir))
: null;
if (!outputDir || !fs.existsSync(outputDir)) {
res.json({ outputDir, tree: null });
return;
}
const children = buildAudiobookOutputTree(outputDir, '', 0);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
res.json({
outputDir,
tree: {
name: path.basename(outputDir) || 'audiobooks',
type: 'folder',
path: '',
size,
mtime: null,
children
}
});
})
);
router.post(
'/select-metadata',
asyncHandler(async (req, res) => {
const {
jobId,
title,
artist,
year,
imdbId,
poster,
fromOmdb,
mbId,
coverUrl,
tracks,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
@@ -387,10 +292,13 @@ router.post(
reqId: req.reqId,
jobId,
title,
artist,
year,
imdbId,
poster,
fromOmdb,
mbId,
coverUrl,
trackCount: Array.isArray(tracks) ? tracks.length : null,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
@@ -405,13 +313,37 @@ router.post(
existingDiscNumber
});
const looksLikeCdMetadataPayload = (
artist !== undefined
|| mbId !== undefined
|| coverUrl !== undefined
|| Array.isArray(tracks)
);
if (looksLikeCdMetadataPayload) {
logger.warn('post:select-metadata:compat-cd-route', {
reqId: req.reqId,
jobId,
trackCount: Array.isArray(tracks) ? tracks.length : 0
});
const job = await pipelineService.selectCdMetadata({
jobId: Number(jobId),
title,
artist,
year,
mbId,
coverUrl: coverUrl || poster || null,
tracks
});
res.json({ job });
return;
}
const job = await pipelineService.selectMetadata({
jobId: Number(jobId),
title,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
@@ -130,12 +130,6 @@ function collectCoverCandidates(row) {
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
}
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
const omdbPoster = String(omdbInfo?.Poster || '').trim();
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
push(omdbPoster, 'omdb_json.Poster');
}
return candidates;
}
@@ -280,8 +274,7 @@ class CoverArtRecoveryService {
status,
poster_url,
imdb_id,
makemkv_info_json,
omdb_json
makemkv_info_json
FROM jobs
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
`
@@ -1096,7 +1096,9 @@ class HardwareMonitorService {
}
const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint;
const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint);
const shouldShowExternal = exists || hasDedicatedMount;
// External storage entries must only be visible when a dedicated filesystem
// mount is present. Existing directories alone are not enough.
const shouldShowExternal = hasDedicatedMount;
if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) {
return hiddenResult({
path: resolvedPath,
File diff suppressed because it is too large Load Diff
+33 -22
View File
@@ -1,4 +1,3 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('MUSICBRAINZ');
const MB_BASE = 'https://musicbrainz.org/ws/2';
@@ -74,6 +73,17 @@ function normalizeRelease(release) {
artist: artistFromTrack || artistFromRecording || artistCredit || null
};
});
const rawReleaseTrackCount = Number(release['track-count']);
const mediaTrackCount = media.reduce((sum, medium) => {
const mediumCount = Number(medium?.['track-count'] ?? medium?.trackCount);
if (!Number.isFinite(mediumCount) || mediumCount <= 0) {
return sum;
}
return sum + Math.trunc(mediumCount);
}, 0);
const trackCount = Number.isFinite(rawReleaseTrackCount) && rawReleaseTrackCount > 0
? Math.trunc(rawReleaseTrackCount)
: (mediaTrackCount > 0 ? mediaTrackCount : normalizedTracks.length);
// Always generate the CAA URL when an id is present; the browser/onError
// handles 404s for releases that have no front cover.
@@ -92,27 +102,21 @@ function normalizeRelease(release) {
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
: null,
coverArtUrl,
tracks: normalizedTracks
tracks: normalizedTracks,
trackCount
};
}
class MusicBrainzService {
async isEnabled() {
const settings = await settingsService.getSettingsMap();
return settings.musicbrainz_enabled !== 'false';
}
async searchByTitle(query) {
async searchByTitle(query, options = {}) {
const q = String(query || '').trim();
if (!q) {
return [];
}
const enabled = await this.isEnabled();
if (!enabled) {
return [];
}
const expectedTrackCountRaw = Number(options?.trackCount);
const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0
? Math.trunc(expectedTrackCountRaw)
: null;
logger.info('search:start', { query: q });
const url = new URL(`${MB_BASE}/release`);
@@ -124,11 +128,23 @@ class MusicBrainzService {
try {
const data = await mbFetch(url.toString());
const releases = Array.isArray(data.releases) ? data.releases : [];
const results = releases.map(normalizeRelease).filter(Boolean);
logger.info('search:done', { query: q, count: results.length });
const normalizedReleases = releases.map(normalizeRelease).filter(Boolean);
const results = expectedTrackCount
? normalizedReleases.filter((release) => Number(release?.trackCount || 0) === expectedTrackCount)
: normalizedReleases;
logger.info('search:done', {
query: q,
expectedTrackCount,
sourceCount: normalizedReleases.length,
count: results.length
});
return results;
} catch (error) {
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
logger.warn('search:failed', {
query: q,
expectedTrackCount,
error: String(error?.message || error)
});
return [];
}
}
@@ -143,11 +159,6 @@ class MusicBrainzService {
return null;
}
const enabled = await this.isEnabled();
if (!enabled) {
return null;
}
logger.info('getById:start', { mbId: id });
const url = new URL(`${MB_BASE}/release/${id}`);
-225
View File
@@ -1,225 +0,0 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
const OMDB_BASE_URL = 'https://www.omdbapi.com/';
const OMDB_TIMEOUT_MS = 10000;
const OMDB_MAX_ATTEMPTS = 2;
const OMDB_RETRY_BASE_DELAY_MS = 300;
function normalizeOmdbTimeoutMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return OMDB_TIMEOUT_MS;
}
return Math.max(1000, Math.trunc(parsed));
}
function isRetryableOmdbError(error, aborted = false) {
if (aborted) {
return true;
}
const code = String(error?.code || '').trim().toUpperCase();
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
return true;
}
const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
return true;
}
const message = String(error?.message || '').toLowerCase();
return message.includes('timed out') || message.includes('timeout');
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, 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)));
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
logger.warn('request:http-failed', {
...meta,
status: response.status,
attempt,
maxAttempts
});
return null;
}
return await response.json();
} catch (error) {
const aborted = error?.name === 'AbortError';
const retryable = isRetryableOmdbError(error, aborted);
const willRetry = retryable && attempt < maxAttempts;
logger[willRetry ? 'info' : 'warn']('request:failed', {
...meta,
timeoutMs,
aborted,
retryable,
attempt,
maxAttempts,
willRetry,
message: error?.message || String(error)
});
if (willRetry) {
await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
continue;
}
return null;
} finally {
clearTimeout(timer);
}
}
return null;
}
async search(query) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) {
return [];
}
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;
if (!apiKey) {
return [];
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
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)
};
};
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 [];
}
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
return results;
}
async fetchByImdbId(imdbId) {
const normalizedId = String(imdbId || '').trim().toLowerCase();
if (!/^tt\d{6,12}$/.test(normalizedId)) {
return null;
}
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return null;
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
if (!data || typeof data !== 'object') {
return null;
}
if (data.Response === 'False') {
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
return null;
}
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
const year = yearMatch ? Number(yearMatch[0]) : null;
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
const result = {
title: data.Title || null,
year: Number.isFinite(year) ? year : null,
imdbId: String(data.imdbID || normalizedId),
type: data.Type || null,
poster,
raw: data
};
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
return result;
}
}
module.exports = new OmdbService();
File diff suppressed because it is too large Load Diff
+28 -10
View File
@@ -56,24 +56,27 @@ function mapStepRow(row) {
function terminateChildProcess(child, { immediate = false } = {}) {
if (!child) {
return;
return false;
}
const signal = immediate ? 'SIGKILL' : 'SIGTERM';
const pid = Number(child.pid);
let signaled = false;
if (Number.isFinite(pid) && pid > 0) {
try {
// For detached children this targets the full process group.
process.kill(-pid, signal);
return;
signaled = true;
} catch (_error) {
// Fall through to direct child signal.
// ignore and continue with direct child signal
}
}
try {
child.kill(signal);
signaled = true;
} catch (_error) {
return;
// ignore
}
return signaled;
}
function appendTailText(currentValue, nextChunk, maxChars = 12000) {
@@ -443,6 +446,7 @@ class ScriptChainService {
currentStepType: null,
activeWaitResolve: null,
activeChild: null,
activeProcessHandle: null,
activeChildTermination: null
};
const emitRuntimeStep = (payload = {}) => {
@@ -469,6 +473,23 @@ class ScriptChainService {
message: 'Abbruch angefordert',
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
});
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel';
let handleCancelled = false;
if (typeof controlState.activeProcessHandle?.cancel === 'function') {
controlState.activeProcessHandle.cancel();
handleCancelled = true;
}
const signaled = terminateChildProcess(controlState.activeChild, { immediate: true });
if (!handleCancelled && !signaled) {
logger.warn('chain:cancel:no-signal-sent', {
chainId,
scriptPid: Number(controlState.activeChild?.pid || 0) || null
});
}
}
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
@@ -476,12 +497,6 @@ class ScriptChainService {
// ignore appendLog failures for control actions
}
}
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel';
terminateChildProcess(controlState.activeChild, { immediate: true });
}
return { accepted: true, message: 'Abbruch angefordert.' };
};
const requestNextStep = async () => {
@@ -662,6 +677,7 @@ class ScriptChainService {
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
}
});
controlState.activeProcessHandle = processHandle;
let runError = null;
let exitCode = 0;
let signal = null;
@@ -676,6 +692,7 @@ class ScriptChainService {
}
const termination = controlState.activeChildTermination;
controlState.activeChild = null;
controlState.activeProcessHandle = null;
controlState.activeChildTermination = null;
if (runError && exitCode === null && !termination) {
throw runError;
@@ -829,6 +846,7 @@ class ScriptChainService {
break;
} finally {
controlState.activeChild = null;
controlState.activeProcessHandle = null;
controlState.activeChildTermination = null;
if (prepared?.cleanup) {
await prepared.cleanup();
+7 -6
View File
@@ -1,5 +1,4 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { getDb } = require('../db/database');
@@ -8,6 +7,7 @@ const settingsService = require('./settingsService');
const runtimeActivityService = require('./runtimeActivityService');
const { streamLines } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const { tempDir } = require('../config');
const SCRIPT_NAME_MAX_LENGTH = 120;
const SCRIPT_BODY_MAX_LENGTH = 200000;
@@ -532,8 +532,9 @@ class ScriptService {
async createExecutableScriptFile(script, context = {}) {
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
const scriptBody = normalizeScriptBody(script?.scriptBody);
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
const scriptPath = path.join(tempDir, 'script.sh');
fs.mkdirSync(tempDir, { recursive: true });
const scriptTempDir = await fs.promises.mkdtemp(path.join(tempDir, 'ripster-script-'));
const scriptPath = path.join(scriptTempDir, 'script.sh');
const wrapped = buildScriptWrapper(scriptBody, {
...context,
scriptId: script?.id ?? context?.scriptId ?? '',
@@ -548,19 +549,19 @@ class ScriptService {
const cleanup = async () => {
try {
await fs.promises.rm(tempDir, { recursive: true, force: true });
await fs.promises.rm(scriptTempDir, { recursive: true, force: true });
} catch (error) {
logger.warn('script:temp-cleanup-failed', {
scriptId: script?.id ?? null,
scriptName: name,
tempDir,
tempDir: scriptTempDir,
error: errorToMeta(error)
});
}
};
return {
tempDir,
tempDir: scriptTempDir,
scriptPath,
cmd: '/usr/bin/env',
args: ['bash', scriptPath],
+20 -7
View File
@@ -1,5 +1,4 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { getDb } = require('../db/database');
@@ -29,7 +28,8 @@ const {
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR,
defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR,
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR,
tempDir: DEFAULT_TEMP_DIR
} = require('../config');
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
@@ -44,11 +44,15 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
]);
const SENSITIVE_SETTING_KEYS = new Set([
'makemkv_registration_key',
'omdb_api_key',
'tmdb_api_read_access_token',
'pushover_token',
'pushover_user'
]);
const LEGACY_HIDDEN_SETTING_KEYS = new Set([
'omdb_api_key',
'omdb_default_type',
'omdb_timeout_ms'
]);
const IMMUTABLE_SETTING_KEYS = new Set([
'makemkv_command',
'mediainfo_command',
@@ -197,6 +201,11 @@ function normalizeBooleanSetting(rawValue, fallback = true) {
return toBoolean(normalizedRaw);
}
function isHiddenSettingKey(key) {
const normalized = String(key || '').trim().toLowerCase();
return LEGACY_HIDDEN_SETTING_KEYS.has(normalized);
}
function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) {
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true);
@@ -856,7 +865,8 @@ class SettingsService {
async getSchemaRows() {
const db = await getDb();
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
const rows = await db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
return rows.filter((row) => !isHiddenSettingKey(row?.key));
}
async getSettingsMap(options = {}) {
@@ -1012,7 +1022,9 @@ class SettingsService {
`
);
return rows.map((row) => ({
return rows
.filter((row) => !isHiddenSettingKey(row?.key))
.map((row) => ({
key: row.key,
category: row.category,
label: row.label,
@@ -1025,7 +1037,7 @@ class SettingsService {
value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
orderIndex: row.order_index,
depends_on: row.depends_on ?? null
}));
}));
}
async getFlatSettings(options = {}) {
@@ -1554,7 +1566,8 @@ class SettingsService {
}
const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const exportFile = path.join(os.tmpdir(), `${exportName}.json`);
fs.mkdirSync(DEFAULT_TEMP_DIR, { recursive: true });
const exportFile = path.join(DEFAULT_TEMP_DIR, `${exportName}.json`);
const args = [
'--scan',
'-i',
+3 -2
View File
@@ -1,9 +1,9 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const logger = require('./logger').child('TEMP_CLEANUP');
const { tempDir } = require('../config');
const TMP_ROOT = os.tmpdir();
const TMP_ROOT = tempDir;
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
@@ -65,6 +65,7 @@ class TempCleanupService {
}
async runSweep(reason = 'manual') {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const deleted = [];
const skipped = [];
const failed = [];
+447 -64
View File
@@ -8,6 +8,49 @@ const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
const TMDB_TIMEOUT_MS = 15000;
class TmdbService {
hasCreditsPayload(details = null) {
const credits = details?.credits && typeof details.credits === 'object'
? details.credits
: null;
if (!credits) {
return false;
}
const crew = Array.isArray(credits.crew) ? credits.crew : [];
const cast = Array.isArray(credits.cast) ? credits.cast : [];
return crew.length > 0 || cast.length > 0;
}
mergeCreditsIntoDetails(details = null, credits = null) {
const sourceDetails = details && typeof details === 'object' ? details : {};
const sourceCredits = credits && typeof credits === 'object' ? credits : null;
if (!sourceCredits) {
return sourceDetails;
}
return {
...sourceDetails,
credits: {
...(sourceDetails?.credits && typeof sourceDetails.credits === 'object' ? sourceDetails.credits : {}),
...(sourceCredits && typeof sourceCredits === 'object' ? sourceCredits : {})
}
};
}
extractDirectorNames(crewValues = []) {
const crew = Array.isArray(crewValues) ? crewValues : [];
const byJob = this.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
if (byJob.length > 0) {
return byJob;
}
// Fallback for edge cases where "Director" job is missing in localized/partial payloads.
return this.normalizeNameList(
crew.filter((member) => String(member?.department || '').trim().toLowerCase() === 'directing'),
{ maxItems: 5 }
);
}
isAbortError(error) {
const name = String(error?.name || '').trim().toLowerCase();
const message = String(error?.message || '').trim().toLowerCase();
@@ -103,9 +146,11 @@ class TmdbService {
async getConfig() {
const settings = await settingsService.getSettingsMap();
const fallbackLanguages = this.parseLanguageList(settings.dvd_series_fallback_languages);
return {
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE',
fallbackLanguages
};
}
@@ -119,6 +164,45 @@ class TmdbService {
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
}
parseLanguageList(value) {
const source = Array.isArray(value)
? value
: String(value || '').split(',');
const normalized = [];
const seen = new Set();
for (const item of source) {
const lang = String(item || '').trim();
if (!lang) {
continue;
}
const dedupeKey = lang.toLowerCase();
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
normalized.push(lang);
}
return normalized;
}
buildLanguageCandidates(primaryLanguage = null, fallbackLanguages = []) {
const preferred = String(primaryLanguage || '').trim() || 'de-DE';
const fallback = this.parseLanguageList(fallbackLanguages);
const combined = [preferred, ...fallback];
return this.parseLanguageList(combined);
}
async resolveLanguageCandidates(options = {}) {
const config = await this.getConfig();
const explicitLanguage = String(options.language || '').trim() || null;
const explicitFallbackLanguages = options.fallbackLanguages !== undefined
? this.parseLanguageList(options.fallbackLanguages)
: null;
const primaryLanguage = explicitLanguage || String(config.language || '').trim() || 'de-DE';
const fallbackLanguages = explicitFallbackLanguages ?? config.fallbackLanguages;
return this.buildLanguageCandidates(primaryLanguage, fallbackLanguages);
}
async request(pathName, options = {}) {
const config = await this.getConfig();
if (!config.readAccessToken) {
@@ -177,47 +261,135 @@ class TmdbService {
if (!normalizedQuery || !(await this.isConfigured())) {
return [];
}
const language = await this.resolveLanguage(options.language);
const languageCandidates = await this.resolveLanguageCandidates(options);
let lastFailureCode = null;
let lastFailureStatusCode = null;
let lastFailureMessage = null;
let data = null;
try {
data = await this.request('/search/tv', {
query: {
for (const language of languageCandidates) {
let data = null;
try {
data = await this.request('/search/tv', {
query: {
query: normalizedQuery,
first_air_date_year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
lastFailureCode = failureCode;
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
lastFailureMessage = error?.message || String(error);
logger.warn('search:failed', {
query: normalizedQuery,
first_air_date_year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
logger.warn('search:failed', {
query: normalizedQuery,
error: error?.message || String(error),
failureCode,
statusCode: Number(error?.statusCode || 0) || null
});
return this.attachFailureCode([], failureCode);
}
error: lastFailureMessage,
failureCode,
statusCode: lastFailureStatusCode
});
continue;
}
const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows
.map((row) => ({
id: Number(row?.id || 0) || null,
title: String(row?.name || row?.original_name || '').trim() || null,
originalTitle: String(row?.original_name || '').trim() || null,
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0
}))
.filter((row) => row.id && row.title);
return this.attachFailureCode(normalizedRows, null);
const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows
.map((row) => ({
id: Number(row?.id || 0) || null,
title: String(row?.name || row?.original_name || '').trim() || null,
originalTitle: String(row?.original_name || '').trim() || null,
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0
}))
.filter((row) => row.id && row.title);
if (normalizedRows.length > 0) {
return this.attachFailureCode(normalizedRows, null);
}
}
if (lastFailureCode) {
logger.warn('search:failed:all-languages', {
query: normalizedQuery,
error: lastFailureMessage,
failureCode: lastFailureCode,
statusCode: lastFailureStatusCode
});
}
return this.attachFailureCode([], lastFailureCode);
}
async searchMovies(query, options = {}) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery || !(await this.isConfigured())) {
return [];
}
const languageCandidates = await this.resolveLanguageCandidates(options);
let lastFailureCode = null;
let lastFailureStatusCode = null;
let lastFailureMessage = null;
for (const language of languageCandidates) {
let data = null;
try {
data = await this.request('/search/movie', {
query: {
query: normalizedQuery,
year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
lastFailureCode = failureCode;
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
lastFailureMessage = error?.message || String(error);
logger.warn('movie:search:failed', {
query: normalizedQuery,
language,
error: lastFailureMessage,
failureCode,
statusCode: lastFailureStatusCode
});
continue;
}
const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows
.map((row) => ({
id: Number(row?.id || 0) || null,
title: String(row?.title || row?.original_title || '').trim() || null,
originalTitle: String(row?.original_title || '').trim() || null,
year: Number(String(row?.release_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0
}))
.filter((row) => row.id && row.title);
if (normalizedRows.length > 0) {
return this.attachFailureCode(normalizedRows, null);
}
}
if (lastFailureCode) {
logger.warn('movie:search:failed:all-languages', {
query: normalizedQuery,
error: lastFailureMessage,
failureCode: lastFailureCode,
statusCode: lastFailureStatusCode
});
}
return this.attachFailureCode([], lastFailureCode);
}
async getSeriesDetails(seriesId, options = {}) {
@@ -225,24 +397,111 @@ class TmdbService {
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
const languageCandidates = await this.resolveLanguageCandidates(options);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return this.request(`/tv/${Math.trunc(id)}`, {
let fallbackDetails = null;
for (const language of languageCandidates) {
const details = await this.request(`/tv/${Math.trunc(id)}`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
}
}).catch((error) => {
logger.warn('series:details:failed', {
seriesId: Math.trunc(id),
language,
error: error?.message || String(error)
});
return null;
});
if (!details) {
continue;
}
const hasLocalizedPayload = Boolean(
String(details?.name || '').trim()
|| String(details?.overview || '').trim()
);
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
}
async getMovieDetails(movieId, options = {}) {
const id = Number(movieId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const languageCandidates = await this.resolveLanguageCandidates(options);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
let fallbackDetails = null;
for (const language of languageCandidates) {
const details = await this.request(`/movie/${Math.trunc(id)}`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
}
}).catch((error) => {
logger.warn('movie:details:failed', {
movieId: Math.trunc(id),
language,
error: error?.message || String(error)
});
return null;
});
if (!details) {
continue;
}
const hasLocalizedPayload = Boolean(
String(details?.title || '').trim()
|| String(details?.overview || '').trim()
);
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
}
async getMovieCredits(movieId, options = {}) {
const id = Number(movieId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/movie/${Math.trunc(id)}/credits`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
language
}
}).catch((error) => {
logger.warn('series:details:failed', {
seriesId: Math.trunc(id),
logger.warn('movie:credits:failed', {
movieId: Math.trunc(id),
error: error?.message || String(error)
});
return null;
});
}
async getMovieDetailsWithCredits(movieId, options = {}) {
const details = await this.getMovieDetails(movieId, options);
if (!details) {
return null;
}
const ensureCredits = options?.ensureCredits === true;
if (!ensureCredits || this.hasCreditsPayload(details)) {
return details;
}
const credits = await this.getMovieCredits(movieId, options);
return this.mergeCreditsIntoDetails(details, credits);
}
async getEpisodeGroups(seriesId) {
const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
@@ -263,18 +522,31 @@ class TmdbService {
if (!normalizedId || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/episode_group/${normalizedId}`, {
query: {
language
}
}).catch((error) => {
logger.warn('episode-group:details:failed', {
groupId: normalizedId,
error: error?.message || String(error)
const languageCandidates = await this.resolveLanguageCandidates(options);
let fallbackDetails = null;
for (const language of languageCandidates) {
const details = await this.request(`/tv/episode_group/${normalizedId}`, {
query: {
language
}
}).catch((error) => {
logger.warn('episode-group:details:failed', {
groupId: normalizedId,
language,
error: error?.message || String(error)
});
return null;
});
return null;
});
if (!details) {
continue;
}
const hasLocalizedPayload = Boolean(String(details?.name || '').trim() || String(details?.description || '').trim());
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
}
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
@@ -283,12 +555,28 @@ class TmdbService {
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
query: {
language
const languageCandidates = await this.resolveLanguageCandidates(options);
let fallbackDetails = null;
for (const language of languageCandidates) {
const details = await this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
query: {
language
}
}).catch(() => null);
if (!details) {
continue;
}
}).catch(() => null);
const hasLocalizedPayload = Boolean(
String(details?.name || '').trim()
|| String(details?.overview || '').trim()
|| (Array.isArray(details?.episodes) && details.episodes.length > 0)
);
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
}
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
@@ -343,10 +631,7 @@ class TmdbService {
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
const genres = Array.isArray(details?.genres) ? details.genres : [];
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
const directorNames = this.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
const directorNames = this.extractDirectorNames(crew);
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
@@ -375,6 +660,39 @@ class TmdbService {
};
}
buildMovieDetailsSummary(movieDetails = null) {
const details = movieDetails && typeof movieDetails === 'object' ? movieDetails : {};
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
const genres = Array.isArray(details?.genres) ? details.genres : [];
const directorNames = this.extractDirectorNames(crew);
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
const voteAverageRaw = Number(details?.vote_average || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const voteCount = Number(details?.vote_count || 0);
const imdbId = String(details?.external_ids?.imdb_id || details?.imdb_id || '').trim() || null;
const releaseDate = String(details?.release_date || '').trim() || null;
const runtime = this.formatRuntimeLabel(details?.runtime);
return {
director: directorNames.length > 0 ? directorNames.join(', ') : null,
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
runtime,
runtimeLabel: runtime,
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
voteAverage,
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
rottenTomatoes: null,
imdbId,
tmdbId: Number(details?.id || 0) || null,
releaseDate
};
}
buildSeriesMetadataCandidate(series = {}, options = {}) {
const season = series?.season && typeof series.season === 'object'
? series.season
@@ -404,6 +722,35 @@ class TmdbService {
};
}
buildMovieMetadataCandidate(movie = {}, options = {}) {
const detailsSummary = options?.detailsSummary && typeof options.detailsSummary === 'object'
? options.detailsSummary
: null;
const tmdbId = Number(movie?.id || movie?.tmdbId || 0) || null;
const releaseDate = String(movie?.releaseDate || detailsSummary?.releaseDate || '').trim() || null;
const year = Number(movie?.year || Number(String(releaseDate || '').slice(0, 4)) || 0) || null;
const imdbId = String(movie?.imdbId || detailsSummary?.imdbId || '').trim() || null;
const providerId = tmdbId !== null ? `tmdb:${tmdbId}` : null;
return {
provider: 'tmdb',
providerId,
metadataKind: 'movie',
workflowKind: 'film',
tmdbId,
imdbId,
title: String(movie?.title || movie?.originalTitle || '').trim() || null,
originalTitle: String(movie?.originalTitle || '').trim() || null,
year,
overview: String(movie?.overview || '').trim() || null,
poster: movie?.poster || this.buildImageUrl(movie?.posterPath, 'w342'),
backdrop: movie?.backdrop || this.buildImageUrl(movie?.backdropPath, 'w780'),
runtime: detailsSummary?.runtime || null,
genre: detailsSummary?.genre || null,
voteAverage: detailsSummary?.voteAverage ?? null
};
}
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
@@ -483,6 +830,42 @@ class TmdbService {
}));
return this.attachFailureCode(normalized, failureCode);
}
async searchMoviesWithDetails(query, options = {}) {
const candidates = await this.searchMovies(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) {
return this.attachFailureCode([], failureCode);
}
const limit = Math.max(1, Math.min(15, Number(options.limit || 8)));
const selectedCandidates = candidates.slice(0, limit);
const language = await this.resolveLanguage(options.language);
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
const details = await this.getMovieDetailsWithCredits(candidate.id, {
language,
appendToResponse: ['credits', 'external_ids'],
ensureCredits: true
});
const summary = this.buildMovieDetailsSummary(details);
const normalized = this.buildMovieMetadataCandidate({
...candidate,
releaseDate: details?.release_date || null,
imdbId: summary?.imdbId || null,
posterPath: details?.poster_path || candidate?.posterPath || null
}, {
detailsSummary: summary
});
return {
...normalized,
tmdbDetails: summary && typeof summary === 'object' ? summary : null
};
}));
const normalized = expanded.filter((item) => item?.tmdbId && item?.title);
return this.attachFailureCode(normalized, failureCode);
}
}
module.exports = new TmdbService();