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],
+19 -6
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,
@@ -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 = [];
+398 -15
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,8 +261,12 @@ 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;
for (const language of languageCandidates) {
let data = null;
try {
data = await this.request('/search/tv', {
@@ -192,13 +280,17 @@ class TmdbService {
});
} 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,
error: error?.message || String(error),
language,
error: lastFailureMessage,
failureCode,
statusCode: Number(error?.statusCode || 0) || null
statusCode: lastFailureStatusCode
});
return this.attachFailureCode([], failureCode);
continue;
}
const rows = Array.isArray(data?.results) ? data.results : [];
@@ -217,19 +309,101 @@ class TmdbService {
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 = {}) {
const id = Number(seriesId);
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
@@ -237,10 +411,95 @@ class TmdbService {
}).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
}
}).catch((error) => {
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) {
@@ -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}`, {
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;
});
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)}`, {
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;
}
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();
+1 -11
View File
@@ -32,6 +32,7 @@ CREATE TABLE jobs (
poster_url TEXT,
omdb_json TEXT,
selected_from_omdb INTEGER DEFAULT 0,
migrate_tmdb INTEGER NOT NULL DEFAULT 0,
start_time TEXT,
end_time TEXT,
status TEXT NOT NULL,
@@ -429,10 +430,6 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
@@ -635,18 +632,11 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group');
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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true');
+3 -3
View File
@@ -142,9 +142,9 @@ Typische Auswirkungen:
| GUI-Feld | Key | Typ | Default | Wirkung im System |
|---|---|---|---|---|
| `OMDb API Key` | `omdb_api_key` | `string` | `leer` | API Key für Metadatensuche. |
| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). |
| `DVD Serien-Sprache` | `dvd_series_language` | `string` | `de-DE` | Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US. |
| `DVD Serien-Fallback-Sprachen` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR. |
| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Read Access Token für Film- und Serien-Metadaten über TheMovieDB (TMDb). |
| `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. |
| `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. |
| `DVD Serien-Ordnung` | `dvd_series_order_preference` | `select` | `episode_group` | Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden. |
| `DVD Disc-Profile wiederverwenden` | `dvd_series_reuse_disc_profiles` | `boolean` | `true` | Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen. |
| `OMDb Typ` | `omdb_default_type` | `select` | `movie` | Vorauswahl für Suche. |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.16.1-6",
"version": "0.16.1-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.16.1-6",
"version": "0.16.1-7",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.16.1-6",
"version": "0.16.1-7",
"private": true,
"type": "module",
"scripts": {
+114 -149
View File
@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client';
@@ -14,6 +12,7 @@ import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage';
import AudiobooksPage from './pages/AudiobooksPage';
import TmdbMigrationPage from './pages/TmdbMigrationPage';
function normalizeJobId(value) {
const parsed = Number(value);
@@ -23,6 +22,20 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
@@ -51,25 +64,6 @@ function isTerminalStage(value) {
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
}
function formatBytes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 'n/a';
}
if (parsed === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
let current = parsed;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
const digits = unitIndex <= 1 ? 0 : 2;
return `${current.toFixed(digits)} ${units[unitIndex]}`;
}
function createInitialAudiobookUploadState() {
return {
phase: 'idle',
@@ -80,28 +74,14 @@ function createInitialAudiobookUploadState() {
statusText: null,
errorMessage: null,
jobId: null,
uploadSessionId: null,
fileLastModified: null,
fileFingerprint: null,
startedAt: null,
finishedAt: null
};
}
function getAudiobookUploadTagMeta(phase) {
const normalized = String(phase || '').trim().toLowerCase();
if (normalized === 'uploading') {
return { label: 'Upload läuft', severity: 'warning' };
}
if (normalized === 'processing') {
return { label: 'Server verarbeitet', severity: 'info' };
}
if (normalized === 'completed') {
return { label: 'Bereit', severity: 'success' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Inaktiv', severity: 'secondary' };
}
function getDownloadIndicatorMeta(summary) {
const activeCount = Number(summary?.activeCount || 0);
const failedCount = Number(summary?.failedCount || 0);
@@ -144,12 +124,13 @@ function App() {
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
const [downloadSummary, setDownloadSummary] = useState(null);
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
const location = useLocation();
const navigate = useNavigate();
const globalToastRef = useRef(null);
const prevCdDrivesRef = useRef({});
const prevJobProgressStatesRef = useRef({});
const hardwareWarmupRef = useRef({ timer: null, attempts: 0 });
const audiobookUploadAbortRef = useRef(null);
const clearHardwareWarmupRetry = () => {
if (hardwareWarmupRef.current?.timer) {
@@ -198,6 +179,39 @@ function App() {
prevCdDrivesRef.current = current;
}, [pipeline?.cdDrives]);
// Trigger a jobs refresh when a tracked job transitions into a terminal
// state through PIPELINE_PROGRESS, so Ripper/History update immediately.
useEffect(() => {
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
? pipeline.jobProgress
: {};
const previousStates = prevJobProgressStatesRef.current || {};
const nextStates = {};
let shouldRefresh = false;
for (const [jobId, entry] of Object.entries(current)) {
const currentState = normalizeStage(entry?.state);
const previousState = normalizeStage(previousStates[jobId]);
if (currentState) {
nextStates[jobId] = currentState;
}
if (
currentState
&& isTerminalStage(currentState)
&& currentState !== previousState
&& !isTerminalStage(previousState)
) {
shouldRefresh = true;
}
}
prevJobProgressStatesRef.current = nextStates;
if (shouldRefresh) {
setRipperJobsRefreshToken((token) => token + 1);
setHistoryJobsRefreshToken((token) => token + 1);
}
}, [pipeline?.jobProgress]);
const refreshPipeline = async () => {
const response = await api.getPipelineState();
setPipeline(response.pipeline);
@@ -221,83 +235,84 @@ function App() {
return response;
};
const clearAudiobookUpload = () => {
setAudiobookUpload(createInitialAudiobookUploadState());
};
const handleAudiobookUpload = async (file, payload = {}) => {
if (!file) {
throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
}
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0
? Number(file.size)
: 0;
const fileName = String(file.name || '').trim() || 'upload.aax';
const totalBytes = Number(file.size || 0);
const abortController = new AbortController();
audiobookUploadAbortRef.current = abortController;
setAudiobookUpload({
phase: 'uploading',
fileName: String(file.name || '').trim() || 'upload.aax',
fileName,
loadedBytes: 0,
totalBytes: fallbackTotalBytes,
totalBytes,
progressPercent: 0,
statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null,
jobId: null,
uploadSessionId: null,
fileLastModified: Number.isFinite(Number(file.lastModified))
? Math.trunc(Number(file.lastModified))
: null,
fileFingerprint: null,
startedAt: new Date().toISOString(),
finishedAt: null
});
try {
const response = await api.uploadAudiobook(file, payload, {
signal: abortController.signal,
onProgress: ({ loaded, total, percent }) => {
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0
? Number(loaded)
: 0;
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
? Number(total)
: fallbackTotalBytes;
const nextPercent = Number.isFinite(Number(percent))
const loadedBytes = Number.isFinite(Number(loaded)) ? Number(loaded) : 0;
const totalSize = Number.isFinite(Number(total)) && Number(total) > 0 ? Number(total) : totalBytes;
const progressPercent = Number.isFinite(Number(percent))
? clampPercent(Number(percent))
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0);
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
: (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0);
setAudiobookUpload((prev) => ({
...prev,
phase: transferComplete ? 'processing' : 'uploading',
loadedBytes: nextLoaded,
totalBytes: nextTotal,
progressPercent: nextPercent,
statusText: transferComplete
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...'
: 'AAX-Datei wird hochgeladen ...'
phase: 'uploading',
loadedBytes,
totalBytes: totalSize,
progressPercent,
statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null
}));
}
});
const uploadedJobId = normalizeJobId(response?.result?.jobId);
const uploadedJobId = extractUploadJobIdFromResponse(response);
const started = Boolean(response?.result?.started);
const queued = Boolean(response?.result?.queued);
await refreshPipeline().catch(() => null);
setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) {
setPendingRipperJobId(uploadedJobId);
}
setAudiobookUpload((prev) => ({
...prev,
phase: 'completed',
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes,
totalBytes: prev.totalBytes || fallbackTotalBytes,
loadedBytes: prev.totalBytes || totalBytes,
totalBytes: prev.totalBytes || totalBytes,
progressPercent: 100,
statusText: uploadedJobId
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.`
? (
queued
? `Upload abgeschlossen. Job #${uploadedJobId} wurde in die Queue eingereiht.`
: (started
? `Upload abgeschlossen. Job #${uploadedJobId} wurde gestartet.`
: `Upload abgeschlossen. Job #${uploadedJobId} ist bereit.`)
)
: 'Upload abgeschlossen.',
errorMessage: null,
jobId: uploadedJobId,
finishedAt: new Date().toISOString()
}));
return response;
} catch (error) {
if (error?.name === 'AbortError') {
setAudiobookUpload(createInitialAudiobookUploadState());
throw error;
}
setAudiobookUpload((prev) => ({
...prev,
phase: 'error',
@@ -306,17 +321,20 @@ function App() {
finishedAt: new Date().toISOString()
}));
throw error;
} finally {
if (audiobookUploadAbortRef.current === abortController) {
audiobookUploadAbortRef.current = null;
}
}
};
const handleRipperJobFocusConsumed = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
const handleCancelAudiobookUpload = () => {
const activeAbortController = audiobookUploadAbortRef.current;
if (activeAbortController) {
activeAbortController.abort();
return;
}
setPendingRipperJobId((prev) => (
normalizeJobId(prev) === normalizedJobId ? null : prev
));
setAudiobookUpload(createInitialAudiobookUploadState());
};
useEffect(() => {
@@ -401,6 +419,11 @@ function App() {
)
)
);
const allowRunningTransitionFromTerminal = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& prevActiveJobId === normalizedProgressJobId
);
// Ignore late/stale progress packets that arrive after a terminal state
// or regress a job from an interactive/ready state back into an
@@ -408,6 +431,7 @@ function App() {
if (
normalizedProgressJobId
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal
&& (
prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal
@@ -421,7 +445,9 @@ function App() {
if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const previousJobStage = normalizeStage(previousJobProgress?.state);
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal;
const keepPreviousJobStage = isTerminalStage(previousJobStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
const mergedJobContext = contextPatch
? {
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
@@ -445,7 +471,9 @@ function App() {
};
}
if (progressJobId === prev?.activeJobId || progressJobId == null) {
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal;
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state);
next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress);
next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta);
@@ -613,18 +641,6 @@ function App() {
{ label: 'Downloads', path: '/downloads' },
...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
];
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const showAudiobookUploadBanner = uploadPhase !== 'idle';
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
const uploadBytesLabel = uploadTotalBytes > 0
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
const isAudiobooksRoute = location.pathname === '/audiobooks';
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => {
if (path === '/ripper') {
@@ -664,57 +680,6 @@ function App() {
</div>
</header>
{showAudiobookUploadBanner ? (
<section className={`app-upload-banner phase-${uploadPhase}`}>
<div className="app-upload-banner-copy">
<div className="app-upload-banner-head">
<strong>Audiobook Upload</strong>
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
</div>
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
</div>
<div
className="app-upload-banner-progress"
aria-label={`Audiobook Upload ${Math.trunc(uploadProgress)} Prozent`}
>
<ProgressBar value={uploadProgress} showValue={false} />
<small>
{uploadPhase === 'processing'
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
: uploadBytesLabel
? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.trunc(uploadProgress)}%`}
</small>
</div>
<div className="app-upload-banner-actions">
{hasUploadedJob && !isAudiobooksRoute ? (
<Button
label="Zu Audiobooks"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => {
navigate('/audiobooks');
}}
/>
) : null}
{canDismissUploadBanner ? (
<Button
icon="pi pi-times"
rounded
text
severity="secondary"
aria-label="Upload-Hinweis schliessen"
onClick={clearAudiobookUpload}
/>
) : null}
</div>
</section>
) : null}
<main className="app-main">
<Routes>
<Route
@@ -726,8 +691,6 @@ function App() {
lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline}
jobsRefreshToken={ripperJobsRefreshToken}
pendingExpandedJobId={pendingRipperJobId}
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
downloadSummary={downloadSummary}
>
<Outlet />
@@ -738,6 +701,7 @@ function App() {
<Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="tmdb-migration" element={<TmdbMigrationPage />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} />
<Route path="converter" element={<ConverterPage />} />
@@ -747,6 +711,7 @@ function App() {
<AudiobooksPage
audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload}
onCancelAudiobookUpload={handleCancelAudiobookUpload}
/>
}
/>
+44 -9
View File
@@ -267,6 +267,12 @@ async function requestWithXhr(path, options = {}) {
xhr.onerror = () => {
settle(() => {
if (Number(xhr.status || 0) === 0) {
const abortLikeError = new Error('Request unterbrochen.');
abortLikeError.name = 'AbortError';
reject(abortLikeError);
return;
}
reject(new Error('Netzwerkfehler'));
});
};
@@ -564,8 +570,8 @@ export const api = {
});
return result;
},
searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
searchTmdbMovie(q) {
return request(`/pipeline/tmdb/movie/search?q=${encodeURIComponent(q)}`);
},
searchTmdbSeries(q, seasonNumber = null) {
const params = new URLSearchParams();
@@ -575,8 +581,14 @@ export const api = {
}
return request(`/pipeline/tmdb/series/search?${params.toString()}`);
},
searchMusicBrainz(q) {
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
searchMusicBrainz(q, options = {}) {
const params = new URLSearchParams();
params.set('q', String(q || ''));
const expectedTrackCount = Number(options?.trackCount);
if (Number.isFinite(expectedTrackCount) && expectedTrackCount > 0) {
params.set('trackCount', String(Math.trunc(expectedTrackCount)));
}
return request(`/pipeline/cd/musicbrainz/search?${params.toString()}`);
},
getMusicBrainzRelease(mbId) {
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
@@ -628,9 +640,6 @@ export const api = {
getAudiobookJobs() {
return request('/pipeline/audiobook/jobs');
},
getAudiobookOutputTree() {
return request('/pipeline/audiobook/output-tree');
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',
@@ -816,6 +825,24 @@ export const api = {
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history${suffix}`);
},
getTmdbMigrationPendingJobs(params = {}) {
const query = new URLSearchParams();
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
query.set('limit', String(Math.trunc(Number(params.limit))));
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history/tmdb-migration/pending${suffix}`);
},
async setJobTmdbMigrationFlag(jobId, migrated = false) {
const result = await request(`/history/${jobId}/tmdb-migration`, {
method: 'POST',
body: JSON.stringify({
migrated: Boolean(migrated)
})
});
afterMutationInvalidate(['/history']);
return result;
},
getOrphanRawFolders() {
return request('/history/orphan-raw');
},
@@ -827,8 +854,16 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async assignJobOmdb(jobId, payload = {}) {
const result = await request(`/history/${jobId}/omdb/assign`, {
async deleteOrphanRawFolder(rawPath) {
const result = await request('/history/orphan-raw/delete', {
method: 'POST',
body: JSON.stringify({ rawPath })
});
afterMutationInvalidate(['/history']);
return result;
},
async assignJobMetadata(jobId, payload = {}) {
const result = await request(`/history/${jobId}/metadata/assign`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
+521 -50
View File
@@ -3,10 +3,10 @@ import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { api } from '../api/client';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
function normalizeJobId(value) {
@@ -84,6 +84,108 @@ function normalizeEditableChapters(chapters = []) {
});
}
function formatChapterDuration(startSecondsValue, endSecondsValue) {
const startSeconds = Number(startSecondsValue || 0);
const endSeconds = Number(endSecondsValue || 0);
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
return '-';
}
return formatChapterTime(endSeconds - startSeconds);
}
function normalizeTrackStageStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') {
return 'done';
}
if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') {
return 'in_progress';
}
if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') {
return 'error';
}
return 'pending';
}
function trackStatusTagMeta(value) {
const normalized = normalizeTrackStageStatus(value);
if (normalized === 'done') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'in_progress') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Offen', severity: 'secondary' };
}
function normalizeScriptId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeIdList(values, kind = 'script') {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function buildEncodeItemsFromConfig(config, phase) {
const source = config && typeof config === 'object' ? config : {};
const prefix = phase === 'post' ? 'post' : 'pre';
const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : [];
const fromExplicit = explicitItems
.map((item) => {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'script' && type !== 'chain') {
return null;
}
const id = type === 'chain'
? normalizeChainId(item?.id ?? item?.chainId)
: normalizeScriptId(item?.id ?? item?.scriptId);
if (!id) {
return null;
}
return { type, id };
})
.filter(Boolean);
if (fromExplicit.length > 0) {
return fromExplicit;
}
const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script');
const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain');
return [
...scriptIds.map((id) => ({ type: 'script', id })),
...chainIds.map((id) => ({ type: 'chain', id }))
];
}
function FormatField({ field, value, onChange, disabled }) {
if (field.type === 'slider') {
return (
@@ -128,7 +230,6 @@ export default function AudiobookConfigPanel({
pipeline,
onStart,
onCancel,
onRetry,
onDeleteJob,
busy
}) {
@@ -149,6 +250,18 @@ export default function AudiobookConfigPanel({
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
const [preRipItems, setPreRipItems] = useState([]);
const [postRipItems, setPostRipItems] = useState([]);
const audiobookConfigKey = JSON.stringify({
preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'),
postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'),
preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'),
postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'),
preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [],
postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : []
});
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
@@ -160,13 +273,51 @@ export default function AudiobookConfigPanel({
setEditableChapters(normalizeEditableChapters(chapters));
}, [jobId, JSON.stringify(chapters || [])]);
useEffect(() => {
let cancelled = false;
const loadCatalog = async () => {
try {
const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
if (cancelled) {
return;
}
const scripts = scriptsResponse.status === 'fulfilled'
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
: [];
const chains = chainsResponse.status === 'fulfilled'
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
: [];
setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name })));
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
} catch (_error) {
if (!cancelled) {
setScriptCatalog([]);
setChainCatalog([]);
}
}
};
void loadCatalog();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre'));
setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post'));
}, [jobId, audiobookConfigKey]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
const canStart = Boolean(jobId) && (
state === 'READY_TO_START'
|| state === 'READY_TO_ENCODE'
|| state === 'ERROR'
|| state === 'CANCELLED'
);
const isRunning = state === 'ENCODING';
const isFinished = state === 'FINISHED';
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const isSplitOutput = format === 'mp3' || format === 'flac';
const showEditableChapters = !(isSplitOutput && isRunning);
const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null;
const completedChapterCount = Number(context?.completedChapterCount ?? -1);
const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0);
@@ -182,6 +333,88 @@ export default function AudiobookConfigPanel({
[schema, formatOptions]
);
const moveEncodeItem = (phase, index, direction) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const list = Array.isArray(prev) ? [...prev] : [];
const from = Number(index);
const to = from + (direction === 'up' ? -1 : 1);
if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) {
return list;
}
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
return list;
});
};
const addEncodeItem = (phase, type) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const selectedIds = new Set(
current
.filter((item) => item?.type === normalizedType)
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog;
const candidate = (Array.isArray(catalog) ? catalog : [])
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedIds.has(String(id)));
if (candidate === undefined || candidate === null) {
return current;
}
return [...current, { type: normalizedType, id: candidate }];
});
};
const changeEncodeItem = (phase, index, type, nextId) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId);
if (normalizedId === null) {
return;
}
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
const duplicate = current.some((item, itemIndex) => {
if (itemIndex === rowIndex) {
return false;
}
if (item?.type !== normalizedType) {
return false;
}
const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id);
return existingId !== null && String(existingId) === String(normalizedId);
});
if (duplicate) {
return current;
}
const next = [...current];
next[rowIndex] = { type: normalizedType, id: normalizedId };
return next;
});
};
const removeEncodeItem = (phase, index) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
return current.filter((_, itemIndex) => itemIndex !== rowIndex);
});
};
return (
<div className="audiobook-config-panel">
<div className="audiobook-config-head">
@@ -217,11 +450,8 @@ export default function AudiobookConfigPanel({
</div>
</div>
<div className="audiobook-config-tags">
<div className="ripper-job-badges">
<Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
</div>
</div>
@@ -263,6 +493,7 @@ export default function AudiobookConfigPanel({
</small>
</div>
{showEditableChapters ? (
<div className="audiobook-config-chapters">
<h4>Kapitel</h4>
{editableChapters.length === 0 ? (
@@ -294,47 +525,284 @@ export default function AudiobookConfigPanel({
</div>
)}
</div>
</div>
{isRunning ? (
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.trunc(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.trunc(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div>
) : null}
</div>
{isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
<div className="audiobook-chapter-status">
<strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong>
<div className="audiobook-chapter-status-list">
<div className="cd-track-selection">
<div className="cd-track-list">
<table className="cd-track-table">
<thead>
<tr>
<th className="check">Auswahl</th>
<th className="num">Nr</th>
<th className="title">Titel</th>
<th className="duration">Länge</th>
<th className="status">Rip</th>
<th className="status">Encode</th>
</tr>
</thead>
<tbody>
{editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle';
const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending';
const ripMeta = trackStatusTagMeta('done');
const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending'));
return (
<div key={chIdx} className={`audiobook-chapter-status-row ${statusClass}`}>
<i className={statusIcon} />
<span className="chapter-status-nr">#{String(chIdx).padStart(2, '0')}</span>
<span className="chapter-status-title">{chapter.title}</span>
<tr key={chIdx} className="selected">
<td className="check">Ja</td>
<td className="num">{String(chIdx).padStart(2, '0')}</td>
<td className="title">{chapter.title || '-'}</td>
<td className="duration">{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}</td>
<td className="status"><Tag value={ripMeta.label} severity={ripMeta.severity} /></td>
<td className="status"><Tag value={encodeMeta.label} severity={encodeMeta.severity} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
) : null}
<div className="encode-automation-grid">
<div className="post-script-box">
<h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preRipItems.length === 0 ? (
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
) : null}
{preRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'down')}
disabled={busy || rowIndex >= preRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('pre', rowIndex)}
disabled={busy}
/>
</div>
);
})}
</div>
</div>
) : null}
{outputPath ? (
<div className="audiobook-output-path">
<strong>Ausgabe:</strong> <code>{outputPath}</code>
</div>
) : null}
<div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div>
<div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null}
{postRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'down')}
disabled={busy || rowIndex >= postRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('post', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
</div>
<div className="actions-row" style={{ marginTop: '1rem' }}>
{canStart ? (
<Button
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
label={(state === 'READY_TO_START' || state === 'READY_TO_ENCODE')
? 'Encode starten'
: 'Mit diesen Einstellungen starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart?.({
@@ -347,7 +815,23 @@ export default function AudiobookConfigPanel({
endSeconds: chapter.endSeconds,
startMs: chapter.startMs,
endMs: chapter.endMs
}))
})),
selectedPreEncodeScriptIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPostEncodeScriptIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPreEncodeChainIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
),
selectedPostEncodeChainIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
)
})}
loading={busy}
disabled={!jobId}
@@ -365,28 +849,15 @@ export default function AudiobookConfigPanel({
/>
) : null}
{isRunning ? (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
severity="secondary"
outlined
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
{(state === 'ERROR' || state === 'CANCELLED') ? (
<Button
label="Retry-Job anlegen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
</div>
<Dialog
@@ -1,298 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
function formatBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return '';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let index = 0;
let current = n;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getNodeByPath(root, targetPath) {
if (!root) {
return null;
}
if ((root.path || '') === (targetPath || '')) {
return root;
}
for (const child of (root.children || [])) {
if (child.type !== 'folder') {
continue;
}
const found = getNodeByPath(child, targetPath);
if (found) {
return found;
}
}
return null;
}
function listChildren(node) {
if (!node || !Array.isArray(node.children)) {
return [];
}
return node.children;
}
function buildBreadcrumb(pathValue) {
if (!pathValue) {
return [];
}
const parts = String(pathValue).split('/').filter(Boolean);
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/')
}));
}
function filterFolderTree(node, query) {
if (!node || node.type !== 'folder') {
return null;
}
if (!query || !query.trim()) {
return node;
}
const normalized = query.toLowerCase();
const children = (node.children || [])
.filter((child) => child.type === 'folder')
.map((child) => filterFolderTree(child, query))
.filter(Boolean);
const nameMatches = String(node.name || '').toLowerCase().includes(normalized);
if (nameMatches || children.length > 0) {
return { ...node, children };
}
return null;
}
function defaultExpandedSet(tree) {
const next = new Set(['']);
const firstLevel = Array.isArray(tree?.children) ? tree.children : [];
for (const entry of firstLevel) {
if (entry?.type === 'folder' && entry?.path) {
next.add(entry.path);
}
}
return next;
}
export default function AudiobookOutputExplorer({ refreshToken = 0 }) {
const [tree, setTree] = useState(null);
const [outputDir, setOutputDir] = useState(null);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
const [sidebarQuery, setSidebarQuery] = useState('');
const loadTree = useCallback(async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await api.getAudiobookOutputTree();
const nextTree = response?.tree || null;
setTree(nextTree);
setOutputDir(response?.outputDir || null);
setExpandedFolders(defaultExpandedSet(nextTree));
setCurrentPath('');
} catch (error) {
setTree(null);
setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadTree();
}, [loadTree, refreshToken]);
const navigateTo = (pathValue) => {
const normalized = String(pathValue || '').trim();
const node = getNodeByPath(tree, normalized);
if (node && node.type === 'folder') {
setCurrentPath(normalized);
}
};
const toggleFolder = (pathValue) => {
const normalized = String(pathValue || '').trim();
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(normalized)) {
next.delete(normalized);
} else {
next.add(normalized);
}
return next;
});
};
const renderTreeNode = (node, depth = 0) => {
if (!node || node.type !== 'folder') {
return null;
}
const key = node.path || '';
const isExpanded = expandedFolders.has(key);
const isCurrent = currentPath === key;
const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : [];
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<button
type="button"
className={`tree-row folder${isCurrent ? ' active' : ''}`}
onClick={() => navigateTo(key)}
>
{children.length > 0 ? (
<span
className="tree-caret"
onClick={(event) => {
event.stopPropagation();
toggleFolder(key);
}}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} />
</span>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'audiobooks'}</span>
</button>
{isExpanded && children.map((child) => renderTreeNode(child, depth + 1))}
</div>
);
};
const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]);
const currentNode = getNodeByPath(tree, currentPath);
const currentChildren = listChildren(currentNode);
const breadcrumb = buildBreadcrumb(currentPath);
if (loading && !tree) {
return (
<div className="explorer-loading">
<ProgressSpinner style={{ width: '2.2rem', height: '2.2rem' }} strokeWidth="5" />
</div>
);
}
if (!tree) {
return (
<div className="explorer-empty">
{errorMessage ? (
<small className="error-text">{errorMessage}</small>
) : (
<small>Kein Audiobook-Output vorhanden. Ausgabepfad: <code>{outputDir || 'nicht konfiguriert'}</code></small>
)}
<div style={{ marginTop: '0.6rem' }}>
<Button icon="pi pi-refresh" label="Neu laden" outlined size="small" onClick={() => void loadTree()} />
</div>
</div>
);
}
return (
<div className="explorer audiobook-output-explorer">
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<input
type="text"
placeholder="Ordner filtern..."
value={sidebarQuery}
onChange={(event) => setSidebarQuery(event.target.value)}
className="sidebar-search"
/>
</div>
<div className="sidebar-tree">
{filteredTree ? renderTreeNode(filteredTree, 0) : <small>Keine Ordner gefunden.</small>}
</div>
</div>
<div className="explorer-main">
<div className="explorer-toolbar">
<Button
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
label="Aktualisieren"
outlined
size="small"
onClick={() => void loadTree()}
disabled={loading}
/>
<div className="explorer-path">
<Button text label="/" onClick={() => navigateTo('')} />
{breadcrumb.map((crumb) => (
<Button key={crumb.path} text label={crumb.name} onClick={() => navigateTo(crumb.path)} />
))}
</div>
</div>
<div className="explorer-list">
<div className="explorer-row header audiobook-output-row">
<span>Name</span>
<span>Größe</span>
<span>Geändert</span>
</div>
{currentChildren.length === 0 ? (
<div className="explorer-row audiobook-output-row">
<span>Keine Einträge in diesem Ordner.</span>
<span />
<span />
</div>
) : (
currentChildren.map((entry) => (
<button
type="button"
key={entry.path || entry.name}
className="explorer-row audiobook-output-row"
onClick={() => {
if (entry.type === 'folder') {
navigateTo(entry.path);
}
}}
style={{ cursor: entry.type === 'folder' ? 'pointer' : 'default' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<i className={`pi ${entry.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
{entry.name}
</span>
<span>{entry.type === 'file' ? (formatBytes(entry.size) || '-') : '-'}</span>
<span>{formatDateTime(entry.mtime)}</span>
</button>
))
)}
</div>
<div className="explorer-footer">
<small>
Root: <code>{outputDir || '-'}</code>
</small>
</div>
</div>
</div>
);
}
+170 -81
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { FileUpload } from 'primereact/fileupload';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Toast } from 'primereact/toast';
@@ -32,15 +31,30 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
export default function AudiobookUploadPanel({
audiobookUpload,
onAudiobookUpload,
onCancelUpload = null,
onUploaded = null
}) {
const toastRef = useRef(null);
const fileUploadRef = useRef(null);
const fallbackFileInputRef = useRef(null);
const [uploadFile, setUploadFile] = useState(null);
const [statusVisible, setStatusVisible] = useState(false);
const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const uploadBusy = phase === 'uploading' || phase === 'processing';
@@ -49,40 +63,15 @@ export default function AudiobookUploadPanel({
: 0;
const loadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const totalBytes = Number(audiobookUpload?.totalBytes || 0);
const fileName = String(audiobookUpload?.fileName || '').trim()
|| String(uploadFile?.name || '').trim()
|| null;
const statusTone = phase === 'error'
? 'danger'
: phase === 'completed'
? 'success'
: phase === 'processing'
? 'info'
: phase === 'uploading'
? 'warning'
: 'secondary';
const statusLabel = phase === 'uploading'
? 'Upload laeuft'
: phase === 'processing'
? 'Server verarbeitet'
: phase === 'completed'
? 'Bereit'
: phase === 'error'
? 'Fehler'
: 'Inaktiv';
useEffect(() => {
if (phase === 'idle') {
setStatusVisible(false);
return;
}
setStatusVisible(true);
if (phase === 'completed') {
const timer = setTimeout(() => setStatusVisible(false), 5000);
return () => clearTimeout(timer);
}
return undefined;
}, [phase]);
const progressLabel = phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`;
const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile);
const canStartUpload = Boolean(uploadFile) && !uploadBusy;
const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function';
const canClearSelection = Boolean(uploadFile) && !uploadBusy;
const handleUpload = async () => {
if (!uploadFile) {
@@ -96,7 +85,7 @@ export default function AudiobookUploadPanel({
}
try {
const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false });
const uploadedJobId = normalizeJobId(response?.result?.jobId);
const uploadedJobId = extractUploadJobIdFromResponse(response);
if (uploadedJobId) {
toastRef.current?.show({
severity: 'success',
@@ -116,6 +105,15 @@ export default function AudiobookUploadPanel({
fileUploadRef.current?.clear?.();
onUploaded?.(uploadedJobId, response);
} catch (error) {
if (error?.name === 'AbortError') {
toastRef.current?.show({
severity: 'info',
summary: 'Upload abgebrochen',
detail: 'Der Audiobook-Upload wurde gestoppt.',
life: 2800
});
return;
}
toastRef.current?.show({
severity: 'error',
summary: 'Upload fehlgeschlagen',
@@ -125,6 +123,72 @@ export default function AudiobookUploadPanel({
}
};
const handleFileSelected = (file) => {
if (!file) {
setUploadFile(null);
return;
}
setUploadFile(file);
};
const handleFileCleared = () => {
setUploadFile(null);
};
const handleChooseFile = () => {
if (uploadBusy) {
return;
}
fallbackFileInputRef.current?.click();
};
const handleFileItemAction = (onRemove = null) => {
if (canCancelUpload) {
void onCancelUpload();
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
return;
}
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
};
const renderFileRow = (file, onRemove = null) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file?.name}>{file?.name || 'upload.aax'}</span>
<small>{formatBytes(Number(file?.size || 0))}</small>
{hasHeaderStatus ? (
<div className="aax-file-status">
<>
<small className="audiobook-upload-inline-text">{progressLabel}</small>
<ProgressBar value={progress} showValue={false} />
{audiobookUpload?.statusText ? (
<small className="audiobook-upload-inline-text">{audiobookUpload.statusText}</small>
) : null}
</>
</div>
) : null}
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={() => {
handleFileItemAction(onRemove);
}}
disabled={!canCancelUpload && !canClearSelection}
tooltip={canCancelUpload ? 'Upload abbrechen' : 'Auswahl entfernen'}
tooltipOptions={{ position: 'left' }}
/>
</div>
);
return (
<div className="audiobook-upload-panel">
<Toast ref={toastRef} position="top-right" />
@@ -135,66 +199,91 @@ export default function AudiobookUploadPanel({
customUpload
uploadHandler={() => void handleUpload()}
disabled={uploadBusy}
onSelect={(event) => setUploadFile(event.files[0] || null)}
onClear={() => setUploadFile(null)}
onRemove={() => setUploadFile(null)}
onSelect={(event) => {
handleFileSelected(event.files[0] || null);
}}
onClear={() => {
handleFileCleared();
}}
onRemove={() => {
handleFileCleared();
}}
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
itemTemplate={(file, options) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file.name}>{file.name}</span>
<small>{options.formatSize}</small>
</div>
headerTemplate={(options) => (
<div className={options.className}>
<div className="audiobook-upload-header">
<div className="audiobook-upload-header-actions">
<Button
icon="pi pi-times"
text
icon="pi pi-images"
rounded
severity="danger"
size="small"
onClick={options.onRemove}
outlined
aria-label="Datei auswählen"
onClick={() => {
void handleChooseFile();
}}
disabled={uploadBusy}
/>
<Button
icon="pi pi-cloud-upload"
rounded
outlined
severity="success"
aria-label="Upload starten"
onClick={() => {
void handleUpload();
}}
disabled={!canStartUpload}
/>
<Button
icon="pi pi-times"
rounded
outlined
severity="secondary"
aria-label="Auswahl entfernen"
onClick={() => {
fileUploadRef.current?.clear?.();
handleFileCleared();
}}
disabled={!canClearSelection}
/>
</div>
</div>
</div>
)}
itemTemplate={(file, options) => (
renderFileRow(file, () => {
options.onRemove?.();
})
)}
emptyTemplate={() => (
uploadFile
? renderFileRow(uploadFile, () => {
// handled by handleFileItemAction
})
: (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben "Auswaehlen" klicken</small>
</div>
)
)}
/>
{statusVisible ? (
<div className={`audiobook-upload-status tone-${statusTone}`}>
<div className="audiobook-upload-status-head">
<strong>{statusLabel}</strong>
<Tag value={statusLabel} severity={statusTone} />
</div>
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
{fileName ? (
<small className="audiobook-upload-file" title={fileName}>
Datei: {fileName}
</small>
) : null}
<div
className="ripper-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.trunc(progress)} Prozent`}
>
<ProgressBar value={progress} showValue={false} />
<small>
{phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`}
</small>
</div>
</div>
) : null}
<input
ref={fallbackFileInputRef}
type="file"
accept=".aax,audio/vnd.audible.aax"
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target?.files?.[0] || null;
handleFileSelected(file);
if (event.target) {
event.target.value = '';
}
}}
/>
</div>
);
}
+5 -1
View File
@@ -142,7 +142,10 @@ export default function CdMetadataDialog({
const searchRunId = searchRunRef.current + 1;
searchRunRef.current = searchRunId;
try {
const searchResults = await onSearch(trimmedQuery);
const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0;
const searchResults = await onSearch(trimmedQuery, {
trackCount: expectedTrackCount > 0 ? expectedTrackCount : null
});
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
if (searchRunRef.current !== searchRunId) {
@@ -264,6 +267,7 @@ export default function CdMetadataDialog({
emptyMessage="Keine Treffer"
>
<Column header="Album" body={mbTitleBody} />
<Column field="trackCount" header="Tracks" style={{ width: '6rem' }} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="country" header="Land" style={{ width: '6rem' }} />
</DataTable>
+69 -6
View File
@@ -239,6 +239,7 @@ export default function CdRipConfigPanel({
const selectedMeta = context.selectedMetadata || {};
const state = String(pipeline?.state || '').trim().toUpperCase();
const jobId = normalizePosition(context?.jobId);
const isPostRipWorkflow = context?.skipRip === true;
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
const isFinished = state === 'FINISHED';
@@ -662,6 +663,7 @@ export default function CdRipConfigPanel({
|| ''
) || null;
const devicePath = normalizeTrackText(context?.devicePath) || '-';
const rawPath = normalizeTrackText(context?.rawPath) || '-';
const outputPath = normalizeTrackText(context?.outputPath) || '-';
const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase();
const formatLabel = (Array.isArray(CD_FORMATS)
@@ -795,7 +797,9 @@ export default function CdRipConfigPanel({
displayValueTemplate={(value) => formatProgressLabel(value)}
/>
<small>
{`Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`}
{isPostRipWorkflow
? `Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`
: `Fortschritt: ${formatProgressLabel(wholeProgress)}`}
</small>
<small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small>
</div>
@@ -892,7 +896,9 @@ export default function CdRipConfigPanel({
<div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
<div><strong>Status:</strong> {stateLabel}</div>
<div><strong>Format:</strong> {formatLabel}</div>
{!isRipping ? <div><strong>Format:</strong> {formatLabel}</div> : null}
{isPostRipWorkflow ? (
<>
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
@@ -902,15 +908,18 @@ export default function CdRipConfigPanel({
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
<div><strong>Laufwerk:</strong> {devicePath}</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div>
</>
) : null}
{!isRipping ? <div><strong>Laufwerk:</strong> {devicePath}</div> : null}
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
{!isRipping ? <div><strong>Output-Pfad:</strong> {outputPath}</div> : null}
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
{!isRipping && jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
</div>
</div>
</div>
{displayTrackRows.length > 0 ? (
{isPostRipWorkflow && displayTrackRows.length > 0 ? (
<div className="cd-track-selection">
<strong>Zu rippende Tracks ({displayTrackRows.length})</strong>
<div className="cd-track-list">
@@ -951,6 +960,58 @@ export default function CdRipConfigPanel({
);
}
if (!isPostRipWorkflow) {
return (
<div className="cd-rip-config-panel">
<div className="status-row">
<Tag value={stateLabel} severity={stateSeverity} />
<span>{statusText || 'Bereit'}</span>
</div>
<div className="cd-meta-summary">
<strong>CD-Details</strong>
<div className="device-meta" style={{ marginTop: '0.55rem' }}>
<div><strong>Album:</strong> {albumTitle}</div>
<div><strong>Interpret:</strong> {albumArtist}</div>
<div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
<div><strong>Status:</strong> {stateLabel}</div>
<div><strong>Laufwerk:</strong> {devicePath}</div>
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div>
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
</div>
</div>
<div className="actions-row">
<Button
label="Rip starten"
icon="pi pi-play"
severity="success"
onClick={() => handleStart({ forceSkipRip: false })}
loading={busy}
/>
<Button
label="Metadaten ändern"
icon="pi pi-pencil"
severity="secondary"
onClick={() => onOpenMetadata && onOpenMetadata()}
loading={busy}
/>
{jobId ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob && onDeleteJob(jobId)}
loading={busy}
/>
) : null}
</div>
</div>
);
}
return (
<div className="cd-rip-config-panel">
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
@@ -1082,6 +1143,7 @@ export default function CdRipConfigPanel({
</div>
) : null}
<div className="encode-automation-grid">
<div className="post-script-box">
<h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
@@ -1307,6 +1369,7 @@ export default function CdRipConfigPanel({
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
</div>
{/* Actions */}
<div className="actions-row" style={{ marginTop: '1rem' }}>
+79 -26
View File
@@ -154,6 +154,59 @@ function deriveAudioTracks(plan) {
return [];
}
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') {
const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {};
const normalized = {
title: String(source?.title || fallbackTitle || '').trim() || null,
year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null,
imdbId: String(source?.imdbId || '').trim() || null,
poster: String(source?.poster || '').trim() || null
};
const metadataProvider = String(source?.metadataProvider || '').trim() || null;
const workflowKind = String(source?.workflowKind || '').trim() || null;
const providerId = String(source?.providerId || '').trim() || null;
const metadataKind = String(source?.metadataKind || '').trim() || null;
const seasonName = String(source?.seasonName || '').trim() || null;
const imdbRating = String(source?.imdbRating || '').trim() || null;
const tmdbId = normalizePositiveInt(source?.tmdbId);
const seasonNumber = normalizePositiveInt(source?.seasonNumber);
const discNumber = normalizePositiveInt(source?.discNumber);
const episodeCount = Number(source?.episodeCount || 0);
const voteAverageRaw = Number(source?.voteAverage || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const episodes = Array.isArray(source?.episodes) ? source.episodes : [];
const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails)
? source.tmdbDetails
: null;
if (metadataProvider) normalized.metadataProvider = metadataProvider;
if (workflowKind) normalized.workflowKind = workflowKind;
if (providerId) normalized.providerId = providerId;
if (metadataKind) normalized.metadataKind = metadataKind;
if (tmdbId) normalized.tmdbId = tmdbId;
if (seasonNumber) normalized.seasonNumber = seasonNumber;
if (seasonName) normalized.seasonName = seasonName;
if (discNumber) normalized.discNumber = discNumber;
if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount);
if (episodes.length > 0) normalized.episodes = episodes;
if (voteAverage !== null) normalized.voteAverage = voteAverage;
if (imdbRating) normalized.imdbRating = imdbRating;
if (tmdbDetails) normalized.tmdbDetails = tmdbDetails;
return normalized;
}
// Status helpers
function mediaTypeBadge(type) {
@@ -260,10 +313,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
// Video metadata
const [videoMetadata, setVideoMetadata] = useState(() => ({
title: String(planMetadata?.title || job.title || job.detected_title || '').trim(),
year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null,
imdbId: String(planMetadata?.imdbId || '').trim() || null,
poster: String(planMetadata?.poster || '').trim() || null
...buildVideoMetadataPayload(
planMetadata,
String(job.title || job.detected_title || '').trim()
)
}));
// Script / Chain catalog
@@ -378,10 +431,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
coverUrl: coverUrl || null
}
: {
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null,
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null,
imdbId: String(videoMetadata?.imdbId || '').trim() || null,
poster: String(videoMetadata?.poster || '').trim() || null
...buildVideoMetadataPayload(
videoMetadata,
String(job.title || job.detected_title || '').trim()
)
};
return {
@@ -436,12 +489,13 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
jobId: job.id,
detectedTitle: videoMetadata?.title || job.title || job.detected_title || '',
selectedMetadata: {
...videoMetadata,
title: videoMetadata?.title || job.title || job.detected_title || '',
year: videoMetadata?.year || null,
imdbId: videoMetadata?.imdbId || null,
poster: videoMetadata?.poster || null
},
omdbCandidates: []
metadataCandidates: []
}), [job.id, job.title, job.detected_title, videoMetadata]);
const cdMetadataDialogContext = useMemo(() => ({
@@ -454,31 +508,28 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
}), [job.id, audioTracks, trackFields]);
// Dialog handlers
const handleOmdbSearch = async (query) => {
const handleMetadataSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : [];
} catch { return []; }
};
const handleOmdbSubmit = async (payload) => {
const handleMetadataSubmit = async (payload) => {
setSearchDialogBusy(true);
try {
setVideoMetadata({
title: String(payload?.title || job.title || job.detected_title || '').trim(),
year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null,
imdbId: String(payload?.imdbId || '').trim() || null,
poster: String(payload?.poster || '').trim() || null
});
setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim()));
setMetadataDialogVisible(false);
} finally {
setSearchDialogBusy(false);
}
};
const handleMusicBrainzSearchDialog = async (query) => {
const handleMusicBrainzSearchDialog = async (query, options = {}) => {
try {
const response = await api.searchMusicBrainz(query);
const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return Array.isArray(response?.results) ? response.results : [];
} catch { return []; }
};
@@ -642,10 +693,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
coverUrl: coverUrl || null
}
: {
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null,
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null,
imdbId: String(videoMetadata?.imdbId || '').trim() || null,
poster: String(videoMetadata?.poster || '').trim() || null
...buildVideoMetadataPayload(
videoMetadata,
String(job.title || job.detected_title || '').trim()
)
};
await api.startConverterJob(job.id, {
@@ -880,8 +931,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
)}
{/* Pre / Post Ausführungen */}
<div className="encode-automation-grid">
{renderEncodeSection('pre', preItems)}
{renderEncodeSection('post', postItems)}
</div>
{/* Aktionen */}
<div className="actions-row" style={{ marginTop: '1rem' }}>
@@ -1025,8 +1078,8 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={() => setMetadataDialogVisible(false)}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={searchDialogBusy}
/>
</div>
+35 -6
View File
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
import { InputSwitch } from 'primereact/inputswitch';
import { Toast } from 'primereact/toast';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import { confirmModal } from '../utils/confirmModal';
// Hilfsfunktionen
@@ -61,7 +62,7 @@ const EMPTY_FORM = {
// Hauptkomponente
export default function CronJobsTab({ onWsMessage }) {
export default function CronJobsTab() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
@@ -89,6 +90,7 @@ export default function CronJobsTab({ onWsMessage }) {
// Aktionen Busy-State per Job-ID
const [busyId, setBusyId] = useState(null);
const wsReloadTimerRef = useRef(null);
// Daten laden
@@ -135,6 +137,16 @@ export default function CronJobsTab({ onWsMessage }) {
};
}, []);
const scheduleLoadAll = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void loadAll();
}, delayMs);
};
const activeCronRunByJobId = useMemo(() => {
const map = new Map();
for (const item of activeCronRuns) {
@@ -146,11 +158,28 @@ export default function CronJobsTab({ onWsMessage }) {
return map;
}, [activeCronRuns]);
// WebSocket: Cronjob-Updates empfangen
useEffect(() => {
if (!onWsMessage) return;
// onWsMessage ist eine Funktion, die wir anmelden
}, [onWsMessage]);
useEffect(() => () => {
if (wsReloadTimerRef.current) {
clearTimeout(wsReloadTimerRef.current);
wsReloadTimerRef.current = null;
}
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (type === 'RUNTIME_ACTIVITY_CHANGED') {
setActiveCronRuns(normalizeActiveCronRuns(message?.payload));
return;
}
if (type === 'CRON_JOBS_UPDATED' || type === 'CRON_JOB_UPDATED') {
scheduleLoadAll(80);
}
}
});
// Cron-Ausdruck validieren (debounced)
@@ -28,7 +28,6 @@ const GENERAL_TOOL_KEYS = new Set([
'mkvmerge_command',
'ffprobe_command',
'handbrake_restart_delete_incomplete_output',
'handbrake_pre_metadata_scan_enabled',
'script_test_timeout_ms'
]);
@@ -58,7 +57,8 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
'makemkv_rip_mode',
'makemkv_rip_mode_bluray',
'makemkv_rip_mode_dvd',
'makemkv_backup_mode'
'makemkv_backup_mode',
'handbrake_pre_metadata_scan_enabled'
]);
const EXPERT_ONLY_SETTING_KEYS = new Set([
'pushover_device',
@@ -78,6 +78,9 @@ const EXPERT_ONLY_SETTING_KEYS = new Set([
'cdparanoia_command',
'script_test_timeout_ms'
]);
const EXPERT_ONLY_CATEGORY_NAMES = new Set([
'logging'
]);
function toBoolean(value) {
if (typeof value === 'boolean') {
@@ -436,6 +439,14 @@ function filterSettingsByVisibility(settings, expertModeEnabled) {
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
}
function shouldHideCategoryByExpertMode(categoryName, expertModeEnabled) {
const normalizedCategory = normalizeText(categoryName);
if (!normalizedCategory) {
return false;
}
return !expertModeEnabled && EXPERT_ONLY_CATEGORY_NAMES.has(normalizedCategory);
}
function buildToolSections(settings) {
const list = Array.isArray(settings) ? settings : [];
const generalBucket = {
@@ -1118,6 +1129,7 @@ export default function DynamicSettingsForm({
const safeCategories = Array.isArray(categories) ? categories : [];
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
const visibleCategories = safeCategories
.filter((category) => !shouldHideCategoryByExpertMode(category?.category, expertModeEnabled))
.map((category) => ({
...category,
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
+154 -77
View File
@@ -641,24 +641,39 @@ function statusBadgeMeta(status, queued = false, options = {}) {
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
return { label, icon: 'pi-play-circle', tone: 'info' };
}
if (normalized === 'CD_READY_TO_RIP') {
return { label, icon: 'pi-play-circle', tone: 'info' };
}
if (normalized === 'WAITING_FOR_USER_DECISION') {
return { label, icon: 'pi-exclamation-circle', tone: 'warning' };
}
if (normalized === 'METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' };
}
if (normalized === 'CD_METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' };
}
if (normalized === 'ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' };
}
if (normalized === 'CD_ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' };
}
if (normalized === 'RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' };
}
if (normalized === 'CD_RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' };
}
if (normalized === 'MEDIAINFO_CHECK') {
return { label, icon: 'pi-sliders-h', tone: 'warning' };
}
if (normalized === 'ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' };
}
if (normalized === 'CD_ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' };
}
if (normalized === 'MERGING') {
return { label, icon: 'pi-spinner pi-spin', tone: 'warning' };
}
@@ -669,6 +684,25 @@ function normalizePathForCompare(value) {
return String(value || '').trim().replace(/[\\/]+$/, '');
}
function isIncompleteRawPath(value) {
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
if (!normalized) {
return false;
}
const segments = normalized.split('/').filter(Boolean);
const baseName = segments[segments.length - 1] || '';
return /^incomplete_/i.test(baseName);
}
function isIncompleteOutputPath(value) {
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
if (!normalized) {
return false;
}
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized)
|| /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
}
function buildMergeToolLogFallback(job = null) {
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object'
? job.handbrakeInfo
@@ -695,19 +729,13 @@ function buildMergeToolLogFallback(job = null) {
return lines.join('\n');
}
function omdbField(value) {
function metadataField(value) {
const raw = String(value || '').trim();
return raw || '-';
}
function omdbRottenTomatoesScore(omdbInfo) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
return omdbField(entry?.Value);
}
function normalizeMetadataProvider(value) {
return String(value || '').trim().toLowerCase() || 'omdb';
return String(value || '').trim().toLowerCase() || 'tmdb';
}
function resolveJobMetadataContext(job) {
@@ -734,7 +762,7 @@ function resolveJobMetadataContext(job) {
const metadataProvider = normalizeMetadataProvider(
mergedSelectedMetadata?.metadataProvider
|| analyzeContext?.metadataProvider
|| 'omdb'
|| 'tmdb'
);
return {
analyzeContext,
@@ -744,7 +772,13 @@ function resolveJobMetadataContext(job) {
}
function uniqueNameList(values, maxItems = 10) {
const source = Array.isArray(values) ? values : [];
const source = Array.isArray(values)
? values
: (typeof values === 'string'
? values.split(',')
: (values && typeof values === 'object'
? [values]
: []));
const limit = Math.max(1, Number(maxItems || 10));
const output = [];
const seen = new Set();
@@ -787,16 +821,12 @@ function formatRuntimeLabel(value) {
return text || null;
}
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
function resolveMetadataDetailsForDisplay(job) {
const metadataContext = resolveJobMetadataContext(job);
const provider = metadataContext.metadataProvider;
const selectedMetadata = metadataContext.selectedMetadata;
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: {};
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
if (isTmdbProvider) {
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
@@ -822,21 +852,6 @@ function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
};
}
return {
provider: 'omdb',
title: 'OMDb Details',
matchLabel: 'OMDb Match',
hasMatch: Boolean(job?.selected_from_omdb),
imdbId: String(job?.imdb_id || '').trim() || null,
director: omdbField(omdbInfo?.Director),
actors: omdbField(omdbInfo?.Actors),
runtime: omdbField(omdbInfo?.Runtime),
genre: omdbField(omdbInfo?.Genre),
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
imdbRating: omdbField(omdbInfo?.imdbRating)
};
}
function resolveSeriesDiscNumber(job) {
const metadataContext = resolveJobMetadataContext(job);
return normalizePositiveInteger(
@@ -1058,7 +1073,7 @@ export default function JobDetailDialog({
detailLoading = false,
onLoadLog,
logLoadingMode = null,
onAssignOmdb,
onAssignMetadata,
onAssignCdMetadata,
onGenerateNfo,
onAcknowledgeError,
@@ -1076,7 +1091,7 @@ export default function JobDetailDialog({
onRestoreMultipartMerge,
onCancel,
isQueued = false,
omdbAssignBusy = false,
metadataAssignBusy = false,
cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false,
generateNfoBusy = false,
@@ -1234,8 +1249,7 @@ export default function JobDetailDialog({
job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !running
&& mediaType !== 'audiobook'
&& !blockMetadataAndReviewUntilRetry
&& (isAudiobook || !blockMetadataAndReviewUntilRetry)
&& typeof onRestartReview === 'function'
);
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
@@ -1328,9 +1342,8 @@ export default function JobDetailDialog({
queueLocked,
{ label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' }
);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const metadataContext = resolveJobMetadataContext(job);
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const metadataDetails = resolveMetadataDetailsForDisplay(job);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
@@ -1455,17 +1468,15 @@ export default function JobDetailDialog({
&& Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0)
);
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
const metadataJsonValue = metadataDetails.provider === 'tmdb'
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
: job?.omdbInfo;
const metadataJsonTitle = 'TMDb Info';
const metadataJsonValue = metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null;
const metadataAssignProvider = isDvdSeries
? 'tmdb'
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
: 'tmdb';
const metadataAssignButtonLabel = 'TMDb neu zuweisen';
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
? 'Öffnet die TMDb-Suche, um Metadaten (Titel, Staffel/Episoden bzw. Film) neu zuzuweisen.'
: 'Öffnet die TMDb-Suche, um Metadaten neu zuzuweisen.';
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -1490,8 +1501,24 @@ export default function JobDetailDialog({
|| ''
).trim() || null;
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const jobMediaType = resolveMediaType(job);
const isDiscMediaJob = ['dvd', 'bluray', 'cd'].includes(jobMediaType);
const jobStatusUpper = String(job?.status || '').trim().toUpperCase();
const outputDownloadReady = Boolean(
job?.output_path
&& job?.outputStatus?.exists
&& !isIncompleteOutputPath(job.output_path)
&& (job?.encodeSuccess || jobStatusUpper === 'FINISHED')
);
const rawDownloadReady = Boolean(
resolvedRawPath
&& job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !isIncompleteRawPath(resolvedRawPath)
&& (!isDiscMediaJob || Boolean(job?.ripSuccessful))
);
const canDownloadRaw = Boolean(rawDownloadReady && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(outputDownloadReady && typeof onDownloadArchive === 'function');
const outputFolders = (() => {
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
if (!isDiskContainer) {
@@ -1507,7 +1534,9 @@ export default function JobDetailDialog({
seen.add(outputPath);
merged.push({
id: folder?.id ?? fallbackId ?? outputPath,
output_path: outputPath
output_path: outputPath,
job_id: normalizePositiveInteger(folder?.job_id ?? folder?.jobId ?? null) || null,
exists: folder?.exists === undefined ? undefined : Boolean(folder?.exists)
});
};
for (const folder of baseFolders) {
@@ -1516,11 +1545,17 @@ export default function JobDetailDialog({
for (const child of childJobs) {
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
for (const childFolder of childOutputFolders) {
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`);
addFolder(
{
...childFolder,
job_id: childFolder?.job_id ?? child?.id ?? null
},
`child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`
);
}
if (child?.output_path) {
addFolder(
{ output_path: child.output_path },
{ output_path: child.output_path, job_id: child?.id ?? null },
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
);
}
@@ -1843,37 +1878,24 @@ export default function JobDetailDialog({
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(metadataDetails.director)}</span>
<span>{metadataField(metadataDetails.director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(metadataDetails.actors)}</span>
<span>{metadataField(metadataDetails.actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(metadataDetails.runtime)}</span>
<span>{metadataField(metadataDetails.runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(metadataDetails.genre)}</span>
<span>{metadataField(metadataDetails.genre)}</span>
</div>
{metadataDetails.provider === 'tmdb' ? (
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{omdbField(metadataDetails.tmdbRating)}</span>
<span>{metadataField(metadataDetails.tmdbRating)}</span>
</div>
) : (
<>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(metadataDetails.imdbRating)}</span>
</div>
</>
)}
</div>
</section>
) : null}
@@ -1954,7 +1976,16 @@ export default function JobDetailDialog({
const childDiscNumber = resolveSeriesDiscNumber(child);
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
const childRawPath = child?.raw_path || child?.output_path || null;
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const childMediaType = resolveMediaType(child);
const childIsDiscMedia = ['dvd', 'bluray', 'cd'].includes(childMediaType);
const childRawDownloadReady = Boolean(
childRawPath
&& child?.rawStatus?.exists
&& child?.rawStatus?.isEmpty !== true
&& !isIncompleteRawPath(childRawPath)
&& (!childIsDiscMedia || Boolean(child?.ripSuccessful))
);
const canDownloadChildRaw = Boolean(childRawDownloadReady && typeof onDownloadArchive === 'function');
return (
<PathField
key={`child-raw-${child.id}`}
@@ -1973,6 +2004,19 @@ export default function JobDetailDialog({
if (!folderPath) {
return null;
}
const folderOwnerJobId = normalizePositiveInteger(folder?.job_id) || normalizePositiveInteger(job?.id);
const folderOwner = folderOwnerJobId === normalizePositiveInteger(job?.id)
? job
: (childJobs.find((child) => normalizePositiveInteger(child?.id) === folderOwnerJobId) || job);
const folderOwnerStatusUpper = String(folderOwner?.status || '').trim().toUpperCase();
const folderExists = folder?.exists !== undefined
? Boolean(folder?.exists)
: Boolean(folderOwner?.outputStatus?.exists);
const folderDownloadReady = Boolean(
folderExists
&& !isIncompleteOutputPath(folderPath)
&& (folderOwner?.encodeSuccess || folderOwnerStatusUpper === 'FINISHED')
);
const normalizedFolderPath = normalizePathForCompare(folderPath);
const isMergeOutput = Boolean(
(normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath))
@@ -1980,13 +2024,13 @@ export default function JobDetailDialog({
);
const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output';
const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`;
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
const canDownloadFolder = Boolean(folderDownloadReady && typeof onDownloadOutputFolder === 'function');
return (
<PathField
key={folder.id || folderPath}
label={label}
value={folderPath}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath, folderOwnerJobId) : null}
downloadDisabled={!canDownloadFolder}
downloadLoading={downloadFolderBusyPath === folderPath}
/>
@@ -2610,7 +2654,7 @@ export default function JobDetailDialog({
</div>
) : null}
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? (
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignMetadata === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
@@ -2620,8 +2664,8 @@ export default function JobDetailDialog({
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(child)}
loading={omdbAssignBusy}
onClick={() => onAssignMetadata?.(child)}
loading={metadataAssignBusy}
disabled={!childActionState.childCanAssignMetadata}
/>
<span className="action-desc">{metadataAssignActionDescription}</span>
@@ -2917,7 +2961,7 @@ export default function JobDetailDialog({
loading={actionBusy}
disabled={!canCdStartReview}
/>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen dann Encode aus vorhandenen WAV-Daten starten.</span>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow mit den zuletzt verwendeten CD-Metadaten: Trackauswahl und Ausgabeeinstellungen prüfen, dann Encode aus vorhandenen WAV-Daten starten.</span>
</div>
<div className="action-item">
<Button
@@ -2933,6 +2977,39 @@ export default function JobDetailDialog({
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
</div>
) : isAudiobook ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audiobook</div>
{typeof onReencode === 'function' ? (
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<span className="action-desc">Startet FFmpeg direkt mit den zuletzt bestätigten Einstellungen neu. Es gibt dabei keinen Anpassungsschritt.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label="Vorprüfung starten"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview}
/>
<span className="action-desc">Legt den Job neu aus der Quelldatei an und öffnet ihn im Ripper, damit Einstellungen vor dem Encode angepasst werden können.</span>
</div>
) : null}
</div>
) : (
canShowGeneralEncodeActions ? (
<div className="actions-group">
@@ -3025,7 +3102,7 @@ export default function JobDetailDialog({
</div>
) : null}
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? (
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignMetadata === 'function' ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
@@ -3035,8 +3112,8 @@ export default function JobDetailDialog({
severity="secondary"
outlined
size="small"
onClick={() => onAssignOmdb?.(job)}
loading={omdbAssignBusy}
onClick={() => onAssignMetadata?.(job)}
loading={metadataAssignBusy}
disabled={running}
/>
<span className="action-desc">{metadataAssignActionDescription}</span>
@@ -301,6 +301,17 @@ function resolveSubtitleTrackVariantFlags(track) {
return { isForcedOnly, fullHasForced };
}
function isClosedCaptionSubtitleTrack(track) {
if (Boolean(track?.sdhLikely)) {
return true;
}
const title = String(track?.title || track?.description || '').trim();
if (!title) {
return false;
}
return /\b(closed\s*caption|cc|sdh|hoh|hearing\s*impaired)\b/i.test(title);
}
function compareForcedSubtitleCandidate(a, b) {
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) {
@@ -1153,6 +1164,7 @@ function TrackList({
if (type === 'subtitle') {
const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const selectedTrackIdSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id)));
const sortedSubtitleTracks = orderedTracks
.filter((track) => !isDuplicateSubtitleTrack(track));
@@ -1206,10 +1218,13 @@ function TrackList({
<div className="media-track-list">
{sortedSubtitleTracks.map((track) => {
const burned = isBurnedSubtitleTrack(track);
const trackId = normalizeTrackId(track?.id);
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und');
const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title);
const flags = resolveSubtitleTrackVariantFlags(track);
const isClosedCaption = isClosedCaptionSubtitleTrack(track);
const ccSuffix = isClosedCaption ? ' | Closed Caption (CC/SDH)' : '';
const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase();
const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low'
? confidenceRaw
@@ -1232,7 +1247,7 @@ function TrackList({
}
onToggleSubtitleVariant(language, 'forced', event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`,
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})${ccSuffix}`,
confidence
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
@@ -1248,7 +1263,7 @@ function TrackList({
return (
<div key={`${title}-${track.id}-combo`} className="media-track-item">
<div className="media-track-item">
<small className="track-action-note">{`#${track.id} | ${displayLanguage}`}</small>
<small className="track-action-note">{`#${track.id} | ${displayLanguage}${ccSuffix}`}</small>
</div>
{renderVariantRow({
key: `${title}-${track.id}-combo-full`,
@@ -1282,7 +1297,9 @@ function TrackList({
);
}
const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode);
const checked = allowSelection
? (trackId !== null && selectedTrackIdSet.has(String(trackId)))
: Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked);
return (
@@ -1292,12 +1309,12 @@ function TrackList({
checked,
disabled,
onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') {
if (disabled || typeof onToggleTrack !== 'function' || trackId === null) {
return;
}
onToggleSubtitleVariant(language, 'full', event.target.checked);
onToggleTrack(trackId, event.target.checked);
},
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})`
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})${ccSuffix}`
})}
<small className="track-action-note">Untertitel: {actionInfo}</small>
</div>
@@ -2177,8 +2194,10 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
{!compactTitleSelection ? (
<div className="encode-automation-grid">
{/* Pre-Encode Items (scripts + chains unified) */}
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
<div className="post-script-box">
<h4>Pre-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
@@ -2299,7 +2318,6 @@ export default function MediaInfoReviewPanel({
) : null}
{/* Post-Encode Items (scripts + chains unified) */}
{!compactTitleSelection ? (
<div className="post-script-box">
<h4>Post-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
@@ -2417,6 +2435,7 @@ export default function MediaInfoReviewPanel({
) : null}
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
</div>
) : null}
<h4>Titel</h4>
@@ -2716,6 +2735,12 @@ export default function MediaInfoReviewPanel({
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
}}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
@@ -2751,6 +2776,12 @@ export default function MediaInfoReviewPanel({
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
}}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
@@ -4,7 +4,6 @@ import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
export default function MetadataSelectionDialog({
visible,
@@ -105,6 +104,13 @@ export default function MetadataSelectionDialog({
}
return null;
};
const normalizeSeriesClassification = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'series' || raw === 'film' || raw === 'ambiguous') {
return raw;
}
return null;
};
const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) {
@@ -133,45 +139,78 @@ export default function MetadataSelectionDialog({
const [conflictError, setConflictError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const [manualWorkflowKind, setManualWorkflowKind] = useState(null);
const activeSearchRequestRef = useRef(0);
const autoSearchDoneRef = useRef(false);
const autoSearchWorkflowRef = useRef(null);
const wasVisibleRef = useRef(false);
const lastVisibleContextIdentityRef = useRef(null);
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
|| null
);
const contextSelectedMetadataProvider = String(
context?.selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const contextMetadataProvider = (
contextWorkflowKind === 'series'
? 'tmdb'
: (contextWorkflowKind === 'film'
? 'omdb'
: (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase()))
) || 'omdb';
const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider);
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
|| ''
).trim().toLowerCase();
const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb';
const providerOptions = mediaProfile === 'dvd'
? [
{ label: 'Film (OMDb)', value: 'omdb' },
{ label: 'Serie (TMDb)', value: 'tmdb' }
]
: [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }];
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
const metadataProvider = 'tmdb';
const providerLabel = 'TMDb';
const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification);
const hasSeriesSignals = useMemo(() => {
if (contextWorkflowKind === 'series') {
return true;
}
const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
? context.selectedMetadata
: {};
const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase();
if (metadataKind === 'series' || metadataKind === 'season') {
return true;
}
if (Number(selectedMetadata?.seasonNumber || 0) > 0) {
return true;
}
if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) {
return true;
}
if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) {
return true;
}
const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [];
return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0);
}, [context, contextWorkflowKind]);
const recommendedWorkflowKind = normalizeWorkflowKind(
context?.seriesDecision?.recommendedWorkflowKind
|| contextWorkflowKind
|| null
) || (hasSeriesSignals ? 'series' : 'film');
const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind;
const allowsWorkflowSwitch = (
(mediaProfile === 'dvd' || mediaProfile === 'bluray')
&& seriesDecisionClassification === 'ambiguous'
);
const supportsExternalIdInput = false;
const requiresDiscNumber = metadataProvider === 'tmdb'
&& effectiveWorkflowKind === 'series'
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
const contextJobId = Number(context?.jobId || 0) || null;
const contextIdentity = contextJobId
? `job:${contextJobId}`
: `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`;
useEffect(() => {
if (!visible) {
wasVisibleRef.current = false;
lastVisibleContextIdentityRef.current = null;
return;
}
const openingNow = !wasVisibleRef.current;
const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity;
if (!openingNow && !contextChanged) {
return;
}
@@ -198,10 +237,13 @@ export default function MetadataSelectionDialog({
setConflictError('');
setSeasonFilter('');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null);
autoSearchDoneRef.current = false;
autoSearchWorkflowRef.current = null;
activeSearchRequestRef.current += 1;
}, [visible, context]);
wasVisibleRef.current = true;
lastVisibleContextIdentityRef.current = contextIdentity;
}, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]);
useEffect(() => {
if (!discValidationError) {
@@ -221,7 +263,7 @@ export default function MetadataSelectionDialog({
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: (context?.omdbCandidates || []);
: [];
const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map();
@@ -246,7 +288,7 @@ export default function MetadataSelectionDialog({
}, [context, extraResults, hasSearchOverride]);
const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') {
if (effectiveWorkflowKind !== 'series') {
return rows;
}
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
@@ -261,7 +303,7 @@ export default function MetadataSelectionDialog({
}, [metadataProvider, rows, seasonFilter]);
const titleWithPosterBody = (row) => (
<div className="omdb-row">
<div className="metadata-row">
{row.poster && row.poster !== 'N/A' ? (
<img src={row.poster} alt={row.title} className="poster-thumb-lg" />
) : (
@@ -299,7 +341,8 @@ export default function MetadataSelectionDialog({
let effectiveResults = [];
for (const candidateQuery of queryVariants) {
const results = await onSearch(candidateQuery, {
metadataProvider
metadataProvider,
workflowKind: effectiveWorkflowKind
});
if (activeSearchRequestRef.current !== requestId) {
return;
@@ -323,20 +366,26 @@ export default function MetadataSelectionDialog({
};
useEffect(() => {
if (!visible || autoSearchDoneRef.current) {
if (!visible) {
return;
}
if (typeof onSearch !== 'function') {
autoSearchDoneRef.current = true;
autoSearchWorkflowRef.current = effectiveWorkflowKind;
return;
}
const trimmedQuery = String(query || '').trim();
if (!trimmedQuery) {
return;
}
const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind;
if (autoSearchDoneRef.current && !workflowChanged) {
return;
}
autoSearchDoneRef.current = true;
autoSearchWorkflowRef.current = effectiveWorkflowKind;
void handleSearch();
}, [visible, query, metadataProvider, onSearch]);
}, [visible, query, effectiveWorkflowKind, onSearch]);
const handleSubmitError = (error, pendingPayload = null) => {
const details = Array.isArray(error?.details) ? error.details : [];
@@ -393,12 +442,22 @@ export default function MetadataSelectionDialog({
year: selected.year,
imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
fromOmdb: metadataProvider === 'omdb',
metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
workflowKind: effectiveWorkflowKind,
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null,
voteAverage: Number.isFinite(Number(selected?.voteAverage))
? Number(Number(selected.voteAverage).toFixed(1))
: null,
imdbRating: String(
selected?.imdbRating
|| selected?.tmdbDetails?.imdbRating
|| ''
).trim() || null,
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
? selected.tmdbDetails
: null,
seasonNumber: selected.seasonNumber || null,
seasonName: selected.seasonName || null,
episodeCount: selected.episodeCount || 0,
@@ -409,11 +468,10 @@ export default function MetadataSelectionDialog({
jobId: context.jobId,
title: manualTitle,
year: manualYear,
imdbId: supportsExternalIdInput ? manualImdb : null,
imdbId: null,
poster: null,
fromOmdb: false,
metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
workflowKind: effectiveWorkflowKind,
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
@@ -572,26 +630,6 @@ export default function MetadataSelectionDialog({
modal
>
<div className="search-row">
{providerOptions.length > 1 ? (
<Dropdown
value={metadataProvider}
options={providerOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb';
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
setHasSearchOverride(false);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
}}
disabled={searchBusy || busy}
style={{ minWidth: '12.5rem' }}
/>
) : null}
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -605,8 +643,26 @@ export default function MetadataSelectionDialog({
/>
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
<div className={`metadata-filter-grid${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}>
{metadataProvider === 'tmdb' ? (
{allowsWorkflowSwitch ? (
<div className="dialog-actions" style={{ justifyContent: 'flex-start', marginTop: '-0.25rem' }}>
<Button
label="Als Film suchen"
severity={effectiveWorkflowKind === 'film' ? 'primary' : 'secondary'}
outlined={effectiveWorkflowKind !== 'film'}
onClick={() => setManualWorkflowKind('film')}
disabled={searchBusy || busy}
/>
<Button
label="Als Serie suchen"
severity={effectiveWorkflowKind === 'series' ? 'primary' : 'secondary'}
outlined={effectiveWorkflowKind !== 'series'}
onClick={() => setManualWorkflowKind('series')}
disabled={searchBusy || busy}
/>
</div>
) : null}
<div className={`metadata-filter-grid${effectiveWorkflowKind !== 'series' ? ' metadata-filter-grid-single' : ''}`}>
{effectiveWorkflowKind === 'series' ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
@@ -650,8 +706,8 @@ export default function MetadataSelectionDialog({
<Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column
header={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'}
body={(row) => (metadataProvider === 'tmdb'
header="Staffel"
body={(row) => (effectiveWorkflowKind === 'series'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: (row.imdbId || '-'))}
style={{ width: '10rem' }}
+71 -29
View File
@@ -316,6 +316,14 @@ function normalizeSubtitleLanguageOrder(rawOrder = []) {
return output;
}
function normalizeSubtitleSelectionMode(value, fallback = 'variants') {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'track_ids' || mode === 'variants') {
return mode;
}
return fallback;
}
function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean(
@@ -430,6 +438,15 @@ function buildDefaultTrackSelection(review) {
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
: [];
const manualHasVariants = Object.keys(manualSubtitleVariantSelection).length > 0;
const subtitleSelectionMode = manualSelectionMatchesTitle
? normalizeSubtitleSelectionMode(
manualSelection?.subtitleSelectionMode,
manualHasVariants
? 'variants'
: (manualSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
)
: 'variants';
selection[titleId] = {
audioTrackIds: manualAudioTrackIds.length > 0
@@ -447,7 +464,8 @@ function buildDefaultTrackSelection(review) {
: defaultSubtitleVariantSelection.subtitleVariantSelection,
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
? manualSubtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder,
subtitleSelectionMode
};
}
@@ -460,7 +478,8 @@ function defaultTrackSelectionForTitle(review, titleId) {
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
};
}
@@ -569,15 +588,22 @@ function hasSeriesMetadataShape(source = null) {
const metadata = source && typeof source === 'object' ? source : {};
const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null);
const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : [];
const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase();
const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase();
const metadataKind = String(metadata?.metadataKind || '').trim().toLowerCase();
const episodeCount = Number(metadata?.episodeCount || 0) || 0;
if (['film', 'movie', 'feature'].includes(workflowKind) || ['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
return Boolean(
seasonNumber
|| episodeCount > 0
|| episodes.length > 0
|| metadataProvider === 'tmdb'
|| metadataProvider === 'themoviedb'
|| workflowKind === 'series'
|| workflowKind === 'season'
|| workflowKind === 'tv'
|| metadataKind === 'series'
|| metadataKind === 'season'
|| metadataKind === 'tv'
);
}
@@ -631,24 +657,14 @@ function resolveDiscWorkflowKind({
return normalized;
}
}
const providerCandidates = [
selected?.metadataProvider,
analyze?.metadataProvider,
pipelineCtx?.metadataProvider
];
for (const candidate of providerCandidates) {
const provider = String(candidate || '').trim().toLowerCase();
if (!provider) {
continue;
}
if (provider === 'tmdb' || provider === 'themoviedb') {
if (
hasSeriesMetadataShape(selected)
|| hasSeriesMetadataShape(analyze)
|| hasSeriesMetadataShape(pipelineCtx)
) {
return 'series';
}
if (provider === 'omdb') {
return 'film';
}
}
return fallbackIsSeries ? 'series' : 'film';
return null;
}
function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
@@ -656,7 +672,10 @@ function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
return null;
}
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
const kind = normalizeDiscWorkflowKind(workflowKind);
if (!kind) {
return null;
}
if (normalizedMediaProfile === 'bluray') {
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
}
@@ -1555,7 +1574,7 @@ export default function PipelineStatusCard({
onAnalyze,
onReanalyze,
onOpenMetadata,
onReassignOmdb,
onReassignMetadata,
onStart,
onRemoveFromQueue,
onRestartEncode,
@@ -3496,11 +3515,24 @@ export default function PipelineStatusCard({
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id)));
const subtitleSelectionMode = normalizeSubtitleSelectionMode(
effectiveSelection?.subtitleSelectionMode,
hasExplicitVariantSelection
? 'variants'
: (filteredSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
);
selectedTrackSelection[titleId] = {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds,
subtitleSelectionMode,
subtitleTrackIds: subtitleSelectionMode === 'track_ids'
? filteredSubtitleTrackIds
: (hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds),
...(subtitleSelectionMode === 'variants'
? {
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
}
: {})
};
}
const episodeAssignments = {};
@@ -3721,7 +3753,7 @@ export default function PipelineStatusCard({
&& state !== 'IDLE'
&& state !== 'DISC_DETECTED'
&& retryJobId
&& typeof onReassignOmdb === 'function'
&& typeof onReassignMetadata === 'function'
&& !isOrphanCancelled
&& !blockMetadataAndReviewForRipRetry
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
@@ -3730,7 +3762,7 @@ export default function PipelineStatusCard({
icon="pi pi-search"
severity="info"
size="small"
onClick={() => onReassignOmdb?.(retryJobId)}
onClick={() => onReassignMetadata?.(retryJobId)}
loading={busy}
/>
) : null}
@@ -4357,7 +4389,8 @@ export default function PipelineStatusCard({
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
@@ -4369,7 +4402,14 @@ export default function PipelineStatusCard({
...prev,
[normalizedTitleId]: {
...current,
[key]: next
[key]: next,
...(trackType === 'subtitle'
? {
subtitleSelectionMode: 'track_ids',
subtitleVariantSelection: {},
subtitleLanguageOrder: []
}
: {})
}
};
});
@@ -4387,7 +4427,8 @@ export default function PipelineStatusCard({
audioTrackIds: [],
subtitleTrackIds: [],
subtitleVariantSelection: {},
subtitleLanguageOrder: []
subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
};
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
const nextMap = { ...currentMap };
@@ -4415,6 +4456,7 @@ export default function PipelineStatusCard({
...prev,
[normalizedTitleId]: {
...current,
subtitleSelectionMode: 'variants',
subtitleVariantSelection: nextMap,
subtitleLanguageOrder: nextOrder
}
+9 -5
View File
@@ -18,7 +18,7 @@ export function useWebSocket({ onMessage }) {
let reconnectTimer;
let progressFlushTimer = null;
let progressFlushRaf = null;
let latestProgressMessage = null;
const latestProgressMessagesByJob = new Map();
let isUnmounted = false;
const clearProgressFlush = () => {
@@ -35,12 +35,14 @@ export function useWebSocket({ onMessage }) {
const flushProgressMessage = () => {
progressFlushRaf = null;
progressFlushTimer = null;
const message = latestProgressMessage;
latestProgressMessage = null;
if (!message) {
if (latestProgressMessagesByJob.size === 0) {
return;
}
const messages = Array.from(latestProgressMessagesByJob.values());
latestProgressMessagesByJob.clear();
for (const message of messages) {
onMessageRef.current?.(message);
}
};
const scheduleProgressFlush = () => {
@@ -65,7 +67,9 @@ export function useWebSocket({ onMessage }) {
try {
const message = JSON.parse(event.data);
if (message?.type === 'PIPELINE_PROGRESS') {
latestProgressMessage = message;
const progressJobId = message?.payload?.activeJobId;
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
latestProgressMessagesByJob.set(jobKey, message);
scheduleProgressFlush();
return;
}
+139 -76
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
@@ -10,7 +9,6 @@ import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { resolveMediaType } from '../utils/jobTaxonomy';
import { confirmModal } from '../utils/confirmModal';
@@ -25,6 +23,44 @@ function normalizeJobId(value) {
return Math.trunc(parsed);
}
function hasOwn(source, key) {
return source != null && Object.prototype.hasOwnProperty.call(source, key);
}
function normalizeLiveProgressForJob(job) {
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
if (!live) {
return null;
}
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
return {
progress: live?.progress ?? null,
eta: live?.eta ?? null,
statusText: live?.statusText ?? null,
state: live?.state ?? null,
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
? liveContext.currentChapter
: null,
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
? Number(liveContext.completedChapterCount)
: null
};
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
function parseEncodePlan(job) {
if (job?.encodePlan && typeof job.encodePlan === 'object') {
return job.encodePlan;
@@ -71,6 +107,22 @@ function resolveAudiobookMetadata(job, encodePlan) {
};
}
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
const narrator = String(metadata?.narrator || '').trim() || null;
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
const year = Number.isFinite(Number(metadata?.year))
? Math.trunc(Number(metadata.year))
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
return (
`#${jobId}`
+ (author ? ` | ${author}` : '')
+ (narrator ? ` | ${narrator}` : '')
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
+ (year ? ` | ${year}` : '')
);
}
function buildAudiobookPipeline(job, progress = null) {
const encodePlan = parseEncodePlan(job);
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
@@ -93,7 +145,13 @@ function buildAudiobookPipeline(job, progress = null) {
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {}
: {},
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
},
mediaInfoReview: reviewData,
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
@@ -109,16 +167,15 @@ function buildAudiobookPipeline(job, progress = null) {
export default function AudiobooksPage({
audiobookUpload,
onAudiobookUpload
onAudiobookUpload,
onCancelAudiobookUpload = null
}) {
const toastRef = useRef(null);
const navigate = useNavigate();
const [jobs, setJobs] = useState([]);
const [loadingJobs, setLoadingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [jobProgress, setJobProgress] = useState({});
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
const explorerRefreshToken = 0;
const setActionBusy = (jobId, busy) => {
const normalizedJobId = normalizeJobId(jobId);
@@ -146,21 +203,32 @@ export default function AudiobooksPage({
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
setJobs(audiobookRows);
setJobProgress((prev) => {
const next = { ...prev };
const next = {};
for (const job of audiobookRows) {
const jobId = normalizeJobId(job?.id);
if (!jobId) {
continue;
}
const status = String(job?.status || '').trim().toUpperCase();
if (!['ANALYZING', 'ENCODING'].includes(status)) {
delete next[jobId];
const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
if (!isLiveStatus) {
continue;
}
const serverProgress = normalizeLiveProgressForJob(job);
if (serverProgress) {
next[jobId] = serverProgress;
continue;
}
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
next[jobId] = prev[jobId];
}
}
return next;
});
return audiobookRows;
} catch (error) {
console.error('AudiobooksPage load jobs error:', error);
return [];
} finally {
setLoadingJobs(false);
}
@@ -183,17 +251,31 @@ export default function AudiobooksPage({
if (!jobId) {
return;
}
setJobProgress((prev) => ({
setJobProgress((prev) => {
const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
? payload.contextPatch
: null;
const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
? (contextPatch?.currentChapter ?? null)
: (previousEntry?.currentChapter ?? null);
const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
? (Number.isFinite(Number(contextPatch?.completedChapterCount))
? Number(contextPatch.completedChapterCount)
: null)
: (previousEntry?.completedChapterCount ?? null);
return {
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null,
currentChapter: payload?.contextPatch?.currentChapter ?? null,
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null
progress: payload?.progress ?? previousEntry?.progress ?? null,
eta: payload?.eta ?? previousEntry?.eta ?? null,
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
state: payload?.state ?? previousEntry?.state ?? null,
currentChapter: nextCurrentChapter,
completedChapterCount: nextCompletedChapterCount
}
}));
};
});
}
if (
message.type === 'PIPELINE_UPDATE'
@@ -262,7 +344,7 @@ export default function AudiobooksPage({
}
setActionBusy(normalizedJobId, true);
try {
await api.cancelJob(normalizedJobId);
await api.cancelPipeline(normalizedJobId);
toastRef.current?.show({
severity: 'info',
summary: 'Abbruch angefordert',
@@ -282,39 +364,6 @@ export default function AudiobooksPage({
}
};
const handleRetry = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.retryJob(normalizedJobId);
const retryJobId = normalizeJobId(response?.retryJob?.id);
toastRef.current?.show({
severity: 'success',
summary: 'Retry erstellt',
detail: retryJobId
? `Neuer Job #${retryJobId} wurde angelegt.`
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
life: 3200
});
await loadJobs();
if (retryJobId) {
setExpandedJobId(retryJobId);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Retry fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleDelete = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
@@ -357,11 +406,14 @@ export default function AudiobooksPage({
}
};
const handleUploaded = async (uploadedJobId) => {
const normalizedJobId = normalizeJobId(uploadedJobId);
await loadJobs();
if (normalizedJobId) {
setExpandedJobId(normalizedJobId);
const handleUploaded = async (uploadedJobId, response = null) => {
const normalizedJobId = normalizeJobId(uploadedJobId)
|| extractUploadJobIdFromResponse(response);
const rows = await loadJobs();
const fallbackJobId = normalizeJobId(rows?.[0]?.id);
const targetJobId = normalizedJobId || fallbackJobId;
if (targetJobId) {
setExpandedJobId(targetJobId);
}
};
@@ -393,6 +445,7 @@ export default function AudiobooksPage({
<AudiobookUploadPanel
audiobookUpload={audiobookUpload}
onAudiobookUpload={onAudiobookUpload}
onCancelUpload={onCancelAudiobookUpload}
onUploaded={handleUploaded}
/>
</Card>
@@ -417,7 +470,17 @@ export default function AudiobooksPage({
const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
: 0;
const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0;
const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
const showProgress = isRunning || progressValue > 0;
const progressLabel = `${Math.trunc(progressValue)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
? pipelineForJob.context.currentChapter
: null;
const chapterLabel = currentChapter?.index && currentChapter?.total
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
: '';
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
if (!isExpanded) {
return (
@@ -427,25 +490,30 @@ export default function AudiobooksPage({
className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)}
>
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small>#{jobId}</small>
<small>{metaLine}</small>
</div>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
);
}
@@ -453,25 +521,22 @@ export default function AudiobooksPage({
return (
<div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span>
<span>{title}</span>
</strong>
<small className="ripper-job-subtitle">{metaLine}</small>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
label="Im Ripper"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => navigate('/ripper')}
/>
<Button
label="Einklappen"
icon="pi pi-angle-up"
@@ -482,11 +547,16 @@ export default function AudiobooksPage({
/>
</div>
</div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => void handleAudiobookStart(jobId, config)}
onCancel={() => void handleCancel(jobId)}
onRetry={() => void handleRetry(jobId)}
onDeleteJob={() => void handleDelete(jobId)}
busy={busy}
/>
@@ -496,13 +566,6 @@ export default function AudiobooksPage({
</div>
)}
</Card>
<Card
title="Audiobook Output Explorer"
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
>
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
</Card>
</div>
);
}
+7 -7
View File
@@ -507,7 +507,7 @@ export default function ConverterPage() {
imdbId: row?.imdb_id || metadata?.imdbId || null,
poster: row?.poster_url || metadata?.poster || null
},
omdbCandidates: []
metadataCandidates: []
});
setMetadataDialogVisible(true);
};
@@ -537,9 +537,9 @@ export default function ConverterPage() {
}
};
const handleOmdbSearch = async (query) => {
const handleMetadataSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : [];
} catch (_error) {
return [];
@@ -978,7 +978,7 @@ export default function ConverterPage() {
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onOpenMetadata={handleOpenMetadataDialog}
onReassignOmdb={handleOpenMetadataDialog}
onReassignMetadata={handleOpenMetadataDialog}
onStart={handleStartPipelineJob}
onConfirmReview={handleConfirmReview}
onSelectPlaylist={handleSelectPlaylist}
@@ -1027,7 +1027,7 @@ export default function ConverterPage() {
setMetadataDialogContext(null);
}}
onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch}
onSearch={handleMetadataSearch}
busy={metadataDialogBusy}
/>
@@ -1196,7 +1196,7 @@ function ConverterVideoJobCard({
onExpand,
onCollapse,
onOpenMetadata,
onReassignOmdb,
onReassignMetadata,
onStart,
onConfirmReview,
onSelectPlaylist,
@@ -1282,7 +1282,7 @@ function ConverterVideoJobCard({
jobRow={job}
pipeline={pipeline}
onOpenMetadata={onOpenMetadata}
onReassignOmdb={onReassignOmdb}
onReassignMetadata={onReassignMetadata}
onStart={onStart}
onConfirmReview={onConfirmReview}
onSelectPlaylist={onSelectPlaylist}
+60 -3
View File
@@ -31,6 +31,7 @@ export default function DatabasePage() {
const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
const toastRef = useRef(null);
const orphanLoadRequestRef = useRef(0);
@@ -130,6 +131,50 @@ export default function DatabasePage() {
setOrphanImportBusyPath(null);
}
};
const handleDeleteOrphanRaw = async (row) => {
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
const confirmed = await confirmModal({
header: 'RAW löschen',
message:
`RAW-Ordner "${target}" wirklich löschen?\n` +
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
acceptLabel: 'RAW löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setOrphanDeleteBusyPath(row.rawPath);
try {
const response = await api.deleteOrphanRawFolder(row.rawPath);
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
const deletedFiles = Number(response?.filesDeleted || 0);
const deletedDirs = Number(response?.dirsRemoved || 0);
if (deletedRawPath) {
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: 'RAW gelöscht',
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
life: 3500
});
await loadOrphans({ silent: true });
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanDeleteBusyPath(null);
}
};
const orphanTitleBody = (row) => (
<div>
<div><strong>{row.title || '-'}</strong></div>
@@ -145,14 +190,26 @@ export default function DatabasePage() {
</div>
);
const orphanActionBody = (row) => (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Button
label="Job anlegen"
icon="pi pi-plus"
size="small"
onClick={() => handleImportOrphanRaw(row)}
loading={orphanImportBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath)}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => handleDeleteOrphanRaw(row)}
loading={orphanDeleteBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
</div>
);
return (
@@ -169,7 +226,7 @@ export default function DatabasePage() {
icon="pi pi-search"
onClick={loadOrphans}
loading={orphanLoading}
disabled={Boolean(orphanImportBusyPath)}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
</div>
@@ -190,7 +247,7 @@ export default function DatabasePage() {
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
</DataTable>
</div>
</Card>
+201 -177
View File
@@ -9,6 +9,7 @@ import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
@@ -459,33 +460,63 @@ function sanitizeRating(value) {
return raw;
}
function findOmdbRatingBySource(omdbInfo, sourceName) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const source = String(sourceName || '').trim().toLowerCase();
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source);
return sanitizeRating(entry?.Value);
function normalizeRatingValue(value) {
if (value === null || value === undefined || value === '') {
return null;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue.toFixed(1);
}
return sanitizeRating(value);
}
function resolveRatings(row) {
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null;
if (!omdbInfo) {
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {};
const selectedTmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: null;
const planTmdbDetails = planMetadata?.tmdbDetails && typeof planMetadata.tmdbDetails === 'object'
? planMetadata.tmdbDetails
: null;
const rowTmdbDetails = row?.tmdbDetails && typeof row.tmdbDetails === 'object'
? row.tmdbDetails
: null;
const tmdbDetails = selectedTmdbDetails || planTmdbDetails || rowTmdbDetails;
const tmdbRating = normalizeRatingValue(
tmdbDetails?.seasonVoteAverage
?? tmdbDetails?.voteAverage
?? tmdbDetails?.imdbRating
?? selectedMetadata?.seasonVoteAverage
?? selectedMetadata?.voteAverage
?? selectedMetadata?.imdbRating
?? planMetadata?.seasonVoteAverage
?? planMetadata?.voteAverage
?? planMetadata?.imdbRating
?? row?.voteAverage
?? row?.imdbRating
);
if (!tmdbRating) {
return [];
}
const imdb = sanitizeRating(omdbInfo?.imdbRating)
|| findOmdbRatingBySource(omdbInfo, 'Internet Movie Database');
const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes');
const metascore = sanitizeRating(omdbInfo?.Metascore);
const ratings = [];
if (imdb) {
ratings.push({ key: 'imdb', label: 'IMDb', value: imdb });
}
if (rotten) {
ratings.push({ key: 'rt', label: 'RT', value: rotten });
}
if (metascore) {
ratings.push({ key: 'meta', label: 'Meta', value: metascore });
if (tmdbRating) {
ratings.push({ key: 'tmdb', label: 'TMDb', value: tmdbRating });
}
return ratings;
}
@@ -553,7 +584,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
@@ -568,6 +599,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
const [conflictModalBusy, setConflictModalBusy] = useState(false);
const toastRef = useRef(null);
const wsReloadTimerRef = useRef(null);
const progressStateByJobRef = useRef(new Map());
const queuedJobIdSet = useMemo(() => {
const next = new Set();
@@ -638,6 +671,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const scheduleLiveReload = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void load();
}, delayMs);
};
useEffect(() => {
api.getPref('history_layout').then((res) => {
if (res?.value === 'list' || res?.value === 'grid') {
@@ -654,6 +697,55 @@ export default function HistoryPage({ refreshToken = 0 }) {
return () => clearTimeout(timer);
}, [search, status, refreshToken]);
useEffect(() => () => {
if (wsReloadTimerRef.current) {
clearTimeout(wsReloadTimerRef.current);
wsReloadTimerRef.current = null;
}
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (
type === 'PIPELINE_STATE_CHANGED'
|| type === 'PIPELINE_QUEUE_CHANGED'
|| type === 'DISC_DETECTED'
|| type === 'DISC_REMOVED'
) {
scheduleLiveReload(80);
return;
}
if (type !== 'PIPELINE_PROGRESS') {
return;
}
const payload = message?.payload && typeof message.payload === 'object'
? message.payload
: {};
const normalizedJobId = normalizeJobId(payload?.activeJobId);
if (!normalizedJobId) {
return;
}
const nextState = String(payload?.state || '').trim().toUpperCase();
if (!nextState) {
return;
}
const key = String(normalizedJobId);
const prevState = progressStateByJobRef.current.get(key) || '';
if (nextState === prevState) {
return;
}
progressStateByJobRef.current.set(key, nextState);
scheduleLiveReload(120);
}
});
useEffect(() => {
const params = new URLSearchParams(location.search);
const openJobId = Number(params.get('open') || 0);
@@ -869,11 +961,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
} else if (action === 'restart_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
const isAudiobookJob = resolveMediaType(job) === 'audiobook';
if (!skipConfirm) {
const confirmed = await confirmModal({
header: 'Review neu starten',
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
acceptLabel: 'Review starten',
header: isAudiobookJob ? 'Vorprüfung starten' : 'Review neu starten',
message: isAudiobookJob
? `Vorprüfung für "${title}" starten?\nDer Job wird neu angelegt und kann danach im Ripper vor dem Encode angepasst werden.`
: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
acceptLabel: isAudiobookJob ? 'Vorprüfung starten' : 'Review starten',
rejectLabel: 'Abbrechen'
});
if (!confirmed) {
@@ -887,14 +982,21 @@ export default function HistoryPage({ refreshToken = 0 }) {
const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
summary: isAudiobookJob ? 'Vorprüfung gestartet' : 'Review-Neustart',
detail: isAudiobookJob
? 'Job neu angelegt. Im Ripper können die Encode-Einstellungen angepasst werden.'
: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
life: 3500
});
await load();
await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
toastRef.current?.show({
severity: 'error',
summary: isAudiobookJob ? 'Vorprüfung fehlgeschlagen' : 'Review-Neustart fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setActionBusy(false);
}
@@ -903,7 +1005,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) {
const confirmed = await confirmModal({
header: 'CD-Vorprüfung starten',
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
message: `CD-Vorprüfung für "${title}" starten?\nTrackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
acceptLabel: 'Vorprüfung starten',
rejectLabel: 'Abbrechen'
});
@@ -917,7 +1019,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({
severity: 'success',
summary: 'CD-Vorprüfung gestartet',
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.',
detail: 'Job ist jetzt im Ripper verfügbar — bitte Tracks und Einstellungen prüfen.',
life: 4000
});
await load();
@@ -1129,8 +1231,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleDownloadOutputFolder = async (row, folderPath) => {
const jobId = Number(row?.id || selectedJob?.id || 0);
const handleDownloadOutputFolder = async (row, folderPath, ownerJobId = null) => {
const jobId = Number(ownerJobId || row?.id || selectedJob?.id || 0);
if (!jobId || !folderPath) return;
setDownloadFolderBusyPath(folderPath);
try {
@@ -1269,7 +1371,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleAssignOmdb = (row) => {
const handleAssignMetadata = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
@@ -1291,11 +1393,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = workflowKind === 'series'
? 'tmdb'
: (workflowKind === 'film'
? 'omdb'
: (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb'));
const metadataProvider = 'tmdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = {
@@ -1326,47 +1424,63 @@ export default function HistoryPage({ refreshToken = 0 }) {
...(discNumber ? { discNumber } : {})
},
metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
omdbCandidates: []
seriesDecision: analyzeContext?.seriesDecision || null
};
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleOmdbSearch = async (query, options = {}) => {
const provider = String(
options?.metadataProvider
|| metadataDialogContext?.metadataProvider
|| 'omdb'
).trim().toLowerCase() || 'omdb';
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = provider === 'tmdb'
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchOmdb(query);
: await api.searchTmdbMovie(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
summary: 'TMDb-Suche fehlgeschlagen',
detail: error.message
});
return [];
}
};
const handleOmdbSubmit = async (payload) => {
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const handleMetadataSubmit = async (payload) => {
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
const isPipelineFlow = submitMode === 'pipeline';
setOmdbAssignBusy(true);
setMetadataAssignBusy(true);
try {
if (isPipelineFlow) {
await api.selectMetadata(payload);
const selectResponse = await api.selectMetadata(payload);
const selectedJobUpdate = selectResponse?.job && typeof selectResponse.job === 'object'
? selectResponse.job
: null;
if (selectedJobUpdate?.id) {
const normalizedSelectedId = Number(selectedJobUpdate.id);
setJobs((prev) => (
Array.isArray(prev)
? prev.map((row) => (Number(row?.id || 0) === normalizedSelectedId ? { ...row, ...selectedJobUpdate } : row))
: prev
));
setSelectedJob((prev) => (
Number(prev?.id || 0) === normalizedSelectedId
? { ...(prev || {}), ...selectedJobUpdate }
: prev
));
}
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
@@ -1408,11 +1522,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
return;
}
await api.assignJobOmdb(payload.jobId, payload);
const assignResponse = await api.assignJobMetadata(payload.jobId, payload);
const assignedJobUpdate = assignResponse?.job && typeof assignResponse.job === 'object'
? assignResponse.job
: null;
if (assignedJobUpdate?.id) {
const normalizedAssignedId = Number(assignedJobUpdate.id);
setJobs((prev) => (
Array.isArray(prev)
? prev.map((row) => (Number(row?.id || 0) === normalizedAssignedId ? { ...row, ...assignedJobUpdate } : row))
: prev
));
setSelectedJob((prev) => (
Number(prev?.id || 0) === normalizedAssignedId
? { ...(prev || {}), ...assignedJobUpdate }
: prev
));
}
toastRef.current?.show({
severity: 'success',
summary: 'Metadaten zugewiesen',
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
detail: 'TMDb-Metadaten wurden aktualisiert.',
life: 3000
});
setMetadataDialogVisible(false);
@@ -1425,11 +1555,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
summary: 'TMDb-Zuweisung fehlgeschlagen',
detail: error.message
});
} finally {
setOmdbAssignBusy(false);
setMetadataAssignBusy(false);
}
};
@@ -1451,9 +1581,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
setCdMetadataDialogVisible(true);
};
const handleMusicBrainzSearch = async (query) => {
const handleMusicBrainzSearch = async (query, options = {}) => {
try {
const response = await api.searchMusicBrainz(query);
const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
@@ -1594,71 +1726,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
setDeleteEntryPreviewLoading(true);
try {
const response = await api.getJobDeletePreview(jobId, {
includeRelated: deleteEntryIncludeRelated,
selectedJobIds
});
const preview = response?.preview || null;
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
: relatedJobs
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
setDeleteEntryPreview(preview);
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
const defaultSelectedRawPaths = rawCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
const defaultSelectedMoviePaths = movieCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschvorschau fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setDeleteEntryPreviewLoading(false);
}
};
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
if (!normalizedRelatedJobId || !dialogJobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const knownJobIds = relatedJobs
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
const currentSelected = new Set(
deleteEntrySelectedJobIds
.map((item) => normalizeJobId(item))
.filter(Boolean)
);
if (checked) {
currentSelected.add(normalizedRelatedJobId);
} else {
currentSelected.delete(normalizedRelatedJobId);
}
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
setDeleteEntrySelectedJobIds(orderedSelected);
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
};
const confirmDeleteEntry = async (target) => {
const normalizedTarget = String(target || '').trim().toLowerCase();
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
@@ -1672,16 +1739,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Historie-Auswahl erforderlich',
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
life: 3500
});
return;
}
if (
(normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled
@@ -1761,16 +1818,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Historie-Auswahl erforderlich',
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
life: 3500
});
return;
}
if (
(normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled
@@ -2455,12 +2502,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
: previewRelatedJobs
.filter((item) => Boolean(item?.selected))
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
@@ -2474,17 +2515,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedRawPaths]
);
const selectedDeleteRelatedJobSet = useMemo(
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
[deleteEntrySelectedJobIds]
);
const selectedDeleteMoviePathSet = useMemo(
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedMoviePaths]
);
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
&& previewRelatedJobs.length > 0
&& deleteEntrySelectedJobIds.length === 0;
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
&& rawDeleteSelectionEnabled
&& deleteEntrySelectedRawPaths.length === 0;
@@ -2571,7 +2605,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignMetadata={handleAssignMetadata}
onAssignCdMetadata={handleAssignCdMetadata}
onGenerateNfo={handleGenerateNfo}
onAcknowledgeError={handleAcknowledgeError}
@@ -2586,7 +2620,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
actionBusy={actionBusy}
cancelBusy={actionBusy}
restoreMergeBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
metadataAssignBusy={metadataAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
generateNfoBusy={generateNfoBusy}
acknowledgeErrorBusy={acknowledgeErrorBusy}
@@ -2612,7 +2646,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
modal
>
<p>
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`}
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge im Lösch-Scope enthalten.`}
</p>
<small className="history-dv-subtle">
{deleteEntryIsMergeJob
@@ -2636,12 +2670,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
<ul className="history-delete-preview-list">
{previewRelatedJobs.map((item) => (
<li key={`delete-related-${item.id}`}>
<label className="history-delete-preview-checkbox-row">
<input
type="checkbox"
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))}
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))}
/>
<span>
<strong>#{item.id}</strong>
{' '}| {item.title || '-'}
@@ -2649,16 +2677,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
{' '}| {getStatusLabel(item.status)}
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
</span>
</label>
</li>
))}
</ul>
) : (
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
)}
{relatedDeleteSelectionRequired ? (
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
) : null}
</div>
{!deleteEntryIsMergeJob ? (
@@ -2751,7 +2775,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
) : (
<>
@@ -2762,7 +2786,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
/>
<Button
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
@@ -2771,7 +2795,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
<Button
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
@@ -2779,7 +2803,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
</>
)}
@@ -2791,7 +2815,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteFilesOnly('both')}
loading={deleteEntryTargetBusy === 'files-both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/>
) : null}
<Button
@@ -2801,7 +2825,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('none')}
loading={deleteEntryTargetBusy === 'none'}
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired}
disabled={deleteTargetActionsDisabled}
/>
<Button
label="Abbrechen"
@@ -2820,9 +2844,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
busy={omdbAssignBusy}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={metadataAssignBusy}
/>
<CdMetadataDialog
File diff suppressed because it is too large Load Diff
+134 -11
View File
@@ -78,6 +78,53 @@ function normalizeUserPresetDefaultsMap(raw = {}) {
return normalized;
}
function normalizeRunningChainIds(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const active = Array.isArray(payload.active) ? payload.active : [];
const ids = [];
const seen = new Set();
for (const item of active) {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'chain') {
continue;
}
const chainIdRaw = Number(item?.chainId || 0);
if (!Number.isFinite(chainIdRaw) || chainIdRaw <= 0) {
continue;
}
const chainId = Math.trunc(chainIdRaw);
if (seen.has(chainId)) {
continue;
}
seen.add(chainId);
ids.push(chainId);
}
return ids;
}
function isLikelyNetworkRequestError(error) {
if (!error) {
return false;
}
const status = Number(error?.status);
if (Number.isFinite(status) && status > 0) {
return false;
}
const message = String(error?.message || '').trim().toLowerCase();
if (!message) {
return true;
}
return (
message.includes('failed to fetch')
|| message.includes('networkerror')
|| message.includes('network error')
|| message.includes('load failed')
|| message.includes('timeout')
|| message.includes('aborted')
|| message.includes('socket hang up')
);
}
function isPresetCompatibleWithSlot(preset, slotMediaType) {
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
if (mediaType === 'all') {
@@ -272,7 +319,8 @@ export default function SettingsPage() {
const [chainSaving, setChainSaving] = useState(false);
const [chainReordering, setChainReordering] = useState(false);
const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
const [chainActionBusyId, setChainActionBusyId] = useState(null);
const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
const [runningChainIds, setRunningChainIds] = useState([]);
const [lastChainTestResult, setLastChainTestResult] = useState(null);
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
const [chainEditorErrors, setChainEditorErrors] = useState({});
@@ -328,6 +376,27 @@ export default function SettingsPage() {
}
return next;
}, [userPresets, userPresetDefaultsDraft]);
const runningChainIdSet = useMemo(
() => new Set(Array.isArray(runningChainIds) ? runningChainIds : []),
[runningChainIds]
);
const setChainActionBusy = (chainId, busyFlag) => {
const normalizedId = Number(chainId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return;
}
const targetId = Math.trunc(normalizedId);
setChainActionBusyIds((prev) => {
const next = new Set(prev);
if (busyFlag) {
next.add(targetId);
} else {
next.delete(targetId);
}
return next;
});
};
const loadScripts = async ({ silent = false } = {}) => {
if (!silent) {
@@ -404,6 +473,15 @@ export default function SettingsPage() {
}
};
const refreshRunningChains = async () => {
try {
const response = await api.getRuntimeActivities();
setRunningChainIds(normalizeRunningChainIds(response));
} catch (_error) {
// ignore polling errors
}
};
const openNewUserPreset = () => {
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
setUserPresetErrors({});
@@ -546,7 +624,8 @@ export default function SettingsPage() {
const presetsPromise = api.getHandBrakePresets();
const scriptsPromise = api.getScripts();
const chainsPromise = api.getScriptChains();
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]);
const runtimePromise = api.getRuntimeActivities();
const [scriptsResponse, chainsResponse, runtimeResponse] = await Promise.allSettled([scriptsPromise, chainsPromise, runtimePromise]);
if (scriptsResponse.status === 'fulfilled') {
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
} else {
@@ -559,6 +638,9 @@ export default function SettingsPage() {
if (chainsResponse.status === 'fulfilled') {
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
}
if (runtimeResponse.status === 'fulfilled') {
setRunningChainIds(normalizeRunningChainIds(runtimeResponse.value));
}
presetsPromise
.then((presetPayload) => {
@@ -593,6 +675,25 @@ export default function SettingsPage() {
loadUserPresetDefaults();
}, []);
useEffect(() => {
let cancelled = false;
const pollRuntime = async () => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setRunningChainIds(normalizeRunningChainIds(response));
}
} catch (_error) {
// ignore polling errors
}
};
const timer = setInterval(pollRuntime, 2500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
const dirtyKeys = useMemo(() => {
const keys = new Set();
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
@@ -974,7 +1075,8 @@ export default function SettingsPage() {
if (!Number.isFinite(chainId) || chainId <= 0) {
return;
}
setChainActionBusyId(chainId);
const normalizedChainId = Math.trunc(chainId);
setChainActionBusy(normalizedChainId, true);
setLastChainTestResult(null);
try {
const response = await api.testScriptChain(chainId);
@@ -994,9 +1096,27 @@ export default function SettingsPage() {
});
}
} catch (error) {
if (isLikelyNetworkRequestError(error)) {
try {
const runtimeResponse = await api.getRuntimeActivities();
const activeIds = normalizeRunningChainIds(runtimeResponse);
setRunningChainIds(activeIds);
if (activeIds.includes(normalizedChainId)) {
toastRef.current?.show({
severity: 'warn',
summary: 'Ketten-Test läuft weiter',
detail: `"${chain?.name || chainId}" läuft im Hintergrund weiter. Der Test-Request wurde vorzeitig beendet (z.B. Timeout/Verbindungsabbruch).`
});
return;
}
} catch (_runtimeError) {
// ignore follow-up runtime fetch errors and show default error toast below
}
}
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
} finally {
setChainActionBusyId(null);
setChainActionBusy(normalizedChainId, false);
refreshRunningChains();
}
};
@@ -1121,7 +1241,7 @@ export default function SettingsPage() {
};
const handleChainListDragStart = (event, chainId) => {
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) {
if (chainSaving || chainsLoading || chainReordering) {
event.preventDefault();
return;
}
@@ -1221,8 +1341,7 @@ export default function SettingsPage() {
|| Boolean(scriptActionBusyId);
const chainListDnDDisabled = chainSaving
|| chainsLoading
|| chainReordering
|| Boolean(chainActionBusyId);
|| chainReordering;
return (
<div className="page-grid">
@@ -1555,6 +1674,10 @@ export default function SettingsPage() {
<div className="script-order-list">
{chains.map((chain, index) => {
const isDragging = Number(chainListDragSourceId) === Number(chain.id);
const chainId = Number(chain?.id);
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
return (
<div key={chain.id} className="script-order-wrapper">
<div
@@ -1596,15 +1719,15 @@ export default function SettingsPage() {
severity="secondary"
outlined
onClick={() => openChainEditor(chain)}
disabled={chainReordering || Boolean(chainActionBusyId)}
disabled={chainReordering}
/>
<Button
icon="pi pi-play"
label="Test"
severity="info"
onClick={() => handleTestChain(chain)}
loading={chainActionBusyId === chain.id}
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)}
loading={isChainActionBusy}
disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
/>
<Button
icon="pi pi-trash"
@@ -1612,7 +1735,7 @@ export default function SettingsPage() {
severity="danger"
outlined
onClick={() => handleDeleteChain(chain)}
disabled={chainReordering || Boolean(chainActionBusyId)}
disabled={chainReordering}
/>
</div>
</div>
+410
View File
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function resolveMediaType(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
const candidates = [
row?.mediaType,
row?.media_type,
row?.mediaProfile,
row?.media_profile,
encodePlan?.mediaProfile,
row?.makemkvInfo?.analyzeContext?.mediaProfile,
row?.makemkvInfo?.mediaProfile,
row?.mediainfoInfo?.mediaProfile
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (raw === 'converter') {
return 'converter';
}
}
return 'other';
}
function buildMetadataContextFromJob(row) {
const jobId = normalizeJobId(row?.id);
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const workflowKindRaw = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
? 'film'
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = 'tmdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
return {
jobId,
status: row?.status || null,
lastState: row?.last_state || null,
submitMode: 'assign',
mediaProfile: rowMediaType,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
year: row?.year || selectedMetadata?.year || null,
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
poster: row?.poster_url || selectedMetadata?.poster || null,
metadataProvider,
workflowKind,
tmdbId: selectedMetadata?.tmdbId || null,
providerId: selectedMetadata?.providerId || null,
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
seasonNumber,
seasonName: selectedMetadata?.seasonName || null,
episodeCount: selectedMetadata?.episodeCount || 0,
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
...(discNumber ? { discNumber } : {})
},
metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
seriesDecision: analyzeContext?.seriesDecision || null
};
}
export default function TmdbMigrationPage() {
const toastRef = useRef(null);
const countdownIntervalRef = useRef(null);
const nextJobTimeoutRef = useRef(null);
const jobsRef = useRef([]);
const runningRef = useRef(false);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
useEffect(() => {
jobsRef.current = Array.isArray(jobs) ? jobs : [];
}, [jobs]);
useEffect(() => {
runningRef.current = running;
}, [running]);
const clearTimers = useCallback(() => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
if (nextJobTimeoutRef.current) {
clearTimeout(nextJobTimeoutRef.current);
nextJobTimeoutRef.current = null;
}
}, []);
useEffect(() => () => {
clearTimers();
}, [clearTimers]);
const loadPendingJobs = useCallback(async () => {
setLoading(true);
try {
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
setJobs(rows);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Laden fehlgeschlagen',
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
});
setJobs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadPendingJobs();
}, [loadPendingJobs]);
const openJobInDialog = useCallback((job) => {
if (!job) {
return;
}
const context = buildMetadataContextFromJob(job);
if (!context?.jobId) {
return;
}
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
}, []);
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
clearTimers();
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
if (!nextJob) {
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
return;
}
setCooldownRemaining(normalizedSeconds);
if (normalizedSeconds > 0) {
countdownIntervalRef.current = setInterval(() => {
setCooldownRemaining((prev) => {
const next = Number(prev || 0) - 1;
return next > 0 ? next : 0;
});
}, 1000);
}
nextJobTimeoutRef.current = setTimeout(() => {
clearTimers();
setCooldownRemaining(0);
if (!runningRef.current) {
return;
}
openJobInDialog(nextJob);
}, normalizedSeconds * 1000);
}, [clearTimers, openJobInDialog]);
const startSequence = () => {
const pending = jobsRef.current;
if (!Array.isArray(pending) || pending.length === 0) {
return;
}
clearTimers();
setRunning(true);
setMetadataDialogVisible(true);
setCooldownRemaining(0);
openJobInDialog(pending[0]);
};
const stopSequence = useCallback(() => {
clearTimers();
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}, [clearTimers]);
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchTmdbMovie(query);
return response?.results || [];
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'TMDb-Suche fehlgeschlagen',
detail: error?.message || 'Suche fehlgeschlagen.'
});
return [];
}
};
const handleMetadataSubmit = async (payload) => {
const normalizedJobId = normalizeJobId(payload?.jobId);
if (!normalizedJobId) {
return;
}
setMetadataAssignBusy(true);
clearTimers();
setCooldownRemaining(0);
try {
await api.assignJobMetadata(normalizedJobId, payload || {});
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
jobsRef.current = remaining;
setJobs(remaining);
toastRef.current?.show({
severity: 'success',
summary: 'Job migriert',
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
life: 2600
});
if (remaining.length === 0) {
setRunning(false);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
toastRef.current?.show({
severity: 'success',
summary: 'Migration abgeschlossen',
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
life: 3200
});
return;
}
if (!runningRef.current) {
return;
}
scheduleNextJob(remaining[0], 10);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Migration fehlgeschlagen',
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
});
throw error;
} finally {
setMetadataAssignBusy(false);
}
};
const mediumTagBody = (row) => {
const mediaType = resolveMediaType(row);
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
};
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
const countdownProgressValue = useMemo(() => {
if (cooldownRemaining <= 0) {
return 0;
}
const elapsed = 10 - Math.min(10, cooldownRemaining);
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
}, [cooldownRemaining]);
return (
<div className="page-grid tmdb-migration-page">
<Toast ref={toastRef} position="top-right" />
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
<div className="tmdb-migration-toolbar">
<div className="tmdb-migration-toolbar-left">
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
</div>
<div className="tmdb-migration-toolbar-actions">
<Button
label="Liste neu laden"
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
outlined
onClick={() => {
if (!metadataAssignBusy) {
void loadPendingJobs();
}
}}
disabled={metadataAssignBusy}
/>
{!running ? (
<Button
label="Start"
icon="pi pi-play"
onClick={startSequence}
disabled={jobs.length === 0 || loading || metadataAssignBusy}
/>
) : (
<Button
label="Stop"
icon="pi pi-stop"
severity="secondary"
onClick={stopSequence}
disabled={metadataAssignBusy}
/>
)}
</div>
</div>
{running && cooldownRemaining > 0 ? (
<div className="tmdb-migration-countdown">
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
<div className="tmdb-migration-countdown-copy">
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
</div>
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
</div>
</div>
) : null}
<DataTable
value={jobs}
loading={loading}
dataKey="id"
emptyMessage="Keine offenen Jobs für TMDb-Migration."
className="tmdb-migration-table"
rows={50}
paginator={jobs.length > 50}
>
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
<Column header="Titel" body={titleBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
<Column
header="Status"
style={{ width: '14rem' }}
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
/>
</DataTable>
</Card>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={stopSequence}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
/>
</div>
);
}
+199 -58
View File
@@ -983,6 +983,62 @@ body {
gap: 0.15rem;
}
.tmdb-migration-page .p-card .p-card-content {
display: grid;
gap: 0.9rem;
}
.tmdb-migration-toolbar {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.tmdb-migration-toolbar-left,
.tmdb-migration-toolbar-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.tmdb-migration-countdown {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.65rem 0.75rem;
align-items: center;
border: 1px solid var(--surface-border);
border-radius: 0.55rem;
background: var(--surface-section);
padding: 0.65rem 0.8rem;
}
.tmdb-migration-countdown-copy {
display: grid;
gap: 0.12rem;
}
.tmdb-migration-countdown-copy small {
color: var(--text-color-secondary);
}
.tmdb-migration-countdown-progress {
grid-column: 1 / -1;
width: 100%;
height: 0.4rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.tmdb-migration-countdown-bar {
height: 100%;
background: var(--primary-color);
transition: width 180ms linear;
}
.queue-job-expand-btn {
width: 1.6rem;
height: 1.6rem;
@@ -1300,7 +1356,7 @@ body {
.aax-file-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 0.65rem;
padding: 0.6rem 0.75rem;
border-top: 1px solid var(--rip-border);
@@ -1328,6 +1384,60 @@ body {
text-overflow: ellipsis;
}
.aax-file-status {
display: grid;
gap: 0.28rem;
margin-top: 0.3rem;
}
.aax-file-status .p-progressbar {
height: 0.35rem;
}
.audiobook-upload-header {
width: 100%;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.9rem;
flex-wrap: wrap;
}
.audiobook-upload-header-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.audiobook-upload-header-actions .p-button.p-button-icon-only {
width: 2.35rem !important;
height: 2.35rem !important;
}
.audiobook-upload-header-status {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: fit-content;
}
.audiobook-upload-header-status-top {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
flex-wrap: wrap;
}
.audiobook-upload-header-status-top small {
color: var(--rip-muted);
}
.audiobook-upload-inline-text {
color: var(--rip-muted);
overflow-wrap: anywhere;
}
.audiobook-upload-status {
margin-top: 0.8rem;
padding: 0.7rem 0.8rem;
@@ -1379,6 +1489,17 @@ body {
margin-top: 0.15rem;
}
@media (max-width: 860px) {
.audiobook-upload-header-status {
min-width: 100%;
justify-content: flex-start;
}
.audiobook-upload-header-status-top {
justify-content: flex-start;
}
}
.ripper-job-badges {
display: flex;
align-items: center;
@@ -3552,14 +3673,14 @@ body {
background: #f6ebd6;
}
.omdb-row {
.metadata-row {
display: grid;
grid-template-columns: 64px 1fr;
gap: 0.65rem;
align-items: center;
}
.omdb-row > div {
.metadata-row > div {
min-width: 0;
}
@@ -3793,7 +3914,8 @@ body {
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
background: var(--rip-panel-soft);
display: grid;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
@@ -3801,6 +3923,44 @@ body {
margin: 0;
}
.post-script-box > small:last-child {
display: block;
min-height: 2.4em;
line-height: 1.2;
}
.post-script-box .actions-row,
.post-script-box .encode-item-add-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
width: 100%;
margin-top: auto;
}
.post-script-box .actions-row .p-button,
.post-script-box .encode-item-add-row .p-button {
width: 100%;
min-height: 2.35rem;
}
.post-script-box .actions-row > .p-button:only-child,
.post-script-box .encode-item-add-row > .p-button:only-child {
grid-column: 1 / -1;
}
@media (max-width: 1675px) {
.post-script-box .actions-row,
.post-script-box .encode-item-add-row {
grid-template-columns: 1fr;
}
.post-script-box .actions-row > .p-button:only-child,
.post-script-box .encode-item-add-row > .p-button:only-child {
grid-column: auto;
}
}
.post-script-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -5119,6 +5279,17 @@ body {
gap: 1rem;
}
.encode-automation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
align-items: stretch;
}
.encode-automation-grid > :only-child {
grid-column: 1 / -1;
}
.audiobook-config-head {
display: flex;
justify-content: space-between;
@@ -5152,13 +5323,6 @@ body {
display: block;
}
.audiobook-config-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: flex-start;
}
.audiobook-config-grid {
display: grid;
grid-template-columns: 1fr;
@@ -5189,9 +5353,7 @@ body {
.audiobook-chapter-list {
display: grid;
gap: 0.55rem;
max-height: 18rem;
overflow: auto;
padding-right: 0.25rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.audiobook-chapter-row {
@@ -5224,47 +5386,13 @@ body {
margin-top: 0.25rem;
}
.audiobook-chapter-status-list {
display: grid;
gap: 0.25rem;
max-height: 16rem;
overflow: auto;
padding-right: 0.25rem;
.audiobook-chapter-status .cd-track-table {
min-width: 38rem;
}
.audiobook-chapter-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: 6px;
font-size: 0.85rem;
}
.chapter-status-done {
background: var(--green-50, #f0fdf4);
color: var(--green-700, #15803d);
}
.chapter-status-active {
background: var(--blue-50, #eff6ff);
color: var(--blue-700, #1d4ed8);
font-weight: 600;
}
.chapter-status-pending {
color: var(--rip-muted, #666);
}
.chapter-status-nr {
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.chapter-status-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.audiobook-chapter-status .cd-track-table td.title,
.audiobook-chapter-status .cd-track-table th.title {
min-width: 16rem;
}
.audiobook-description-dialog p {
@@ -5281,7 +5409,13 @@ body {
word-break: break-word;
}
@media (max-width: 980px) {
@media (max-width: 1600px) {
.audiobook-chapter-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1350px) {
.audiobook-config-summary {
width: 100%;
}
@@ -5294,6 +5428,17 @@ body {
.audiobook-config-grid {
grid-template-columns: 1fr;
}
.audiobook-chapter-list {
grid-template-columns: 1fr;
}
}
@media (max-width: 1400px) {
.encode-automation-grid {
grid-template-columns: 1fr;
}
}
/* ── ReencodeConflictModal ──────────────────────────────────────────────── */
@@ -5723,10 +5868,6 @@ body {
gap: 0.55rem;
}
.audiobook-output-explorer .audiobook-output-row {
grid-template-columns: minmax(180px, 2fr) minmax(96px, 0.8fr) minmax(160px, 1.2fr);
}
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
.icon-button {
+4 -14
View File
@@ -254,16 +254,14 @@ export function isSeriesVideoJob(job) {
return true;
}
const metadataProvider = String(
analyzeContext?.metadataProvider
|| selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const metadataKind = String(
selectedMetadata?.metadataKind
|| analyzeContext?.metadataKind
|| ''
).trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const seasonNumberText = String(seasonNumberRaw ?? '').trim();
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
@@ -279,15 +277,7 @@ export function isSeriesVideoJob(job) {
return true;
}
const tmdbIdRaw = selectedMetadata?.tmdbId
?? analyzeContext?.tmdbId
?? selectedMetadata?.providerId
?? analyzeContext?.providerId
?? null;
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
return false;
}
export function isSeriesDvdJob(job) {
+5 -5
View File
@@ -13,11 +13,11 @@ const STATUS_LABELS = {
FINISHED: 'Fertig',
CANCELLED: 'Abgebrochen',
ERROR: 'Fehler',
CD_ANALYZING: 'CD-Analyse',
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
CD_RIPPING: 'CD-Rippen',
CD_ENCODING: 'CD-Encodieren'
CD_ANALYZING: 'Analyse',
CD_METADATA_SELECTION: 'Metadatenauswahl',
CD_READY_TO_RIP: 'Bereit zum Encodieren',
CD_RIPPING: 'Rippen',
CD_ENCODING: 'Encodieren'
};
const PROCESS_STATUS_LABELS = {
+5 -2
View File
@@ -222,7 +222,7 @@ backup_and_remove_backend_src() {
backup_persistent_backend_data() {
local src_dir="$INSTALL_DIR/backend/data"
local backup_root="/tmp/ripster-data-backup"
local backup_root="$INSTALL_DIR/backend/data/temp/ripster-data-backup"
local backup_dir="${backup_root}/data_bak_$(date +%Y%m%d%H%M%S)"
local copied_any=false
local candidate
@@ -391,7 +391,7 @@ SupplementaryGroups=video render cdrom disk
# darf no_new_privileges nicht aktiv sein.
NoNewPrivileges=false
ProtectSystem=full
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
PrivateTmp=true
EOF
)
@@ -974,6 +974,7 @@ mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/downloads"
mkdir -p "$INSTALL_DIR/backend/data/logs"
mkdir -p "$INSTALL_DIR/backend/data/temp"
# Gesicherte Daten zurückspielen
if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
@@ -987,6 +988,7 @@ mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/temp"
# --- npm-Abhängigkeiten installieren -----------------------------------------
header "npm-Abhängigkeiten installieren"
@@ -1043,6 +1045,7 @@ DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
DEFAULT_TEMP_DIR=${INSTALL_DIR}/backend/data/temp
EOF
ok "Backend .env erstellt"
fi
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.16.1-6",
"version": "0.16.1-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.16.1-6",
"version": "0.16.1-7",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.16.1-6",
"version": "0.16.1-7",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",