DVD Integration
This commit is contained in:
@@ -5,7 +5,9 @@
|
|||||||
"Bash(ls /home/michael/ripster/backend/*.sh)",
|
"Bash(ls /home/michael/ripster/backend/*.sh)",
|
||||||
"Bash(systemctl list-units --type=service)",
|
"Bash(systemctl list-units --type=service)",
|
||||||
"Bash(pip install -q -r requirements-docs.txt)",
|
"Bash(pip install -q -r requirements-docs.txt)",
|
||||||
"Bash(mkdocs build --strict)"
|
"Bash(mkdocs build --strict)",
|
||||||
|
"Read(//mnt/external/media/**)",
|
||||||
|
"WebFetch(domain:www.makemkv.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,44 @@ const { errorToMeta } = require('../utils/errorMeta');
|
|||||||
const { setLogRootDir, getJobLogDir } = require('../services/logPathService');
|
const { setLogRootDir, getJobLogDir } = require('../services/logPathService');
|
||||||
|
|
||||||
const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql');
|
const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql');
|
||||||
|
const LEGACY_PROFILE_SETTING_MIGRATIONS = [
|
||||||
|
{
|
||||||
|
legacyKey: 'mediainfo_extra_args',
|
||||||
|
profileKeys: ['mediainfo_extra_args_bluray', 'mediainfo_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'makemkv_rip_mode',
|
||||||
|
profileKeys: ['makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'makemkv_analyze_extra_args',
|
||||||
|
profileKeys: ['makemkv_analyze_extra_args_bluray', 'makemkv_analyze_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'makemkv_rip_extra_args',
|
||||||
|
profileKeys: ['makemkv_rip_extra_args_bluray', 'makemkv_rip_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'handbrake_preset',
|
||||||
|
profileKeys: ['handbrake_preset_bluray', 'handbrake_preset_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'handbrake_extra_args',
|
||||||
|
profileKeys: ['handbrake_extra_args_bluray', 'handbrake_extra_args_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'output_extension',
|
||||||
|
profileKeys: ['output_extension_bluray', 'output_extension_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'filename_template',
|
||||||
|
profileKeys: ['filename_template_bluray', 'filename_template_dvd']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legacyKey: 'output_folder_template',
|
||||||
|
profileKeys: ['output_folder_template_bluray', 'output_folder_template_dvd']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
let dbInstance;
|
let dbInstance;
|
||||||
|
|
||||||
@@ -484,6 +522,7 @@ async function openAndPrepareDatabase() {
|
|||||||
await applySchemaModel(dbInstance, schemaModel);
|
await applySchemaModel(dbInstance, schemaModel);
|
||||||
|
|
||||||
await seedDefaultSettings(dbInstance);
|
await seedDefaultSettings(dbInstance);
|
||||||
|
await migrateLegacyProfiledToolSettings(dbInstance);
|
||||||
await removeDeprecatedSettings(dbInstance);
|
await removeDeprecatedSettings(dbInstance);
|
||||||
await ensurePipelineStateRow(dbInstance);
|
await ensurePipelineStateRow(dbInstance);
|
||||||
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
|
||||||
@@ -573,6 +612,72 @@ async function seedDefaultSettings(db) {
|
|||||||
logger.info('seed:settings', { count: seeded });
|
logger.info('seed:settings', { count: seeded });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readCurrentOrDefaultSettingValue(db, key) {
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return db.get(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.default_value AS defaultValue,
|
||||||
|
v.value AS currentValue,
|
||||||
|
COALESCE(v.value, s.default_value) AS effectiveValue
|
||||||
|
FROM settings_schema s
|
||||||
|
LEFT JOIN settings_values v ON v.key = s.key
|
||||||
|
WHERE s.key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyProfiledToolSettings(db) {
|
||||||
|
let copiedCount = 0;
|
||||||
|
for (const migration of LEGACY_PROFILE_SETTING_MIGRATIONS) {
|
||||||
|
const legacyRow = await readCurrentOrDefaultSettingValue(db, migration.legacyKey);
|
||||||
|
if (!legacyRow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetKey of migration.profileKeys || []) {
|
||||||
|
const targetRow = await readCurrentOrDefaultSettingValue(db, targetKey);
|
||||||
|
if (!targetRow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = targetRow.currentValue;
|
||||||
|
const defaultValue = targetRow.defaultValue;
|
||||||
|
const shouldCopy = (
|
||||||
|
currentValue === null
|
||||||
|
|| currentValue === undefined
|
||||||
|
|| String(currentValue) === String(defaultValue ?? '')
|
||||||
|
);
|
||||||
|
if (!shouldCopy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`
|
||||||
|
INSERT INTO settings_values (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`,
|
||||||
|
[targetKey, legacyRow.effectiveValue ?? null]
|
||||||
|
);
|
||||||
|
copiedCount += 1;
|
||||||
|
logger.info('migrate:legacy-tool-setting-copied', {
|
||||||
|
from: migration.legacyKey,
|
||||||
|
to: targetKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copiedCount > 0) {
|
||||||
|
logger.info('migrate:legacy-tool-settings:done', { copiedCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensurePipelineStateRow(db) {
|
async function ensurePipelineStateRow(db) {
|
||||||
await db.run(
|
await db.run(
|
||||||
`
|
`
|
||||||
@@ -584,7 +689,19 @@ async function ensurePipelineStateRow(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeDeprecatedSettings(db) {
|
async function removeDeprecatedSettings(db) {
|
||||||
const deprecatedKeys = ['pushover_notify_disc_detected'];
|
const deprecatedKeys = [
|
||||||
|
'pushover_notify_disc_detected',
|
||||||
|
'mediainfo_extra_args',
|
||||||
|
'makemkv_rip_mode',
|
||||||
|
'makemkv_analyze_extra_args',
|
||||||
|
'makemkv_rip_extra_args',
|
||||||
|
'handbrake_preset',
|
||||||
|
'handbrake_extra_args',
|
||||||
|
'output_extension',
|
||||||
|
'filename_template',
|
||||||
|
'output_folder_template',
|
||||||
|
'makemkv_backup_mode'
|
||||||
|
];
|
||||||
for (const key of deprecatedKeys) {
|
for (const key of deprecatedKeys) {
|
||||||
const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
const result = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||||
if (result?.changes > 0) {
|
if (result?.changes > 0) {
|
||||||
|
|||||||
@@ -146,18 +146,6 @@ const defaultSchema = [
|
|||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 205
|
orderIndex: 205
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'mediainfo_extra_args',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'Mediainfo Extra Args',
|
|
||||||
type: 'string',
|
|
||||||
required: 0,
|
|
||||||
description: 'Zusätzliche CLI-Parameter für mediainfo.',
|
|
||||||
defaultValue: '',
|
|
||||||
options: [],
|
|
||||||
validation: {},
|
|
||||||
orderIndex: 206
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'makemkv_min_length_minutes',
|
key: 'makemkv_min_length_minutes',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
@@ -170,57 +158,6 @@ const defaultSchema = [
|
|||||||
validation: { min: 1, max: 1000 },
|
validation: { min: 1, max: 1000 },
|
||||||
orderIndex: 210
|
orderIndex: 210
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'pipeline_max_parallel_jobs',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'Parallele Jobs',
|
|
||||||
type: 'number',
|
|
||||||
required: 1,
|
|
||||||
description: 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.',
|
|
||||||
defaultValue: '1',
|
|
||||||
options: [],
|
|
||||||
validation: { min: 1, max: 12 },
|
|
||||||
orderIndex: 211
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'makemkv_rip_mode',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'MakeMKV Rip Modus',
|
|
||||||
type: 'select',
|
|
||||||
required: 1,
|
|
||||||
description: 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.',
|
|
||||||
defaultValue: 'backup',
|
|
||||||
options: [
|
|
||||||
{ label: 'MKV', value: 'mkv' },
|
|
||||||
{ label: 'Backup', value: 'backup' }
|
|
||||||
],
|
|
||||||
validation: {},
|
|
||||||
orderIndex: 212
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'makemkv_analyze_extra_args',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'MakeMKV Analyze Extra Args',
|
|
||||||
type: 'string',
|
|
||||||
required: 0,
|
|
||||||
description: 'Zusätzliche CLI-Parameter für Analyze.',
|
|
||||||
defaultValue: '',
|
|
||||||
options: [],
|
|
||||||
validation: {},
|
|
||||||
orderIndex: 220
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'makemkv_rip_extra_args',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'MakeMKV Rip Extra Args',
|
|
||||||
type: 'string',
|
|
||||||
required: 0,
|
|
||||||
description: 'Zusätzliche CLI-Parameter für Rip.',
|
|
||||||
defaultValue: '',
|
|
||||||
options: [],
|
|
||||||
validation: {},
|
|
||||||
orderIndex: 230
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'handbrake_command',
|
key: 'handbrake_command',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
@@ -231,31 +168,7 @@ const defaultSchema = [
|
|||||||
defaultValue: 'HandBrakeCLI',
|
defaultValue: 'HandBrakeCLI',
|
||||||
options: [],
|
options: [],
|
||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 300
|
orderIndex: 215
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'handbrake_preset',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'HandBrake Preset',
|
|
||||||
type: 'string',
|
|
||||||
required: 1,
|
|
||||||
description: 'Preset Name für -Z.',
|
|
||||||
defaultValue: 'H.264 MKV 1080p30',
|
|
||||||
options: [],
|
|
||||||
validation: { minLength: 1 },
|
|
||||||
orderIndex: 310
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'handbrake_extra_args',
|
|
||||||
category: 'Tools',
|
|
||||||
label: 'HandBrake Extra Args',
|
|
||||||
type: 'string',
|
|
||||||
required: 0,
|
|
||||||
description: 'Zusätzliche CLI-Argumente.',
|
|
||||||
defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1',
|
|
||||||
options: [],
|
|
||||||
validation: {},
|
|
||||||
orderIndex: 320
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'handbrake_restart_delete_incomplete_output',
|
key: 'handbrake_restart_delete_incomplete_output',
|
||||||
@@ -267,15 +180,102 @@ const defaultSchema = [
|
|||||||
defaultValue: 'true',
|
defaultValue: 'true',
|
||||||
options: [],
|
options: [],
|
||||||
validation: {},
|
validation: {},
|
||||||
|
orderIndex: 220
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pipeline_max_parallel_jobs',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Parallele Jobs',
|
||||||
|
type: 'number',
|
||||||
|
required: 1,
|
||||||
|
description: 'Maximale Anzahl parallel laufender Jobs. Weitere Starts landen in der Queue.',
|
||||||
|
defaultValue: '1',
|
||||||
|
options: [],
|
||||||
|
validation: { min: 1, max: 12 },
|
||||||
|
orderIndex: 225
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mediainfo_extra_args_bluray',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Mediainfo Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für mediainfo (Blu-ray).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_rip_mode_bluray',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Rip Modus',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.',
|
||||||
|
defaultValue: 'backup',
|
||||||
|
options: [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'Backup', value: 'backup' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 305
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_analyze_extra_args_bluray',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Analyze Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 310
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_rip_extra_args_bluray',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Rip Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für Rip (Blu-ray).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 315
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_preset_bluray',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Preset',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Preset Name für -Z (Blu-ray).',
|
||||||
|
defaultValue: 'H.264 MKV 1080p30',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_extra_args_bluray',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Argumente (Blu-ray).',
|
||||||
|
defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
orderIndex: 325
|
orderIndex: 325
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'output_extension',
|
key: 'output_extension_bluray',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
label: 'Ausgabeformat',
|
label: 'Ausgabeformat',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
required: 1,
|
required: 1,
|
||||||
description: 'Dateiendung für finale Datei.',
|
description: 'Dateiendung für finale Datei (Blu-ray).',
|
||||||
defaultValue: 'mkv',
|
defaultValue: 'mkv',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'MKV', value: 'mkv' },
|
{ label: 'MKV', value: 'mkv' },
|
||||||
@@ -285,28 +285,142 @@ const defaultSchema = [
|
|||||||
orderIndex: 330
|
orderIndex: 330
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'filename_template',
|
key: 'filename_template_bluray',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
label: 'Dateiname Template',
|
label: 'Dateiname Template',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: 1,
|
required: 1,
|
||||||
description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId}.',
|
description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (Blu-ray).',
|
||||||
defaultValue: '${title} (${year})',
|
defaultValue: '${title} (${year})',
|
||||||
options: [],
|
options: [],
|
||||||
validation: { minLength: 1 },
|
validation: { minLength: 1 },
|
||||||
orderIndex: 340
|
orderIndex: 335
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'output_folder_template',
|
key: 'output_folder_template_bluray',
|
||||||
category: 'Tools',
|
category: 'Tools',
|
||||||
label: 'Ordnername Template',
|
label: 'Ordnername Template',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: 0,
|
required: 0,
|
||||||
description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template verwenden.',
|
description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (Blu-ray).',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
options: [],
|
options: [],
|
||||||
validation: {},
|
validation: {},
|
||||||
orderIndex: 345
|
orderIndex: 340
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mediainfo_extra_args_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Mediainfo Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für mediainfo (DVD).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_rip_mode_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Rip Modus',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'mkv: direkte MKV-Dateien; backup: vollständige Disc-Struktur im RAW-Ordner.',
|
||||||
|
defaultValue: 'mkv',
|
||||||
|
options: [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'Backup', value: 'backup' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 505
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_analyze_extra_args_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Analyze Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für Analyze (DVD).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 510
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'makemkv_rip_extra_args_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'MakeMKV Rip Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Parameter für Rip (DVD).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 515
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_preset_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Preset',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Preset Name für -Z (DVD).',
|
||||||
|
defaultValue: 'H.264 MKV 480p30',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 520
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handbrake_extra_args_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'HandBrake Extra Args',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Zusätzliche CLI-Argumente (DVD).',
|
||||||
|
defaultValue: '--audio-lang-list deu,eng --first-audio --subtitle-lang-list deu,eng --first-subtitle --aencoder copy --audio-copy-mask ac3,eac3,dts --audio-fallback ac3 --encoder-preset slow --quality 18 --encoder-tune film --encoder-profile high --encoder-level 4.1',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 525
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'output_extension_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Ausgabeformat',
|
||||||
|
type: 'select',
|
||||||
|
required: 1,
|
||||||
|
description: 'Dateiendung für finale Datei (DVD).',
|
||||||
|
defaultValue: 'mkv',
|
||||||
|
options: [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'MP4', value: 'mp4' }
|
||||||
|
],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 530
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'filename_template_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Dateiname Template',
|
||||||
|
type: 'string',
|
||||||
|
required: 1,
|
||||||
|
description: 'Verfügbare Tokens: ${title}, ${year}, ${imdbId} (DVD).',
|
||||||
|
defaultValue: '${title} (${year})',
|
||||||
|
options: [],
|
||||||
|
validation: { minLength: 1 },
|
||||||
|
orderIndex: 535
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'output_folder_template_dvd',
|
||||||
|
category: 'Tools',
|
||||||
|
label: 'Ordnername Template',
|
||||||
|
type: 'string',
|
||||||
|
required: 0,
|
||||||
|
description: 'Optional. Verfügbare Tokens: ${title}, ${year}, ${imdbId}. Leer = Dateiname-Template (DVD).',
|
||||||
|
defaultValue: '',
|
||||||
|
options: [],
|
||||||
|
validation: {},
|
||||||
|
orderIndex: 540
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'omdb_api_key',
|
key: 'omdb_api_key',
|
||||||
|
|||||||
@@ -20,7 +20,42 @@ function flattenDevices(nodes, acc = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSignature(info) {
|
function buildSignature(info) {
|
||||||
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}`;
|
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMediaProfile(rawValue) {
|
||||||
|
const value = String(rawValue || '').trim().toLowerCase();
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value === 'bluray' || value === 'blu-ray' || value === 'bd' || value === 'bdmv') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (value === 'dvd') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (value === 'disc' || value === 'other' || value === 'sonstiges' || value === 'cd') {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMediaProfileFromTextParts(parts) {
|
||||||
|
const markerText = (parts || [])
|
||||||
|
.map((value) => String(value || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (!markerText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
class DiskDetectionService extends EventEmitter {
|
class DiskDetectionService extends EventEmitter {
|
||||||
@@ -265,6 +300,14 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
const details = await this.getBlockDeviceInfo();
|
const details = await this.getBlockDeviceInfo();
|
||||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||||
|
|
||||||
|
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||||
|
discLabel,
|
||||||
|
label: match.label,
|
||||||
|
model: match.model,
|
||||||
|
fstype: match.fstype,
|
||||||
|
mountpoint: match.mountpoint
|
||||||
|
});
|
||||||
|
|
||||||
const detected = {
|
const detected = {
|
||||||
mode: 'explicit',
|
mode: 'explicit',
|
||||||
path: devicePath,
|
path: devicePath,
|
||||||
@@ -274,6 +317,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
discLabel: discLabel || null,
|
discLabel: discLabel || null,
|
||||||
mountpoint: match.mountpoint || null,
|
mountpoint: match.mountpoint || null,
|
||||||
fstype: match.fstype || null,
|
fstype: match.fstype || null,
|
||||||
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(match.name || devicePath)
|
index: this.guessDiscIndex(match.name || devicePath)
|
||||||
};
|
};
|
||||||
logger.debug('detect:explicit:success', { detected });
|
logger.debug('detect:explicit:success', { detected });
|
||||||
@@ -304,6 +348,14 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const discLabel = await this.getDiscLabel(path);
|
const discLabel = await this.getDiscLabel(path);
|
||||||
|
|
||||||
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
|
discLabel,
|
||||||
|
label: item.label,
|
||||||
|
model: item.model,
|
||||||
|
fstype: item.fstype,
|
||||||
|
mountpoint: item.mountpoint
|
||||||
|
});
|
||||||
|
|
||||||
const detected = {
|
const detected = {
|
||||||
mode: 'auto',
|
mode: 'auto',
|
||||||
path,
|
path,
|
||||||
@@ -313,6 +365,7 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
discLabel: discLabel || null,
|
discLabel: discLabel || null,
|
||||||
mountpoint: item.mountpoint || null,
|
mountpoint: item.mountpoint || null,
|
||||||
fstype: item.fstype || null,
|
fstype: item.fstype || null,
|
||||||
|
mediaProfile: mediaProfile || null,
|
||||||
index: this.guessDiscIndex(item.name)
|
index: this.guessDiscIndex(item.name)
|
||||||
};
|
};
|
||||||
logger.debug('detect:auto:success', { detected });
|
logger.debug('detect:auto:success', { detected });
|
||||||
@@ -372,6 +425,82 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async inferMediaProfile(devicePath, hints = {}) {
|
||||||
|
const explicit = normalizeMediaProfile(hints?.mediaProfile);
|
||||||
|
if (explicit) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hinted = inferMediaProfileFromTextParts([
|
||||||
|
hints?.discLabel,
|
||||||
|
hints?.label,
|
||||||
|
hints?.fstype
|
||||||
|
]);
|
||||||
|
if (hinted) {
|
||||||
|
return hinted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountpoint = String(hints?.mountpoint || '').trim();
|
||||||
|
if (mountpoint) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(`${mountpoint}/BDMV`)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(`${mountpoint}/VIDEO_TS`)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('blkid', ['-o', 'export', devicePath]);
|
||||||
|
const payload = {};
|
||||||
|
for (const line of String(stdout || '').split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf('=');
|
||||||
|
if (idx <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = String(line.slice(0, idx)).trim().toUpperCase();
|
||||||
|
const value = String(line.slice(idx + 1)).trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
payload[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byBlkidMarker = inferMediaProfileFromTextParts([
|
||||||
|
payload.LABEL,
|
||||||
|
payload.TYPE,
|
||||||
|
payload.VERSION
|
||||||
|
]);
|
||||||
|
if (byBlkidMarker) {
|
||||||
|
return byBlkidMarker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = String(payload.TYPE || '').trim().toLowerCase();
|
||||||
|
if (type === 'udf') {
|
||||||
|
const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.'));
|
||||||
|
if (Number.isFinite(version)) {
|
||||||
|
return version >= 2 ? 'bluray' : 'dvd';
|
||||||
|
}
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('infer-media-profile:blkid-failed', {
|
||||||
|
devicePath,
|
||||||
|
error: errorToMeta(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
guessDiscIndex(name) {
|
guessDiscIndex(name) {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -135,27 +135,103 @@ function hasBlurayStructure(rawPath) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDvdStructure(rawPath) {
|
||||||
|
const basePath = String(rawPath || '').trim();
|
||||||
|
if (!basePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoTsPath = path.join(basePath, 'VIDEO_TS');
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(videoTsPath)) {
|
||||||
|
const stat = fs.statSync(videoTsPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(basePath)) {
|
||||||
|
const stat = fs.statSync(basePath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const entries = fs.readdirSync(basePath);
|
||||||
|
if (entries.some((entry) => /^vts_\d{2}_\d\.(ifo|vob|bup)$/i.test(entry) || /^video_ts\.(ifo|vob|bup)$/i.test(entry))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
return /(^|\/)video_ts\/.+\.(ifo|vob|bup)$/i.test(basePath) || /\.(ifo|vob|bup)$/i.test(basePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors and fallback to path checks
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/(^|\/)video_ts(\/|$)/i.test(basePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMediaTypeValue(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (raw === 'dvd') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
||||||
const mkInfo = parseInfoFromValue(makemkvInfo, null);
|
const mkInfo = parseInfoFromValue(makemkvInfo, null);
|
||||||
const miInfo = parseInfoFromValue(mediainfoInfo, null);
|
const miInfo = parseInfoFromValue(mediainfoInfo, null);
|
||||||
const plan = parseInfoFromValue(encodePlan, null);
|
const plan = parseInfoFromValue(encodePlan, null);
|
||||||
const rawPath = String(job?.raw_path || '').trim();
|
const rawPath = String(job?.raw_path || '').trim();
|
||||||
const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim();
|
const encodeInputPath = String(job?.encode_input_path || plan?.encodeInputPath || '').trim();
|
||||||
|
const profileHint = normalizeMediaTypeValue(
|
||||||
|
plan?.mediaProfile
|
||||||
|
|| mkInfo?.analyzeContext?.mediaProfile
|
||||||
|
|| mkInfo?.mediaProfile
|
||||||
|
|| miInfo?.mediaProfile
|
||||||
|
|| job?.media_type
|
||||||
|
|| job?.mediaType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profileHint === 'bluray' || profileHint === 'dvd') {
|
||||||
|
return profileHint;
|
||||||
|
}
|
||||||
|
|
||||||
if (hasBlurayStructure(rawPath)) {
|
if (hasBlurayStructure(rawPath)) {
|
||||||
return 'bluray';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
|
if (hasDvdStructure(rawPath)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
const mkSource = String(mkInfo?.source || '').trim().toLowerCase();
|
const mkSource = String(mkInfo?.source || '').trim().toLowerCase();
|
||||||
const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase();
|
const mkRipMode = String(mkInfo?.ripMode || mkInfo?.rip_mode || '').trim().toLowerCase();
|
||||||
if (
|
if (Boolean(mkInfo?.analyzeContext?.playlistAnalysis)) {
|
||||||
mkRipMode === 'backup'
|
|
||||||
|| mkSource.includes('backup')
|
|
||||||
|| mkSource.includes('raw_backup')
|
|
||||||
|| Boolean(mkInfo?.analyzeContext?.playlistAnalysis)
|
|
||||||
) {
|
|
||||||
return 'bluray';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
|
if (mkRipMode === 'backup' || mkSource.includes('backup') || mkSource.includes('raw_backup')) {
|
||||||
|
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const planMode = String(plan?.mode || '').trim().toLowerCase();
|
const planMode = String(plan?.mode || '').trim().toLowerCase();
|
||||||
if (planMode === 'pre_rip' || Boolean(plan?.preRip)) {
|
if (planMode === 'pre_rip' || Boolean(plan?.preRip)) {
|
||||||
@@ -163,9 +239,17 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase();
|
const mediainfoSource = String(miInfo?.source || '').trim().toLowerCase();
|
||||||
if (mediainfoSource.includes('raw_backup') || Number(miInfo?.handbrakeTitleId) > 0) {
|
if (Number(miInfo?.handbrakeTitleId) > 0) {
|
||||||
return 'bluray';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
|
if (mediainfoSource.includes('raw_backup')) {
|
||||||
|
if (hasDvdStructure(rawPath) || hasDvdStructure(encodeInputPath)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (hasBlurayStructure(rawPath) || hasBlurayStructure(encodeInputPath)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
/(^|\/)bdmv(\/|$)/i.test(rawPath)
|
/(^|\/)bdmv(\/|$)/i.test(rawPath)
|
||||||
@@ -174,8 +258,15 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan) {
|
|||||||
) {
|
) {
|
||||||
return 'bluray';
|
return 'bluray';
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
/(^|\/)video_ts(\/|$)/i.test(rawPath)
|
||||||
|
|| /(^|\/)video_ts(\/|$)/i.test(encodeInputPath)
|
||||||
|
|| /\.(ifo|vob|bup)(\.|$)/i.test(encodeInputPath)
|
||||||
|
) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
|
||||||
return 'disc';
|
return profileHint || 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProcessLogPath(jobId) {
|
function toProcessLogPath(jobId) {
|
||||||
@@ -199,11 +290,39 @@ function toProcessLogStreamKey(jobId) {
|
|||||||
return String(Math.trunc(normalizedId));
|
return String(Math.trunc(normalizedId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function enrichJobRow(job) {
|
function resolveEffectiveRawPath(storedPath, rawDir) {
|
||||||
const rawStatus = inspectDirectory(job.raw_path);
|
const stored = String(storedPath || '').trim();
|
||||||
const outputStatus = inspectOutputFile(job.output_path);
|
if (!stored || !rawDir) return stored;
|
||||||
const movieDir = job.output_path ? path.dirname(job.output_path) : null;
|
const folderName = path.basename(stored);
|
||||||
const movieDirStatus = inspectDirectory(movieDir);
|
if (!folderName) return stored;
|
||||||
|
return path.join(String(rawDir).trim(), folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveOutputPath(storedPath, movieDir) {
|
||||||
|
const stored = String(storedPath || '').trim();
|
||||||
|
if (!stored || !movieDir) return stored;
|
||||||
|
// output_path structure: {movie_dir}/{folderName}/{fileName}
|
||||||
|
const fileName = path.basename(stored);
|
||||||
|
const folderName = path.basename(path.dirname(stored));
|
||||||
|
if (!fileName || !folderName || folderName === '.') return stored;
|
||||||
|
return path.join(String(movieDir).trim(), folderName, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichJobRow(job, settings = null) {
|
||||||
|
const rawDir = String(settings?.raw_dir || '').trim();
|
||||||
|
const movieDir = String(settings?.movie_dir || '').trim();
|
||||||
|
|
||||||
|
const effectiveRawPath = rawDir && job.raw_path
|
||||||
|
? resolveEffectiveRawPath(job.raw_path, rawDir)
|
||||||
|
: (job.raw_path || null);
|
||||||
|
const effectiveOutputPath = movieDir && job.output_path
|
||||||
|
? resolveEffectiveOutputPath(job.output_path, movieDir)
|
||||||
|
: (job.output_path || null);
|
||||||
|
|
||||||
|
const rawStatus = inspectDirectory(effectiveRawPath);
|
||||||
|
const outputStatus = inspectOutputFile(effectiveOutputPath);
|
||||||
|
const movieDirPath = effectiveOutputPath ? path.dirname(effectiveOutputPath) : null;
|
||||||
|
const movieDirStatus = inspectDirectory(movieDirPath);
|
||||||
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
|
const makemkvInfo = parseJsonSafe(job.makemkv_info_json, null);
|
||||||
const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null);
|
const handbrakeInfo = parseJsonSafe(job.handbrake_info_json, null);
|
||||||
const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null);
|
const mediainfoInfo = parseJsonSafe(job.mediainfo_info_json, null);
|
||||||
@@ -215,6 +334,8 @@ function enrichJobRow(job) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
|
raw_path: effectiveRawPath,
|
||||||
|
output_path: effectiveOutputPath,
|
||||||
makemkvInfo,
|
makemkvInfo,
|
||||||
handbrakeInfo,
|
handbrakeInfo,
|
||||||
mediainfoInfo,
|
mediainfoInfo,
|
||||||
@@ -547,7 +668,8 @@ class HistoryService {
|
|||||||
|
|
||||||
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
const jobs = await db.all(
|
const [jobs, settings] = await Promise.all([
|
||||||
|
db.all(
|
||||||
`
|
`
|
||||||
SELECT j.*
|
SELECT j.*
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -556,10 +678,12 @@ class HistoryService {
|
|||||||
LIMIT 500
|
LIMIT 500
|
||||||
`,
|
`,
|
||||||
values
|
values
|
||||||
);
|
),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
|
|
||||||
return jobs.map((job) => ({
|
return jobs.map((job) => ({
|
||||||
...enrichJobRow(job),
|
...enrichJobRow(job, settings),
|
||||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -575,57 +699,68 @@ class HistoryService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [rows, settings] = await Promise.all([
|
||||||
|
(async () => {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const placeholders = ids.map(() => '?').join(', ');
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
const rows = await db.all(
|
return db.all(`SELECT * FROM jobs WHERE id IN (${placeholders})`, ids);
|
||||||
`SELECT * FROM jobs WHERE id IN (${placeholders})`,
|
})(),
|
||||||
ids
|
settingsService.getSettingsMap()
|
||||||
);
|
]);
|
||||||
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
const byId = new Map(rows.map((row) => [Number(row.id), row]));
|
||||||
return ids
|
return ids
|
||||||
.map((id) => byId.get(id))
|
.map((id) => byId.get(id))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((job) => ({
|
.map((job) => ({
|
||||||
...enrichJobRow(job),
|
...enrichJobRow(job, settings),
|
||||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRunningJobs() {
|
async getRunningJobs() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
const [rows, settings] = await Promise.all([
|
||||||
|
db.all(
|
||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE status IN ('RIPPING', 'ENCODING')
|
WHERE status IN ('RIPPING', 'ENCODING')
|
||||||
ORDER BY updated_at ASC, id ASC
|
ORDER BY updated_at ASC, id ASC
|
||||||
`
|
`
|
||||||
);
|
),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
return rows.map((job) => ({
|
return rows.map((job) => ({
|
||||||
...enrichJobRow(job),
|
...enrichJobRow(job, settings),
|
||||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRunningEncodeJobs() {
|
async getRunningEncodeJobs() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
const [rows, settings] = await Promise.all([
|
||||||
|
db.all(
|
||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE status = 'ENCODING'
|
WHERE status = 'ENCODING'
|
||||||
ORDER BY updated_at ASC, id ASC
|
ORDER BY updated_at ASC, id ASC
|
||||||
`
|
`
|
||||||
);
|
),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
return rows.map((job) => ({
|
return rows.map((job) => ({
|
||||||
...enrichJobRow(job),
|
...enrichJobRow(job, settings),
|
||||||
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobWithLogs(jobId, options = {}) {
|
async getJobWithLogs(jobId, options = {}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const job = await db.get('SELECT * FROM jobs WHERE id = ?', [jobId]);
|
const [job, settings] = await Promise.all([
|
||||||
|
db.get('SELECT * FROM jobs WHERE id = ?', [jobId]),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -643,7 +778,7 @@ class HistoryService {
|
|||||||
|
|
||||||
if (!shouldLoadLogs) {
|
if (!shouldLoadLogs) {
|
||||||
return {
|
return {
|
||||||
...enrichJobRow(job),
|
...enrichJobRow(job, settings),
|
||||||
log_count: baseLogCount,
|
log_count: baseLogCount,
|
||||||
logs: [],
|
logs: [],
|
||||||
log: '',
|
log: '',
|
||||||
@@ -662,7 +797,7 @@ class HistoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...enrichJobRow(job),
|
...enrichJobRow(job, settings),
|
||||||
log_count: processLog.exists ? processLog.total : 0,
|
log_count: processLog.exists ? processLog.total : 0,
|
||||||
logs: [],
|
logs: [],
|
||||||
log: processLog.lines.join('\n'),
|
log: processLog.lines.join('\n'),
|
||||||
@@ -909,7 +1044,7 @@ class HistoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imported = await this.getJobById(created.id);
|
const imported = await this.getJobById(created.id);
|
||||||
return enrichJobRow(imported);
|
return enrichJobRow(imported, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignOmdbMetadata(jobId, payload = {}) {
|
async assignOmdbMetadata(jobId, payload = {}) {
|
||||||
@@ -967,8 +1102,11 @@ class HistoryService {
|
|||||||
: `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"`
|
: `Metadaten manuell aktualisiert: title="${title || '-'}", year="${year || '-'}", imdb="${imdbId || '-'}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.getJobById(jobId);
|
const [updated, settings] = await Promise.all([
|
||||||
return enrichJobRow(updated);
|
this.getJobById(jobId),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
|
return enrichJobRow(updated, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteJobFiles(jobId, target = 'both') {
|
async deleteJobFiles(jobId, target = 'both') {
|
||||||
@@ -987,6 +1125,12 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
|
const effectiveRawPath = settings.raw_dir && job.raw_path
|
||||||
|
? resolveEffectiveRawPath(job.raw_path, settings.raw_dir)
|
||||||
|
: job.raw_path;
|
||||||
|
const effectiveOutputPath = settings.movie_dir && job.output_path
|
||||||
|
? resolveEffectiveOutputPath(job.output_path, settings.movie_dir)
|
||||||
|
: job.output_path;
|
||||||
const summary = {
|
const summary = {
|
||||||
target,
|
target,
|
||||||
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
||||||
@@ -995,16 +1139,16 @@ class HistoryService {
|
|||||||
|
|
||||||
if (target === 'raw' || target === 'both') {
|
if (target === 'raw' || target === 'both') {
|
||||||
summary.raw.attempted = true;
|
summary.raw.attempted = true;
|
||||||
if (!job.raw_path) {
|
if (!effectiveRawPath) {
|
||||||
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
|
summary.raw.reason = 'Kein raw_path im Job gesetzt.';
|
||||||
} else if (!isPathInside(settings.raw_dir, job.raw_path)) {
|
} else if (!isPathInside(settings.raw_dir, effectiveRawPath)) {
|
||||||
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${job.raw_path}`);
|
const error = new Error(`RAW-Pfad liegt außerhalb von raw_dir: ${effectiveRawPath}`);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!fs.existsSync(job.raw_path)) {
|
} else if (!fs.existsSync(effectiveRawPath)) {
|
||||||
summary.raw.reason = 'RAW-Pfad existiert nicht.';
|
summary.raw.reason = 'RAW-Pfad existiert nicht.';
|
||||||
} else {
|
} else {
|
||||||
const result = deleteFilesRecursively(job.raw_path, true);
|
const result = deleteFilesRecursively(effectiveRawPath, true);
|
||||||
summary.raw.deleted = true;
|
summary.raw.deleted = true;
|
||||||
summary.raw.filesDeleted = result.filesDeleted;
|
summary.raw.filesDeleted = result.filesDeleted;
|
||||||
summary.raw.dirsRemoved = result.dirsRemoved;
|
summary.raw.dirsRemoved = result.dirsRemoved;
|
||||||
@@ -1013,16 +1157,16 @@ class HistoryService {
|
|||||||
|
|
||||||
if (target === 'movie' || target === 'both') {
|
if (target === 'movie' || target === 'both') {
|
||||||
summary.movie.attempted = true;
|
summary.movie.attempted = true;
|
||||||
if (!job.output_path) {
|
if (!effectiveOutputPath) {
|
||||||
summary.movie.reason = 'Kein output_path im Job gesetzt.';
|
summary.movie.reason = 'Kein output_path im Job gesetzt.';
|
||||||
} else if (!isPathInside(settings.movie_dir, job.output_path)) {
|
} else if (!isPathInside(settings.movie_dir, effectiveOutputPath)) {
|
||||||
const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${job.output_path}`);
|
const error = new Error(`Movie-Pfad liegt außerhalb von movie_dir: ${effectiveOutputPath}`);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
throw error;
|
||||||
} else if (!fs.existsSync(job.output_path)) {
|
} else if (!fs.existsSync(effectiveOutputPath)) {
|
||||||
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
||||||
} else {
|
} else {
|
||||||
const outputPath = normalizeComparablePath(job.output_path);
|
const outputPath = normalizeComparablePath(effectiveOutputPath);
|
||||||
const movieRoot = normalizeComparablePath(settings.movie_dir);
|
const movieRoot = normalizeComparablePath(settings.movie_dir);
|
||||||
const stat = fs.lstatSync(outputPath);
|
const stat = fs.lstatSync(outputPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
@@ -1061,10 +1205,13 @@ class HistoryService {
|
|||||||
);
|
);
|
||||||
logger.info('job:delete-files', { jobId, summary });
|
logger.info('job:delete-files', { jobId, summary });
|
||||||
|
|
||||||
const updated = await this.getJobById(jobId);
|
const [updated, enrichSettings] = await Promise.all([
|
||||||
|
this.getJobById(jobId),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
summary,
|
summary,
|
||||||
job: enrichJobRow(updated)
|
job: enrichJobRow(updated, enrichSettings)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,45 @@ const SUBTITLE_SELECTION_KEYS_FLAG_ONLY = new Set(['--all-subtitles', '--first-s
|
|||||||
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
const SUBTITLE_FLAG_KEYS_WITH_VALUE = new Set(['--subtitle-burned', '--subtitle-default', '--subtitle-forced']);
|
||||||
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
const TITLE_SELECTION_KEYS_WITH_VALUE = new Set(['-t', '--title']);
|
||||||
const LOG_DIR_SETTING_KEY = 'log_dir';
|
const LOG_DIR_SETTING_KEY = 'log_dir';
|
||||||
|
const MEDIA_PROFILES = ['bluray', 'dvd', 'other'];
|
||||||
|
const PROFILED_SETTINGS = {
|
||||||
|
mediainfo_extra_args: {
|
||||||
|
bluray: 'mediainfo_extra_args_bluray',
|
||||||
|
dvd: 'mediainfo_extra_args_dvd'
|
||||||
|
},
|
||||||
|
makemkv_rip_mode: {
|
||||||
|
bluray: 'makemkv_rip_mode_bluray',
|
||||||
|
dvd: 'makemkv_rip_mode_dvd'
|
||||||
|
},
|
||||||
|
makemkv_analyze_extra_args: {
|
||||||
|
bluray: 'makemkv_analyze_extra_args_bluray',
|
||||||
|
dvd: 'makemkv_analyze_extra_args_dvd'
|
||||||
|
},
|
||||||
|
makemkv_rip_extra_args: {
|
||||||
|
bluray: 'makemkv_rip_extra_args_bluray',
|
||||||
|
dvd: 'makemkv_rip_extra_args_dvd'
|
||||||
|
},
|
||||||
|
handbrake_preset: {
|
||||||
|
bluray: 'handbrake_preset_bluray',
|
||||||
|
dvd: 'handbrake_preset_dvd'
|
||||||
|
},
|
||||||
|
handbrake_extra_args: {
|
||||||
|
bluray: 'handbrake_extra_args_bluray',
|
||||||
|
dvd: 'handbrake_extra_args_dvd'
|
||||||
|
},
|
||||||
|
output_extension: {
|
||||||
|
bluray: 'output_extension_bluray',
|
||||||
|
dvd: 'output_extension_dvd'
|
||||||
|
},
|
||||||
|
filename_template: {
|
||||||
|
bluray: 'filename_template_bluray',
|
||||||
|
dvd: 'filename_template_dvd'
|
||||||
|
},
|
||||||
|
output_folder_template: {
|
||||||
|
bluray: 'output_folder_template_bluray',
|
||||||
|
dvd: 'output_folder_template_dvd'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function applyRuntimeLogDirSetting(rawValue) {
|
function applyRuntimeLogDirSetting(rawValue) {
|
||||||
const resolved = setLogRootDir(rawValue);
|
const resolved = setLogRootDir(rawValue);
|
||||||
@@ -183,6 +222,37 @@ function uniquePresetEntries(entries) {
|
|||||||
return unique;
|
return unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMediaProfileValue(value) {
|
||||||
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw === 'bluray' || raw === 'blu-ray' || raw === 'bd' || raw === 'bdmv') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (raw === 'dvd') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProfileFallbackOrder(profile) {
|
||||||
|
const normalized = normalizeMediaProfileValue(profile);
|
||||||
|
if (normalized === 'bluray') {
|
||||||
|
return ['bluray', 'dvd'];
|
||||||
|
}
|
||||||
|
if (normalized === 'dvd') {
|
||||||
|
return ['dvd', 'bluray'];
|
||||||
|
}
|
||||||
|
if (normalized === 'other') {
|
||||||
|
return ['dvd', 'bluray'];
|
||||||
|
}
|
||||||
|
return ['dvd', 'bluray'];
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePresetListLines(rawOutput) {
|
function normalizePresetListLines(rawOutput) {
|
||||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
@@ -358,6 +428,42 @@ class SettingsService {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeMediaProfile(value) {
|
||||||
|
return normalizeMediaProfileValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveEffectiveToolSettings(settingsMap = {}, mediaProfile = null) {
|
||||||
|
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
|
const fallbackOrder = resolveProfileFallbackOrder(mediaProfile);
|
||||||
|
const resolvedMediaProfile = normalizeMediaProfileValue(mediaProfile) || fallbackOrder[0] || 'dvd';
|
||||||
|
const effective = {
|
||||||
|
...sourceMap,
|
||||||
|
media_profile: resolvedMediaProfile
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [legacyKey, profileKeys] of Object.entries(PROFILED_SETTINGS)) {
|
||||||
|
let resolvedValue = sourceMap[legacyKey];
|
||||||
|
for (const profile of fallbackOrder) {
|
||||||
|
const profileKey = profileKeys?.[profile];
|
||||||
|
if (!profileKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sourceMap[profileKey] !== undefined) {
|
||||||
|
resolvedValue = sourceMap[profileKey];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effective[legacyKey] = resolvedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectiveSettingsMap(mediaProfile = null) {
|
||||||
|
const map = await this.getSettingsMap();
|
||||||
|
return this.resolveEffectiveToolSettings(map, mediaProfile);
|
||||||
|
}
|
||||||
|
|
||||||
async getFlatSettings() {
|
async getFlatSettings() {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
@@ -537,19 +643,24 @@ class SettingsService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildMakeMKVAnalyzeConfig(deviceInfo = null) {
|
async buildMakeMKVAnalyzeConfig(deviceInfo = null, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(
|
||||||
|
rawMap,
|
||||||
|
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||||
|
);
|
||||||
const cmd = map.makemkv_command;
|
const cmd = map.makemkv_command;
|
||||||
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo)];
|
const args = ['-r', 'info', this.resolveSourceArg(map, deviceInfo), ...splitArgs(map.makemkv_analyze_extra_args)];
|
||||||
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
|
logger.debug('cli:makemkv:analyze', { cmd, args, deviceInfo });
|
||||||
return { cmd, args };
|
return { cmd, args };
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) {
|
async buildMakeMKVAnalyzePathConfig(sourcePath, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||||
const cmd = map.makemkv_command;
|
const cmd = map.makemkv_command;
|
||||||
const sourceArg = `file:${sourcePath}`;
|
const sourceArg = `file:${sourcePath}`;
|
||||||
const args = ['-r', 'info', sourceArg];
|
const args = ['-r', 'info', sourceArg, ...splitArgs(map.makemkv_analyze_extra_args)];
|
||||||
const titleIdRaw = Number(options?.titleId);
|
const titleIdRaw = Number(options?.titleId);
|
||||||
// "makemkvcon info" supports only <source>; title filtering is done in app parser.
|
// "makemkvcon info" supports only <source>; title filtering is done in app parser.
|
||||||
logger.debug('cli:makemkv:analyze:path', {
|
logger.debug('cli:makemkv:analyze:path', {
|
||||||
@@ -562,7 +673,11 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) {
|
async buildMakeMKVRipConfig(rawJobDir, deviceInfo = null, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(
|
||||||
|
rawMap,
|
||||||
|
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||||
|
);
|
||||||
const cmd = map.makemkv_command;
|
const cmd = map.makemkv_command;
|
||||||
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||||||
? 'backup'
|
? 'backup'
|
||||||
@@ -579,12 +694,16 @@ class SettingsService {
|
|||||||
ignored: parsedExtra
|
ignored: parsedExtra
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
baseArgs = [
|
const normalizedProfile = normalizeMediaProfileValue(options?.mediaProfile || deviceInfo?.mediaProfile || null);
|
||||||
'backup',
|
const isDvd = normalizedProfile === 'dvd';
|
||||||
'--decrypt',
|
if (isDvd) {
|
||||||
sourceArg,
|
const isoBase = options?.isoOutputBase
|
||||||
rawJobDir
|
? path.join(rawJobDir, options.isoOutputBase)
|
||||||
];
|
: rawJobDir;
|
||||||
|
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', '--noscan', sourceArg, isoBase];
|
||||||
|
} else {
|
||||||
|
baseArgs = ['-r', '--progress=-same', 'backup', '--decrypt', sourceArg, rawJobDir];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
extra = parsedExtra;
|
extra = parsedExtra;
|
||||||
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||||
@@ -592,6 +711,7 @@ class SettingsService {
|
|||||||
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||||
if (hasExplicitTitle) {
|
if (hasExplicitTitle) {
|
||||||
baseArgs = [
|
baseArgs = [
|
||||||
|
'-r', '--progress=-same',
|
||||||
'mkv',
|
'mkv',
|
||||||
sourceArg,
|
sourceArg,
|
||||||
targetTitle,
|
targetTitle,
|
||||||
@@ -599,6 +719,7 @@ class SettingsService {
|
|||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
baseArgs = [
|
baseArgs = [
|
||||||
|
'-r', '--progress=-same',
|
||||||
'--minlength=' + Math.round(minLength * 60),
|
'--minlength=' + Math.round(minLength * 60),
|
||||||
'mkv',
|
'mkv',
|
||||||
sourceArg,
|
sourceArg,
|
||||||
@@ -637,8 +758,9 @@ class SettingsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildMediaInfoConfig(inputPath) {
|
async buildMediaInfoConfig(inputPath, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||||
const cmd = map.mediainfo_command || 'mediainfo';
|
const cmd = map.mediainfo_command || 'mediainfo';
|
||||||
const baseArgs = ['--Output=JSON'];
|
const baseArgs = ['--Output=JSON'];
|
||||||
const extra = splitArgs(map.mediainfo_extra_args);
|
const extra = splitArgs(map.mediainfo_extra_args);
|
||||||
@@ -648,7 +770,8 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async buildHandBrakeConfig(inputFile, outputFile, options = {}) {
|
async buildHandBrakeConfig(inputFile, outputFile, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||||
const cmd = map.handbrake_command;
|
const cmd = map.handbrake_command;
|
||||||
const rawTitleId = Number(options?.titleId);
|
const rawTitleId = Number(options?.titleId);
|
||||||
const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
|
const selectedTitleId = Number.isFinite(rawTitleId) && rawTitleId > 0
|
||||||
@@ -752,8 +875,12 @@ class SettingsService {
|
|||||||
return '/dev/sr0';
|
return '/dev/sr0';
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildHandBrakeScanConfig(deviceInfo = null) {
|
async buildHandBrakeScanConfig(deviceInfo = null, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(
|
||||||
|
rawMap,
|
||||||
|
options?.mediaProfile || deviceInfo?.mediaProfile || null
|
||||||
|
);
|
||||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||||
const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo);
|
const sourceArg = this.resolveHandBrakeSourceArg(map, deviceInfo);
|
||||||
// Match legacy rip.sh behavior: scan all titles, then decide in app logic.
|
// Match legacy rip.sh behavior: scan all titles, then decide in app logic.
|
||||||
@@ -767,7 +894,8 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async buildHandBrakeScanConfigForInput(inputPath, options = {}) {
|
async buildHandBrakeScanConfigForInput(inputPath, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||||
// RAW backup folders must be scanned as full BD source to get usable title list.
|
// RAW backup folders must be scanned as full BD source to get usable title list.
|
||||||
const rawTitleId = Number(options?.titleId);
|
const rawTitleId = Number(options?.titleId);
|
||||||
@@ -785,7 +913,8 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) {
|
async buildHandBrakePresetProfile(sampleInputPath = null, options = {}) {
|
||||||
const map = await this.getSettingsMap();
|
const rawMap = options?.settingsMap || await this.getSettingsMap();
|
||||||
|
const map = this.resolveEffectiveToolSettings(rawMap, options?.mediaProfile || null);
|
||||||
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
const cmd = map.handbrake_command || 'HandBrakeCLI';
|
||||||
const presetName = map.handbrake_preset || null;
|
const presetName = map.handbrake_preset || null;
|
||||||
const rawTitleId = Number(options?.titleId);
|
const rawTitleId = Number(options?.titleId);
|
||||||
@@ -917,10 +1046,12 @@ class SettingsService {
|
|||||||
|
|
||||||
async getHandBrakePresetOptions() {
|
async getHandBrakePresetOptions() {
|
||||||
const map = await this.getSettingsMap();
|
const map = await this.getSettingsMap();
|
||||||
const configuredPreset = String(map.handbrake_preset || '').trim();
|
const configuredPresets = uniqueOrderedValues([
|
||||||
const fallbackOptions = configuredPreset
|
map.handbrake_preset_bluray,
|
||||||
? [{ label: configuredPreset, value: configuredPreset }]
|
map.handbrake_preset_dvd,
|
||||||
: [];
|
map.handbrake_preset
|
||||||
|
]);
|
||||||
|
const fallbackOptions = configuredPresets.map((preset) => ({ label: preset, value: preset }));
|
||||||
const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim();
|
const rawCommand = String(map.handbrake_command || 'HandBrakeCLI').trim();
|
||||||
const commandTokens = splitArgs(rawCommand);
|
const commandTokens = splitArgs(rawCommand);
|
||||||
const cmd = commandTokens[0] || 'HandBrakeCLI';
|
const cmd = commandTokens[0] || 'HandBrakeCLI';
|
||||||
@@ -963,7 +1094,7 @@ class SettingsService {
|
|||||||
options: fallbackOptions
|
options: fallbackOptions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!configuredPreset) {
|
if (configuredPresets.length === 0) {
|
||||||
return {
|
return {
|
||||||
source: 'handbrake-cli',
|
source: 'handbrake-cli',
|
||||||
message: null,
|
message: null,
|
||||||
@@ -971,8 +1102,10 @@ class SettingsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasConfiguredPreset = options.some((option) => option.value === configuredPreset);
|
const missingConfiguredPresets = configuredPresets.filter(
|
||||||
if (hasConfiguredPreset) {
|
(preset) => !options.some((option) => option.value === preset)
|
||||||
|
);
|
||||||
|
if (missingConfiguredPresets.length === 0) {
|
||||||
return {
|
return {
|
||||||
source: 'handbrake-cli',
|
source: 'handbrake-cli',
|
||||||
message: null,
|
message: null,
|
||||||
@@ -982,8 +1115,11 @@ class SettingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'handbrake-cli',
|
source: 'handbrake-cli',
|
||||||
message: `Aktuell gesetztes Preset "${configuredPreset}" wurde in HandBrakeCLI -z nicht gefunden.`,
|
message: `Konfigurierte Presets wurden in HandBrakeCLI -z nicht gefunden: ${missingConfiguredPresets.join(', ')}`,
|
||||||
options: [{ label: configuredPreset, value: configuredPreset }, ...options]
|
options: [
|
||||||
|
...missingConfiguredPresets.map((preset) => ({ label: preset, value: preset })),
|
||||||
|
...options
|
||||||
|
]
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -32,16 +32,13 @@ function parseEta(line) {
|
|||||||
function parseMakeMkvProgress(line) {
|
function parseMakeMkvProgress(line) {
|
||||||
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
|
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
|
||||||
if (prgv) {
|
if (prgv) {
|
||||||
const a = Number(prgv[1]);
|
// Format: PRGV:current,total,max (official makemkv docs)
|
||||||
const b = Number(prgv[2]);
|
// progress = current / max
|
||||||
const c = Number(prgv[3]);
|
const current = Number(prgv[1]);
|
||||||
|
const max = Number(prgv[3]);
|
||||||
|
|
||||||
if (c > 0) {
|
if (max > 0) {
|
||||||
return { percent: clampPercent((a / c) * 100), eta: null };
|
return { percent: clampPercent((current / max) * 100), eta: null };
|
||||||
}
|
|
||||||
|
|
||||||
if (b > 0) {
|
|
||||||
return { percent: clampPercent((a / b) * 100), eta: null };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,16 +106,24 @@ mediainfo --Version
|
|||||||
|
|
||||||
Ripster benötigt ein physisches **DVD- oder Blu-ray-Laufwerk**.
|
Ripster benötigt ein physisches **DVD- oder Blu-ray-Laufwerk**.
|
||||||
|
|
||||||
!!! info "Blu-ray unter Linux"
|
!!! danger "LibDriveIO-Modus erforderlich"
|
||||||
Für Blu-ray-Ripping unter Linux wird zusätzlich `libaacs` benötigt. MakeMKV bringt jedoch eine eigene Entschlüsselung mit, daher ist dies in den meisten Fällen nicht erforderlich.
|
Das Laufwerk muss im **LibDriveIO-Modus** betrieben werden – MakeMKV greift direkt auf Rohdaten des Laufwerks zu. Ohne diesen Modus können verschlüsselte Blu-rays (insbesondere UHD) nicht gelesen werden.
|
||||||
|
|
||||||
|
Nicht alle Laufwerke unterstützen den direkten Zugriff. Eine Anleitung zur Einrichtung und Liste kompatibler Laufwerke findet sich im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=18856).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Laufwerk prüfen
|
# Laufwerk prüfen
|
||||||
ls /dev/sr*
|
ls /dev/sr*
|
||||||
# oder
|
# oder
|
||||||
lsblk | grep rom
|
lsblk | grep rom
|
||||||
|
|
||||||
|
# Laufwerk-Berechtigungen setzen (erforderlich für LibDriveIO)
|
||||||
|
sudo chmod a+rw /dev/sr0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! info "Blu-ray unter Linux"
|
||||||
|
MakeMKV bringt mit LibDriveIO eine eigene Entschlüsselung mit – externe Bibliotheken wie `libaacs` sind in der Regel nicht erforderlich.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OMDb API-Key
|
## OMDb API-Key
|
||||||
|
|||||||
@@ -66,6 +66,48 @@ Ripster's `progressParsers.js` parst diese Ausgabe für die Live-Fortschrittsanz
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## LibDriveIO-Modus (Pflicht)
|
||||||
|
|
||||||
|
!!! danger "Laufwerk muss im LibDriveIO-Modus betrieben werden"
|
||||||
|
MakeMKV greift auf Discs über **LibDriveIO** zu – eine Bibliothek, die direkt auf Rohdaten des Laufwerks zugreift und den Standard-OS-Treiber umgeht. Ohne diesen Modus kann MakeMKV verschlüsselte Blu-rays (insbesondere UHD) **nicht lesen**.
|
||||||
|
|
||||||
|
### Was ist LibDriveIO?
|
||||||
|
|
||||||
|
LibDriveIO ist MakeMKVs interne Treiberschicht für den direkten Laufwerkszugriff. Sie ermöglicht:
|
||||||
|
|
||||||
|
- Lesen von verschlüsselten Blu-ray-Sektoren (AACS, BD+, AACS2)
|
||||||
|
- Zugriff auf Disc-Strukturen, die über Standard-OS-APIs nicht erreichbar sind
|
||||||
|
- UHD-Blu-ray-Entschlüsselung ohne externe Bibliotheken
|
||||||
|
|
||||||
|
### Voraussetzungen für den LibDriveIO-Modus
|
||||||
|
|
||||||
|
Das Laufwerk muss **LibDriveIO-kompatibel** sein und entsprechend betrieben werden:
|
||||||
|
|
||||||
|
1. **Kompatibles Laufwerk** – Nicht alle Laufwerke unterstützen den Rohdatenzugriff. UHD-kompatible Laufwerke (z. B. LG, Pioneer bestimmter Firmware-Versionen) sind erforderlich.
|
||||||
|
|
||||||
|
2. **Laufwerk-Berechtigungen** – Der Prozess benötigt direkten Zugriff auf das Blockdevice:
|
||||||
|
```bash
|
||||||
|
sudo chmod a+rw /dev/sr0
|
||||||
|
# oder dauerhaft über udev-Regel
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Kein OS-seitiger Disc-Mount** – Das Laufwerk darf beim Ripping **nicht** durch das OS automatisch gemountet sein (AutoMount deaktivieren):
|
||||||
|
```bash
|
||||||
|
# Automount temporär deaktivieren (GNOME)
|
||||||
|
gsettings set org.gnome.desktop.media-handling automount false
|
||||||
|
```
|
||||||
|
|
||||||
|
### How-To: LibDriveIO einrichten
|
||||||
|
|
||||||
|
Die vollständige Anleitung zur Einrichtung und zu kompatiblen Laufwerken findet sich im offiziellen MakeMKV-Forum:
|
||||||
|
|
||||||
|
[:octicons-link-external-24: MakeMKV Forum – LibDriveIO How-To](https://www.makemkv.com/forum/viewtopic.php?t=18856){ .md-button }
|
||||||
|
|
||||||
|
!!! tip "Prüfen ob LibDriveIO aktiv ist"
|
||||||
|
In der MakeMKV-Ausgabe erscheint beim Laufwerkszugriff `LibDriveIO` statt `LibMMMBD`, wenn der direkte Modus aktiv ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MakeMKV-Lizenz
|
## MakeMKV-Lizenz
|
||||||
|
|
||||||
MakeMKV ist **Beta-Software** und kostenlos für den persönlichen Gebrauch während der Beta-Phase. Eine Beta-Lizenz ist regelmäßig im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=1053) verfügbar.
|
MakeMKV ist **Beta-Software** und kostenlos für den persönlichen Gebrauch während der Beta-Phase. Eine Beta-Lizenz ist regelmäßig im [MakeMKV-Forum](https://www.makemkv.com/forum/viewtopic.php?t=1053) verfügbar.
|
||||||
|
|||||||
10
frontend/src/assets/media-other.svg
Normal file
10
frontend/src/assets/media-other.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Other medium">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="othbg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#f4f1da"/>
|
||||||
|
<stop offset="100%" stop-color="#bcae73"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="30" fill="url(#othbg)"/>
|
||||||
|
<path d="M40 15v26.5c0 5.5-4 9.5-9.5 9.5C25.8 51 22 47.5 22 43c0-4.7 4-8.5 8.8-8.5 1.4 0 2.8.3 4.2 1V22.4l12-3.2V39c0 5.6-4 9.6-9.4 9.6-4.8 0-8.6-3.4-8.6-7.8 0-4.9 4.2-8.8 9.2-8.8 1 0 2 .2 2.8.5V15z" fill="#2f3440"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 575 B |
@@ -14,39 +14,41 @@ function normalizeSettingKey(value) {
|
|||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GENERAL_TOOL_KEYS = new Set([
|
||||||
|
'makemkv_command',
|
||||||
|
'makemkv_registration_key',
|
||||||
|
'makemkv_min_length_minutes',
|
||||||
|
'mediainfo_command',
|
||||||
|
'handbrake_command',
|
||||||
|
'handbrake_restart_delete_incomplete_output'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const HANDBRAKE_PRESET_SETTING_KEYS = new Set([
|
||||||
|
'handbrake_preset',
|
||||||
|
'handbrake_preset_bluray',
|
||||||
|
'handbrake_preset_dvd'
|
||||||
|
]);
|
||||||
|
|
||||||
function buildToolSections(settings) {
|
function buildToolSections(settings) {
|
||||||
const list = Array.isArray(settings) ? settings : [];
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
const definitions = [
|
const generalBucket = {
|
||||||
{
|
id: 'general',
|
||||||
id: 'makemkv',
|
title: 'General',
|
||||||
title: 'MakeMKV',
|
description: 'Gemeinsame Tool-Settings für alle Medien.',
|
||||||
description: 'Disc-Analyse und Rip-Einstellungen.',
|
|
||||||
match: (key) => key.startsWith('makemkv_')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mediainfo',
|
|
||||||
title: 'MediaInfo',
|
|
||||||
description: 'Track-Analyse und zusätzliche mediainfo Parameter.',
|
|
||||||
match: (key) => key.startsWith('mediainfo_')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'handbrake',
|
|
||||||
title: 'HandBrake',
|
|
||||||
description: 'Preset, Encoding-CLI und HandBrake-Optionen.',
|
|
||||||
match: (key) => key.startsWith('handbrake_')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'output',
|
|
||||||
title: 'Output',
|
|
||||||
description: 'Container-Format sowie Datei- und Ordnernamen-Template.',
|
|
||||||
match: (key) => key === 'output_extension' || key === 'filename_template' || key === 'output_folder_template'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const buckets = definitions.map((item) => ({
|
|
||||||
...item,
|
|
||||||
settings: []
|
settings: []
|
||||||
}));
|
};
|
||||||
|
const blurayBucket = {
|
||||||
|
id: 'bluray',
|
||||||
|
title: 'BluRay',
|
||||||
|
description: 'Profil-spezifische Settings für Blu-ray.',
|
||||||
|
settings: []
|
||||||
|
};
|
||||||
|
const dvdBucket = {
|
||||||
|
id: 'dvd',
|
||||||
|
title: 'DVD',
|
||||||
|
description: 'Profil-spezifische Settings für DVD.',
|
||||||
|
settings: []
|
||||||
|
};
|
||||||
const fallbackBucket = {
|
const fallbackBucket = {
|
||||||
id: 'other',
|
id: 'other',
|
||||||
title: 'Weitere Tool-Settings',
|
title: 'Weitere Tool-Settings',
|
||||||
@@ -56,20 +58,26 @@ function buildToolSections(settings) {
|
|||||||
|
|
||||||
for (const setting of list) {
|
for (const setting of list) {
|
||||||
const key = normalizeSettingKey(setting?.key);
|
const key = normalizeSettingKey(setting?.key);
|
||||||
let assigned = false;
|
if (GENERAL_TOOL_KEYS.has(key)) {
|
||||||
for (const bucket of buckets) {
|
generalBucket.settings.push(setting);
|
||||||
if (bucket.match(key)) {
|
continue;
|
||||||
bucket.settings.push(setting);
|
|
||||||
assigned = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (key.endsWith('_bluray')) {
|
||||||
|
blurayBucket.settings.push(setting);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key.endsWith('_dvd')) {
|
||||||
|
dvdBucket.settings.push(setting);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (!assigned) {
|
|
||||||
fallbackBucket.settings.push(setting);
|
fallbackBucket.settings.push(setting);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const sections = buckets.filter((item) => item.settings.length > 0);
|
const sections = [
|
||||||
|
generalBucket,
|
||||||
|
blurayBucket,
|
||||||
|
dvdBucket
|
||||||
|
].filter((item) => item.settings.length > 0);
|
||||||
if (fallbackBucket.settings.length > 0) {
|
if (fallbackBucket.settings.length > 0) {
|
||||||
sections.push(fallbackBucket);
|
sections.push(fallbackBucket);
|
||||||
}
|
}
|
||||||
@@ -96,7 +104,8 @@ function buildSectionsForCategory(categoryName, settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isHandBrakePresetSetting(setting) {
|
function isHandBrakePresetSetting(setting) {
|
||||||
return String(setting?.key || '').trim().toLowerCase() === 'handbrake_preset';
|
const key = String(setting?.key || '').trim().toLowerCase();
|
||||||
|
return HANDBRAKE_PRESET_SETTING_KEYS.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DynamicSettingsForm({
|
export default function DynamicSettingsForm({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from 'primereact/button';
|
|||||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import { getStatusLabel } from '../utils/statusPresentation';
|
import { getStatusLabel } from '../utils/statusPresentation';
|
||||||
|
|
||||||
function JsonView({ title, value }) {
|
function JsonView({ title, value }) {
|
||||||
@@ -14,9 +15,54 @@ function JsonView({ title, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScriptResultRow({ result }) {
|
||||||
|
const status = String(result?.status || '').toUpperCase();
|
||||||
|
const isSuccess = status === 'SUCCESS';
|
||||||
|
const isError = status === 'ERROR';
|
||||||
|
const isSkipped = status.startsWith('SKIPPED');
|
||||||
|
const icon = isSuccess ? 'pi-check-circle' : isError ? 'pi-times-circle' : 'pi-minus-circle';
|
||||||
|
const tone = isSuccess ? 'success' : isError ? 'danger' : 'warning';
|
||||||
|
return (
|
||||||
|
<div className="script-result-row">
|
||||||
|
<span className={`job-step-inline-${isSuccess ? 'ok' : isError ? 'no' : 'warn'}`}>
|
||||||
|
<i className={`pi ${icon}`} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="script-result-name">{result?.scriptName || result?.chainName || `#${result?.scriptId ?? result?.chainId ?? '?'}`}</span>
|
||||||
|
<span className={`script-result-status tone-${tone}`}>{status}</span>
|
||||||
|
{result?.error ? <span className="script-result-error">{result.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScriptSummarySection({ title, summary }) {
|
||||||
|
if (!summary || summary.configured === 0) return null;
|
||||||
|
const results = Array.isArray(summary.results) ? summary.results : [];
|
||||||
|
return (
|
||||||
|
<div className="script-summary-block">
|
||||||
|
<strong>{title}:</strong>
|
||||||
|
<span className="script-summary-counts">
|
||||||
|
{summary.succeeded > 0 ? <span className="tone-success">{summary.succeeded} OK</span> : null}
|
||||||
|
{summary.failed > 0 ? <span className="tone-danger">{summary.failed} Fehler</span> : null}
|
||||||
|
{summary.skipped > 0 ? <span className="tone-warning">{summary.skipped} übersprungen</span> : null}
|
||||||
|
</span>
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<div className="script-result-list">
|
||||||
|
{results.map((r, i) => <ScriptResultRow key={i} result={r} />)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
if (raw === 'bluray') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (raw === 'dvd' || raw === 'disc') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeMeta(status, queued = false) {
|
function statusBadgeMeta(status, queued = false) {
|
||||||
@@ -132,9 +178,15 @@ export default function JobDetailDialog({
|
|||||||
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
const logLoaded = Boolean(logMeta?.loaded) || Boolean(job?.log);
|
||||||
const logTruncated = Boolean(logMeta?.truncated);
|
const logTruncated = Boolean(logMeta?.truncated);
|
||||||
const mediaType = resolveMediaType(job);
|
const mediaType = resolveMediaType(job);
|
||||||
const mediaTypeLabel = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
const mediaTypeLabel = mediaType === 'bluray'
|
||||||
const mediaTypeIcon = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
? 'Blu-ray'
|
||||||
const mediaTypeAlt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||||
|
const mediaTypeIcon = mediaType === 'bluray'
|
||||||
|
? blurayIndicatorIcon
|
||||||
|
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||||
|
const mediaTypeAlt = mediaType === 'bluray'
|
||||||
|
? 'Blu-ray'
|
||||||
|
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
|
|
||||||
@@ -267,6 +319,16 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
|
||||||
|
<section className="job-meta-block job-meta-block-full">
|
||||||
|
<h4>Skripte</h4>
|
||||||
|
<div className="script-results-grid">
|
||||||
|
<ScriptSummarySection title="Pre-Encode" summary={job.handbrakeInfo?.preEncodeScripts} />
|
||||||
|
<ScriptSummarySection title="Post-Encode" summary={job.handbrakeInfo?.postEncodeScripts} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="job-json-grid">
|
<div className="job-json-grid">
|
||||||
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
<JsonView title="OMDb Info" value={job.omdbInfo} />
|
||||||
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
<JsonView title="MakeMKV Info" value={job.makemkvInfo} />
|
||||||
|
|||||||
@@ -685,27 +685,19 @@ export default function MediaInfoReviewPanel({
|
|||||||
allowTrackSelection = false,
|
allowTrackSelection = false,
|
||||||
trackSelectionByTitle = {},
|
trackSelectionByTitle = {},
|
||||||
onTrackSelectionChange = null,
|
onTrackSelectionChange = null,
|
||||||
availablePostScripts = [],
|
availableScripts = [],
|
||||||
selectedPostEncodeScriptIds = [],
|
|
||||||
allowPostScriptSelection = false,
|
|
||||||
onAddPostEncodeScript = null,
|
|
||||||
onChangePostEncodeScript = null,
|
|
||||||
onRemovePostEncodeScript = null,
|
|
||||||
onReorderPostEncodeScript = null,
|
|
||||||
availablePreScripts = [],
|
|
||||||
selectedPreEncodeScriptIds = [],
|
|
||||||
allowPreScriptSelection = false,
|
|
||||||
onAddPreEncodeScript = null,
|
|
||||||
onChangePreEncodeScript = null,
|
|
||||||
onRemovePreEncodeScript = null,
|
|
||||||
availableChains = [],
|
availableChains = [],
|
||||||
selectedPreEncodeChainIds = [],
|
preEncodeItems = [],
|
||||||
selectedPostEncodeChainIds = [],
|
postEncodeItems = [],
|
||||||
allowChainSelection = false,
|
allowEncodeItemSelection = false,
|
||||||
onAddPreEncodeChain = null,
|
onAddPreEncodeItem = null,
|
||||||
onRemovePreEncodeChain = null,
|
onChangePreEncodeItem = null,
|
||||||
onAddPostEncodeChain = null,
|
onRemovePreEncodeItem = null,
|
||||||
onRemovePostEncodeChain = null
|
onReorderPreEncodeItem = null,
|
||||||
|
onAddPostEncodeItem = null,
|
||||||
|
onChangePostEncodeItem = null,
|
||||||
|
onRemovePostEncodeItem = null,
|
||||||
|
onReorderPostEncodeItem = null
|
||||||
}) {
|
}) {
|
||||||
if (!review) {
|
if (!review) {
|
||||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||||
@@ -718,30 +710,33 @@ export default function MediaInfoReviewPanel({
|
|||||||
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
||||||
const playlistRecommendation = review.playlistRecommendation || null;
|
const playlistRecommendation = review.playlistRecommendation || null;
|
||||||
const presetLabel = String(presetDisplayValue || review.selectors?.preset || '').trim() || '-';
|
const presetLabel = String(presetDisplayValue || review.selectors?.preset || '').trim() || '-';
|
||||||
const scriptRows = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
|
||||||
const scriptCatalog = (Array.isArray(availablePostScripts) ? availablePostScripts : [])
|
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: normalizeScriptId(item?.id),
|
id: normalizeScriptId(item?.id),
|
||||||
name: String(item?.name || '').trim()
|
name: String(item?.name || '').trim()
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.id !== null && item.name.length > 0);
|
.filter((item) => item.id !== null && item.name.length > 0);
|
||||||
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
|
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
|
||||||
const canAddScriptRow = allowPostScriptSelection && scriptCatalog.length > 0 && scriptRows.length < scriptCatalog.length;
|
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
|
||||||
const canReorderScriptRows = allowPostScriptSelection && scriptRows.length > 1;
|
.map((item) => ({ id: Number(item?.id), name: String(item?.name || '').trim() }))
|
||||||
|
.filter((item) => Number.isFinite(item.id) && item.id > 0 && item.name.length > 0);
|
||||||
|
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
|
||||||
|
|
||||||
const handleScriptDrop = (event, targetIndex) => {
|
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
|
||||||
if (!allowPostScriptSelection || typeof onReorderPostEncodeScript !== 'function') {
|
if (!allowEncodeItemSelection || typeof onReorder !== 'function' || items.length < 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const fromText = event.dataTransfer?.getData('text/plain');
|
const fromIndex = Number(event.dataTransfer?.getData('text/plain'));
|
||||||
const fromIndex = Number(fromText);
|
|
||||||
if (!Number.isInteger(fromIndex)) {
|
if (!Number.isInteger(fromIndex)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onReorderPostEncodeScript(fromIndex, targetIndex);
|
onReorder(fromIndex, targetIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
|
||||||
|
const handlePostDrop = makeHandleDrop(postEncodeItems, onReorderPostEncodeItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="media-review-wrap">
|
<div className="media-review-wrap">
|
||||||
<div className="media-review-meta">
|
<div className="media-review-meta">
|
||||||
@@ -780,215 +775,196 @@ export default function MediaInfoReviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Pre-Encode Scripts */}
|
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||||
{(allowPreScriptSelection || normalizeScriptIdList(selectedPreEncodeScriptIds).length > 0) ? (
|
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||||
<div className="post-script-box">
|
<div className="post-script-box">
|
||||||
<h4>Pre-Encode Scripte (optional)</h4>
|
<h4>Pre-Encode Ausführungen (optional)</h4>
|
||||||
{(Array.isArray(availablePreScripts) ? availablePreScripts : []).length === 0 ? (
|
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||||
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
|
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||||
) : null}
|
) : null}
|
||||||
{normalizeScriptIdList(selectedPreEncodeScriptIds).length === 0 ? (
|
{preEncodeItems.length === 0 ? (
|
||||||
<small>Keine Pre-Encode Scripte ausgewählt.</small>
|
<small>Keine Pre-Encode Ausführungen ausgewählt.</small>
|
||||||
) : null}
|
) : null}
|
||||||
{normalizeScriptIdList(selectedPreEncodeScriptIds).map((scriptId, rowIndex) => {
|
{preEncodeItems.map((item, rowIndex) => {
|
||||||
const preCatalog = (Array.isArray(availablePreScripts) ? availablePreScripts : [])
|
const isScript = item.type === 'script';
|
||||||
.map((item) => ({ id: normalizeScriptId(item?.id), name: String(item?.name || '') }))
|
const canDrag = allowEncodeItemSelection && preEncodeItems.length > 1;
|
||||||
.filter((item) => item.id !== null);
|
const scriptObj = isScript ? (scriptById.get(normalizeScriptId(item.id)) || null) : null;
|
||||||
const preById = new Map(preCatalog.map((item) => [item.id, item]));
|
const chainObj = !isScript ? (chainById.get(Number(item.id)) || null) : null;
|
||||||
const script = preById.get(scriptId) || null;
|
const name = isScript
|
||||||
const selectedElsewhere = new Set(
|
? (scriptObj?.name || `Skript #${item.id}`)
|
||||||
normalizeScriptIdList(selectedPreEncodeScriptIds).filter((_, i) => i !== rowIndex).map((id) => String(id))
|
: (chainObj?.name || `Kette #${item.id}`);
|
||||||
|
const usedScriptIds = new Set(
|
||||||
|
preEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
|
||||||
);
|
);
|
||||||
const options = preCatalog.map((item) => ({
|
const scriptOptions = scriptCatalog.map((s) => ({
|
||||||
label: item.name,
|
label: s.name,
|
||||||
value: item.id,
|
value: s.id,
|
||||||
disabled: selectedElsewhere.has(String(item.id))
|
disabled: usedScriptIds.has(String(s.id))
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<div key={`pre-script-row-${rowIndex}-${scriptId}`} className={`post-script-row${allowPreScriptSelection ? ' editable' : ''}`}>
|
|
||||||
{allowPreScriptSelection ? (
|
|
||||||
<>
|
|
||||||
<Dropdown
|
|
||||||
value={scriptId}
|
|
||||||
options={options}
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
optionDisabled="disabled"
|
|
||||||
onChange={(event) => onChangePreEncodeScript?.(rowIndex, event.value)}
|
|
||||||
className="full-width"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon="pi pi-times"
|
|
||||||
severity="danger"
|
|
||||||
outlined
|
|
||||||
onClick={() => onRemovePreEncodeScript?.(rowIndex)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{allowPreScriptSelection && (Array.isArray(availablePreScripts) ? availablePreScripts : []).length > normalizeScriptIdList(selectedPreEncodeScriptIds).length ? (
|
|
||||||
<Button
|
|
||||||
label="Pre-Script hinzufügen"
|
|
||||||
icon="pi pi-plus"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
onClick={() => onAddPreEncodeScript?.()}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<small>Diese Scripte werden vor dem Encoding ausgeführt. Bei Fehler wird der Encode abgebrochen.</small>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Chain Selections */}
|
|
||||||
{(allowChainSelection || selectedPreEncodeChainIds.length > 0 || selectedPostEncodeChainIds.length > 0) ? (
|
|
||||||
<div className="post-script-box">
|
|
||||||
<h4>Skriptketten (optional)</h4>
|
|
||||||
{(Array.isArray(availableChains) ? availableChains : []).length === 0 ? (
|
|
||||||
<small>Keine Skriptketten konfiguriert. In den Settings unter "Skriptketten" anlegen.</small>
|
|
||||||
) : null}
|
|
||||||
{(Array.isArray(availableChains) ? availableChains : []).length > 0 ? (
|
|
||||||
<div className="chain-selection-groups">
|
|
||||||
<div className="chain-selection-group">
|
|
||||||
<strong>Pre-Encode Ketten</strong>
|
|
||||||
{selectedPreEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
|
|
||||||
{selectedPreEncodeChainIds.map((chainId, index) => {
|
|
||||||
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
|
|
||||||
return (
|
|
||||||
<div key={`pre-chain-${index}-${chainId}`} className="post-script-row editable">
|
|
||||||
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
|
|
||||||
{allowChainSelection ? (
|
|
||||||
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeChain?.(index)} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{allowChainSelection ? (
|
|
||||||
<Dropdown
|
|
||||||
value={null}
|
|
||||||
options={(Array.isArray(availableChains) ? availableChains : [])
|
|
||||||
.filter((c) => !selectedPreEncodeChainIds.includes(Number(c.id)))
|
|
||||||
.map((c) => ({ label: c.name, value: c.id }))}
|
|
||||||
onChange={(e) => onAddPreEncodeChain?.(e.value)}
|
|
||||||
placeholder="Kette hinzufügen..."
|
|
||||||
className="chain-add-dropdown"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chain-selection-group">
|
|
||||||
<strong>Post-Encode Ketten</strong>
|
|
||||||
{selectedPostEncodeChainIds.length === 0 ? <small>Keine ausgewählt.</small> : null}
|
|
||||||
{selectedPostEncodeChainIds.map((chainId, index) => {
|
|
||||||
const chain = (Array.isArray(availableChains) ? availableChains : []).find((c) => Number(c.id) === chainId);
|
|
||||||
return (
|
|
||||||
<div key={`post-chain-${index}-${chainId}`} className="post-script-row editable">
|
|
||||||
<small>{`${index + 1}. ${chain?.name || `Kette #${chainId}`}`}</small>
|
|
||||||
{allowChainSelection ? (
|
|
||||||
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeChain?.(index)} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{allowChainSelection ? (
|
|
||||||
<Dropdown
|
|
||||||
value={null}
|
|
||||||
options={(Array.isArray(availableChains) ? availableChains : [])
|
|
||||||
.filter((c) => !selectedPostEncodeChainIds.includes(Number(c.id)))
|
|
||||||
.map((c) => ({ label: c.name, value: c.id }))}
|
|
||||||
onChange={(e) => onAddPostEncodeChain?.(e.value)}
|
|
||||||
placeholder="Kette hinzufügen..."
|
|
||||||
className="chain-add-dropdown"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="post-script-box">
|
|
||||||
<h4>Post-Encode Scripte (optional)</h4>
|
|
||||||
{scriptCatalog.length === 0 ? (
|
|
||||||
<small>Keine Scripte konfiguriert. In den Settings unter "Scripte" anlegen.</small>
|
|
||||||
) : null}
|
|
||||||
{scriptRows.length === 0 ? (
|
|
||||||
<small>Keine Post-Encode Scripte ausgewählt.</small>
|
|
||||||
) : null}
|
|
||||||
{scriptRows.map((scriptId, rowIndex) => {
|
|
||||||
const script = scriptById.get(scriptId) || null;
|
|
||||||
const selectedInOtherRows = new Set(
|
|
||||||
scriptRows.filter((id, index) => index !== rowIndex).map((id) => String(id))
|
|
||||||
);
|
|
||||||
const options = scriptCatalog.map((item) => ({
|
|
||||||
label: item.name,
|
|
||||||
value: item.id,
|
|
||||||
disabled: selectedInOtherRows.has(String(item.id))
|
|
||||||
}));
|
}));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`post-script-row-${rowIndex}-${scriptId}`}
|
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
|
||||||
className={`post-script-row${allowPostScriptSelection ? ' editable' : ''}`}
|
className={`post-script-row${allowEncodeItemSelection ? ' editable' : ''}`}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (!canReorderScriptRows) {
|
if (!canDrag) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => handleScriptDrop(event, rowIndex)}
|
onDrop={(event) => handlePreDrop(event, rowIndex)}
|
||||||
>
|
>
|
||||||
{allowPostScriptSelection ? (
|
{allowEncodeItemSelection ? (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className={`post-script-drag-handle pi pi-bars${canReorderScriptRows ? '' : ' disabled'}`}
|
className={`post-script-drag-handle pi pi-bars${canDrag ? '' : ' disabled'}`}
|
||||||
title={canReorderScriptRows ? 'Ziehen zum Umordnen' : 'Mindestens zwei Scripte zum Umordnen'}
|
title={canDrag ? 'Ziehen zum Umordnen' : 'Mindestens zwei Einträge zum Umordnen'}
|
||||||
draggable={canReorderScriptRows}
|
draggable={canDrag}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (!canReorderScriptRows) {
|
if (!canDrag) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.dataTransfer.setData('text/plain', String(rowIndex));
|
event.dataTransfer.setData('text/plain', String(rowIndex));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
|
||||||
|
{isScript ? (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={scriptId}
|
value={normalizeScriptId(item.id)}
|
||||||
options={options}
|
options={scriptOptions}
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
optionDisabled="disabled"
|
optionDisabled="disabled"
|
||||||
onChange={(event) => onChangePostEncodeScript?.(rowIndex, event.value)}
|
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'script', event.value)}
|
||||||
className="full-width"
|
className="full-width"
|
||||||
/>
|
/>
|
||||||
<Button
|
) : (
|
||||||
icon="pi pi-times"
|
<span className="post-script-chain-name">{name}</span>
|
||||||
severity="danger"
|
)}
|
||||||
outlined
|
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
|
||||||
onClick={() => onRemovePostEncodeScript?.(rowIndex)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<small>{`${rowIndex + 1}. ${script?.name || `Script #${scriptId}`}`}</small>
|
<small><i className={`pi ${isScript ? 'pi-code' : 'pi-link'}`} /> {`${rowIndex + 1}. ${name}`}</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{canAddScriptRow ? (
|
{allowEncodeItemSelection ? (
|
||||||
|
<div className="encode-item-add-row">
|
||||||
|
{scriptCatalog.length > preEncodeItems.filter((i) => i.type === 'script').length ? (
|
||||||
<Button
|
<Button
|
||||||
label="Script hinzufügen"
|
label="Skript hinzufügen"
|
||||||
icon="pi pi-plus"
|
icon="pi pi-code"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => onAddPostEncodeScript?.()}
|
onClick={() => onAddPreEncodeItem?.('script')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<small>Ausführung erfolgt nur nach erfolgreichem Encode, strikt nacheinander in genau dieser Reihenfolge (Drag-and-Drop möglich).</small>
|
{chainCatalog.length > preEncodeItems.filter((i) => i.type === 'chain').length ? (
|
||||||
|
<Button
|
||||||
|
label="Kette hinzufügen"
|
||||||
|
icon="pi pi-link"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => onAddPreEncodeItem?.('chain')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Post-Encode Items (scripts + chains unified) */}
|
||||||
|
<div className="post-script-box">
|
||||||
|
<h4>Post-Encode Ausführungen (optional)</h4>
|
||||||
|
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||||
|
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||||
|
) : null}
|
||||||
|
{postEncodeItems.length === 0 ? (
|
||||||
|
<small>Keine Post-Encode Ausführungen ausgewählt.</small>
|
||||||
|
) : null}
|
||||||
|
{postEncodeItems.map((item, rowIndex) => {
|
||||||
|
const isScript = item.type === 'script';
|
||||||
|
const canDrag = allowEncodeItemSelection && postEncodeItems.length > 1;
|
||||||
|
const scriptObj = isScript ? (scriptById.get(normalizeScriptId(item.id)) || null) : null;
|
||||||
|
const chainObj = !isScript ? (chainById.get(Number(item.id)) || null) : null;
|
||||||
|
const name = isScript
|
||||||
|
? (scriptObj?.name || `Skript #${item.id}`)
|
||||||
|
: (chainObj?.name || `Kette #${item.id}`);
|
||||||
|
const usedScriptIds = new Set(
|
||||||
|
postEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
|
||||||
|
);
|
||||||
|
const scriptOptions = scriptCatalog.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.id,
|
||||||
|
disabled: usedScriptIds.has(String(s.id))
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
|
||||||
|
className={`post-script-row${allowEncodeItemSelection ? ' editable' : ''}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!canDrag) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
}}
|
||||||
|
onDrop={(event) => handlePostDrop(event, rowIndex)}
|
||||||
|
>
|
||||||
|
{allowEncodeItemSelection ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`post-script-drag-handle pi pi-bars${canDrag ? '' : ' disabled'}`}
|
||||||
|
title={canDrag ? 'Ziehen zum Umordnen' : 'Mindestens zwei Einträge zum Umordnen'}
|
||||||
|
draggable={canDrag}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (!canDrag) return;
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(rowIndex));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
|
||||||
|
{isScript ? (
|
||||||
|
<Dropdown
|
||||||
|
value={normalizeScriptId(item.id)}
|
||||||
|
options={scriptOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
optionDisabled="disabled"
|
||||||
|
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'script', event.value)}
|
||||||
|
className="full-width"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="post-script-chain-name">{name}</span>
|
||||||
|
)}
|
||||||
|
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<small><i className={`pi ${isScript ? 'pi-code' : 'pi-link'}`} /> {`${rowIndex + 1}. ${name}`}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allowEncodeItemSelection ? (
|
||||||
|
<div className="encode-item-add-row">
|
||||||
|
{scriptCatalog.length > postEncodeItems.filter((i) => i.type === 'script').length ? (
|
||||||
|
<Button
|
||||||
|
label="Skript hinzufügen"
|
||||||
|
icon="pi pi-code"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => onAddPostEncodeItem?.('script')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{chainCatalog.length > postEncodeItems.filter((i) => i.type === 'chain').length ? (
|
||||||
|
<Button
|
||||||
|
label="Kette hinzufügen"
|
||||||
|
icon="pi pi-link"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => onAddPostEncodeItem?.('chain')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Titel</h4>
|
<h4>Titel</h4>
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ function normalizeScriptIdList(values) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChainId(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function isBurnedSubtitleTrack(track) {
|
function isBurnedSubtitleTrack(track) {
|
||||||
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
||||||
? track.subtitlePreviewFlags
|
? track.subtitlePreviewFlags
|
||||||
@@ -225,10 +233,9 @@ export default function PipelineStatusCard({
|
|||||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||||
const [scriptCatalog, setScriptCatalog] = useState([]);
|
const [scriptCatalog, setScriptCatalog] = useState([]);
|
||||||
const [chainCatalog, setChainCatalog] = useState([]);
|
const [chainCatalog, setChainCatalog] = useState([]);
|
||||||
const [selectedPostEncodeScriptIds, setSelectedPostEncodeScriptIds] = useState([]);
|
// Unified ordered lists: [{type: 'script'|'chain', id: number}]
|
||||||
const [selectedPreEncodeScriptIds, setSelectedPreEncodeScriptIds] = useState([]);
|
const [preEncodeItems, setPreEncodeItems] = useState([]);
|
||||||
const [selectedPostEncodeChainIds, setSelectedPostEncodeChainIds] = useState([]);
|
const [postEncodeItems, setPostEncodeItems] = useState([]);
|
||||||
const [selectedPreEncodeChainIds, setSelectedPreEncodeChainIds] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -282,20 +289,15 @@ export default function PipelineStatusCard({
|
|||||||
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
||||||
setSelectedEncodeTitleId(fromReview);
|
setSelectedEncodeTitleId(fromReview);
|
||||||
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
||||||
setSelectedPostEncodeScriptIds(
|
const normChain = (raw) => (Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
|
||||||
normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || [])
|
setPreEncodeItems([
|
||||||
);
|
...normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
||||||
setSelectedPreEncodeScriptIds(
|
...normChain(mediaInfoReview?.preEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
||||||
normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || [])
|
]);
|
||||||
);
|
setPostEncodeItems([
|
||||||
setSelectedPostEncodeChainIds(
|
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
||||||
(Array.isArray(mediaInfoReview?.postEncodeChainIds) ? mediaInfoReview.postEncodeChainIds : [])
|
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
||||||
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
]);
|
||||||
);
|
|
||||||
setSelectedPreEncodeChainIds(
|
|
||||||
(Array.isArray(mediaInfoReview?.preEncodeChainIds) ? mediaInfoReview.preEncodeChainIds : [])
|
|
||||||
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
|
||||||
);
|
|
||||||
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -454,17 +456,17 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const selectedPostScriptIds = normalizeScriptIdList(selectedPostEncodeScriptIds);
|
const selectedPostScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
|
||||||
const selectedPreScriptIds = normalizeScriptIdList(selectedPreEncodeScriptIds);
|
const selectedPreScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
|
||||||
const normalizeChainIdList = (raw) =>
|
const selectedPostChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
|
||||||
(Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
|
const selectedPreChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
|
||||||
return {
|
return {
|
||||||
encodeTitleId,
|
encodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
selectedPostScriptIds,
|
selectedPostScriptIds,
|
||||||
selectedPreScriptIds,
|
selectedPreScriptIds,
|
||||||
selectedPostChainIds: normalizeChainIdList(selectedPostEncodeChainIds),
|
selectedPostChainIds,
|
||||||
selectedPreChainIds: normalizeChainIdList(selectedPreEncodeChainIds)
|
selectedPreChainIds
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -776,143 +778,219 @@ export default function PipelineStatusCard({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
availablePostScripts={scriptCatalog}
|
availableScripts={scriptCatalog}
|
||||||
selectedPostEncodeScriptIds={selectedPostEncodeScriptIds}
|
availableChains={chainCatalog}
|
||||||
allowPostScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
preEncodeItems={preEncodeItems}
|
||||||
onAddPostEncodeScript={() => {
|
postEncodeItems={postEncodeItems}
|
||||||
setSelectedPostEncodeScriptIds((prev) => {
|
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
onAddPreEncodeItem={(itemType) => {
|
||||||
const selectedSet = new Set(normalizedCurrent.map((id) => String(id)));
|
setPreEncodeItems((prev) => {
|
||||||
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
|
if (itemType === 'chain') {
|
||||||
|
const selectedSet = new Set(
|
||||||
|
current
|
||||||
|
.filter((item) => item?.type === 'chain')
|
||||||
|
.map((item) => normalizeChainId(item?.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
|
);
|
||||||
|
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
|
||||||
|
.map((item) => normalizeChainId(item?.id))
|
||||||
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
||||||
|
if (nextCandidate === undefined || nextCandidate === null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return [...current, { type: 'chain', id: nextCandidate }];
|
||||||
|
}
|
||||||
|
const selectedSet = new Set(
|
||||||
|
current
|
||||||
|
.filter((item) => item?.type === 'script')
|
||||||
|
.map((item) => normalizeScriptId(item?.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
|
);
|
||||||
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
||||||
.map((item) => normalizeScriptId(item?.id))
|
.map((item) => normalizeScriptId(item?.id))
|
||||||
.find((id) => id !== null && !selectedSet.has(String(id)));
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
||||||
if (nextCandidate === undefined || nextCandidate === null) {
|
if (nextCandidate === undefined || nextCandidate === null) {
|
||||||
return normalizedCurrent;
|
return current;
|
||||||
}
|
}
|
||||||
return [...normalizedCurrent, nextCandidate];
|
return [...current, { type: 'script', id: nextCandidate }];
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onChangePostEncodeScript={(rowIndex, nextScriptId) => {
|
onChangePreEncodeItem={(rowIndex, itemType, nextId) => {
|
||||||
setSelectedPostEncodeScriptIds((prev) => {
|
setPreEncodeItems((prev) => {
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
const index = Number(rowIndex);
|
||||||
return normalizedCurrent;
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
const normalizedScriptId = normalizeScriptId(nextScriptId);
|
const type = itemType === 'chain' ? 'chain' : 'script';
|
||||||
if (normalizedScriptId === null) {
|
if (type === 'chain') {
|
||||||
return normalizedCurrent;
|
const normalizedId = normalizeChainId(nextId);
|
||||||
|
if (normalizedId === null) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
const duplicateAtOtherIndex = normalizedCurrent.some((id, idx) =>
|
const duplicate = current.some((item, idx) =>
|
||||||
idx !== rowIndex && String(id) === String(normalizedScriptId)
|
idx !== index
|
||||||
|
&& item?.type === 'chain'
|
||||||
|
&& String(normalizeChainId(item?.id)) === String(normalizedId)
|
||||||
);
|
);
|
||||||
if (duplicateAtOtherIndex) {
|
if (duplicate) {
|
||||||
return normalizedCurrent;
|
return current;
|
||||||
}
|
}
|
||||||
const next = [...normalizedCurrent];
|
const next = [...current];
|
||||||
next[rowIndex] = normalizedScriptId;
|
next[index] = { type: 'chain', id: normalizedId };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const normalizedId = normalizeScriptId(nextId);
|
||||||
|
if (normalizedId === null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const duplicate = current.some((item, idx) =>
|
||||||
|
idx !== index
|
||||||
|
&& item?.type === 'script'
|
||||||
|
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
next[index] = { type: 'script', id: normalizedId };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onRemovePostEncodeScript={(rowIndex) => {
|
onRemovePreEncodeItem={(rowIndex) => {
|
||||||
setSelectedPostEncodeScriptIds((prev) => {
|
setPreEncodeItems((prev) => {
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
const index = Number(rowIndex);
|
||||||
return normalizedCurrent;
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
|
return current.filter((_, idx) => idx !== index);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onReorderPostEncodeScript={(fromIndex, toIndex) => {
|
onReorderPreEncodeItem={(fromIndex, toIndex) => {
|
||||||
setSelectedPostEncodeScriptIds((prev) => {
|
setPreEncodeItems((prev) => {
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
const from = Number(fromIndex);
|
const from = Number(fromIndex);
|
||||||
const to = Number(toIndex);
|
const to = Number(toIndex);
|
||||||
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
||||||
return normalizedCurrent;
|
return current;
|
||||||
}
|
}
|
||||||
if (from < 0 || to < 0 || from >= normalizedCurrent.length || to >= normalizedCurrent.length) {
|
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
|
||||||
return normalizedCurrent;
|
return current;
|
||||||
}
|
}
|
||||||
if (from === to) {
|
const next = [...current];
|
||||||
return normalizedCurrent;
|
|
||||||
}
|
|
||||||
const next = [...normalizedCurrent];
|
|
||||||
const [moved] = next.splice(from, 1);
|
const [moved] = next.splice(from, 1);
|
||||||
next.splice(to, 0, moved);
|
next.splice(to, 0, moved);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
availablePreScripts={scriptCatalog}
|
onAddPostEncodeItem={(itemType) => {
|
||||||
selectedPreEncodeScriptIds={selectedPreEncodeScriptIds}
|
setPostEncodeItems((prev) => {
|
||||||
allowPreScriptSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
onAddPreEncodeScript={() => {
|
if (itemType === 'chain') {
|
||||||
setSelectedPreEncodeScriptIds((prev) => {
|
const selectedSet = new Set(
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
current
|
||||||
const selectedSet = new Set(normalizedCurrent.map((id) => String(id)));
|
.filter((item) => item?.type === 'chain')
|
||||||
|
.map((item) => normalizeChainId(item?.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
|
);
|
||||||
|
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
|
||||||
|
.map((item) => normalizeChainId(item?.id))
|
||||||
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
||||||
|
if (nextCandidate === undefined || nextCandidate === null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return [...current, { type: 'chain', id: nextCandidate }];
|
||||||
|
}
|
||||||
|
const selectedSet = new Set(
|
||||||
|
current
|
||||||
|
.filter((item) => item?.type === 'script')
|
||||||
|
.map((item) => normalizeScriptId(item?.id))
|
||||||
|
.filter((id) => id !== null)
|
||||||
|
.map((id) => String(id))
|
||||||
|
);
|
||||||
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
||||||
.map((item) => normalizeScriptId(item?.id))
|
.map((item) => normalizeScriptId(item?.id))
|
||||||
.find((id) => id !== null && !selectedSet.has(String(id)));
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
||||||
if (nextCandidate === undefined || nextCandidate === null) {
|
if (nextCandidate === undefined || nextCandidate === null) {
|
||||||
return normalizedCurrent;
|
return current;
|
||||||
}
|
}
|
||||||
return [...normalizedCurrent, nextCandidate];
|
return [...current, { type: 'script', id: nextCandidate }];
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onChangePreEncodeScript={(rowIndex, nextScriptId) => {
|
onChangePostEncodeItem={(rowIndex, itemType, nextId) => {
|
||||||
setSelectedPreEncodeScriptIds((prev) => {
|
setPostEncodeItems((prev) => {
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
const index = Number(rowIndex);
|
||||||
return normalizedCurrent;
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
const normalizedScriptId = normalizeScriptId(nextScriptId);
|
const type = itemType === 'chain' ? 'chain' : 'script';
|
||||||
if (normalizedScriptId === null) {
|
if (type === 'chain') {
|
||||||
return normalizedCurrent;
|
const normalizedId = normalizeChainId(nextId);
|
||||||
|
if (normalizedId === null) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
if (normalizedCurrent.some((id, idx) => idx !== rowIndex && String(id) === String(normalizedScriptId))) {
|
const duplicate = current.some((item, idx) =>
|
||||||
return normalizedCurrent;
|
idx !== index
|
||||||
|
&& item?.type === 'chain'
|
||||||
|
&& String(normalizeChainId(item?.id)) === String(normalizedId)
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
const next = [...normalizedCurrent];
|
const next = [...current];
|
||||||
next[rowIndex] = normalizedScriptId;
|
next[index] = { type: 'chain', id: normalizedId };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const normalizedId = normalizeScriptId(nextId);
|
||||||
|
if (normalizedId === null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const duplicate = current.some((item, idx) =>
|
||||||
|
idx !== index
|
||||||
|
&& item?.type === 'script'
|
||||||
|
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
next[index] = { type: 'script', id: normalizedId };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onRemovePreEncodeScript={(rowIndex) => {
|
onRemovePostEncodeItem={(rowIndex) => {
|
||||||
setSelectedPreEncodeScriptIds((prev) => {
|
setPostEncodeItems((prev) => {
|
||||||
const normalizedCurrent = normalizeScriptIdList(prev);
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
if (!Number.isFinite(Number(rowIndex)) || rowIndex < 0 || rowIndex >= normalizedCurrent.length) {
|
const index = Number(rowIndex);
|
||||||
return normalizedCurrent;
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
||||||
|
return current;
|
||||||
}
|
}
|
||||||
return normalizedCurrent.filter((_, idx) => idx !== rowIndex);
|
return current.filter((_, idx) => idx !== index);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
availableChains={chainCatalog}
|
onReorderPostEncodeItem={(fromIndex, toIndex) => {
|
||||||
selectedPreEncodeChainIds={selectedPreEncodeChainIds}
|
setPostEncodeItems((prev) => {
|
||||||
selectedPostEncodeChainIds={selectedPostEncodeChainIds}
|
const current = Array.isArray(prev) ? prev : [];
|
||||||
allowChainSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed && !queueLocked}
|
const from = Number(fromIndex);
|
||||||
onAddPreEncodeChain={(chainId) => {
|
const to = Number(toIndex);
|
||||||
setSelectedPreEncodeChainIds((prev) => {
|
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
||||||
const id = Number(chainId);
|
return current;
|
||||||
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
|
|
||||||
return prev;
|
|
||||||
}
|
}
|
||||||
return [...prev, id];
|
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
|
||||||
});
|
return current;
|
||||||
}}
|
|
||||||
onRemovePreEncodeChain={(index) => {
|
|
||||||
setSelectedPreEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
}}
|
|
||||||
onAddPostEncodeChain={(chainId) => {
|
|
||||||
setSelectedPostEncodeChainIds((prev) => {
|
|
||||||
const id = Number(chainId);
|
|
||||||
if (!Number.isFinite(id) || id <= 0 || prev.includes(id)) {
|
|
||||||
return prev;
|
|
||||||
}
|
}
|
||||||
return [...prev, id];
|
const next = [...current];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onRemovePostEncodeChain={(index) => {
|
|
||||||
setSelectedPostEncodeChainIds((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import PipelineStatusCard from '../components/PipelineStatusCard';
|
|||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
|
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'];
|
||||||
@@ -179,7 +180,13 @@ function getAnalyzeContext(job) {
|
|||||||
|
|
||||||
function resolveMediaType(job) {
|
function resolveMediaType(job) {
|
||||||
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
const raw = String(job?.mediaType || job?.media_type || '').trim().toLowerCase();
|
||||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
if (raw === 'bluray') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (raw === 'dvd' || raw === 'disc') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaIndicatorMeta(job) {
|
function mediaIndicatorMeta(job) {
|
||||||
@@ -191,11 +198,18 @@ function mediaIndicatorMeta(job) {
|
|||||||
alt: 'Blu-ray',
|
alt: 'Blu-ray',
|
||||||
title: 'Blu-ray'
|
title: 'Blu-ray'
|
||||||
}
|
}
|
||||||
: {
|
: mediaType === 'dvd'
|
||||||
|
? {
|
||||||
mediaType,
|
mediaType,
|
||||||
src: discIndicatorIcon,
|
src: discIndicatorIcon,
|
||||||
alt: 'Disc',
|
alt: 'DVD',
|
||||||
title: 'CD/sonstiges Medium'
|
title: 'DVD'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mediaType,
|
||||||
|
src: otherIndicatorIcon,
|
||||||
|
alt: 'Sonstiges Medium',
|
||||||
|
title: 'Sonstiges Medium'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import JobDetailDialog from '../components/JobDetailDialog';
|
|||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import {
|
import {
|
||||||
getStatusLabel,
|
getStatusLabel,
|
||||||
getStatusSeverity,
|
getStatusSeverity,
|
||||||
@@ -21,7 +22,13 @@ import {
|
|||||||
|
|
||||||
function resolveMediaType(row) {
|
function resolveMediaType(row) {
|
||||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
if (raw === 'bluray') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (raw === 'dvd' || raw === 'disc') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
@@ -573,10 +580,18 @@ export default function DatabasePage() {
|
|||||||
};
|
};
|
||||||
const mediaBody = (row) => {
|
const mediaBody = (row) => {
|
||||||
const mediaType = resolveMediaType(row);
|
const mediaType = resolveMediaType(row);
|
||||||
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
const src = mediaType === 'bluray'
|
||||||
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
? blurayIndicatorIcon
|
||||||
const title = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges Medium';
|
: (mediaType === 'dvd' ? discIndicatorIcon : otherIndicatorIcon);
|
||||||
const label = mediaType === 'bluray' ? 'Blu-ray' : 'Sonstiges';
|
const alt = mediaType === 'bluray'
|
||||||
|
? 'Blu-ray'
|
||||||
|
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||||
|
const title = mediaType === 'bluray'
|
||||||
|
? 'Blu-ray'
|
||||||
|
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
|
||||||
|
const label = mediaType === 'bluray'
|
||||||
|
? 'Blu-ray'
|
||||||
|
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges');
|
||||||
return (
|
return (
|
||||||
<span className="job-step-cell">
|
<span className="job-step-cell">
|
||||||
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
<img src={src} alt={alt} title={title} className="media-indicator-icon" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { DataTable } from 'primereact/datatable';
|
import { DataView, DataViewLayoutOptions } from 'primereact/dataview';
|
||||||
import { Column } from 'primereact/column';
|
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
@@ -11,17 +10,79 @@ import { api } from '../api/client';
|
|||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import {
|
import {
|
||||||
getStatusLabel,
|
getStatusLabel,
|
||||||
getStatusSeverity,
|
getStatusSeverity,
|
||||||
getProcessStatusLabel,
|
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
STATUS_FILTER_OPTIONS
|
STATUS_FILTER_OPTIONS
|
||||||
} from '../utils/statusPresentation';
|
} from '../utils/statusPresentation';
|
||||||
|
|
||||||
|
const MEDIA_FILTER_OPTIONS = [
|
||||||
|
{ label: 'Alle Medien', value: '' },
|
||||||
|
{ label: 'Blu-ray', value: 'bluray' },
|
||||||
|
{ label: 'DVD', value: 'dvd' },
|
||||||
|
{ label: 'Sonstiges', value: 'other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BASE_SORT_FIELD_OPTIONS = [
|
||||||
|
{ label: 'Startzeit', value: 'start_time' },
|
||||||
|
{ label: 'Endzeit', value: 'end_time' },
|
||||||
|
{ label: 'Titel', value: 'title' },
|
||||||
|
{ label: 'Medium', value: 'mediaType' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTIONAL_SORT_FIELD_OPTIONS = [
|
||||||
|
{ label: 'Keine', value: '' },
|
||||||
|
...BASE_SORT_FIELD_OPTIONS
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_DIRECTION_OPTIONS = [
|
||||||
|
{ label: 'Aufsteigend', value: 1 },
|
||||||
|
{ label: 'Absteigend', value: -1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEDIA_SORT_RANK = {
|
||||||
|
bluray: 0,
|
||||||
|
dvd: 1,
|
||||||
|
other: 2
|
||||||
|
};
|
||||||
|
|
||||||
function resolveMediaType(row) {
|
function resolveMediaType(row) {
|
||||||
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
const raw = String(row?.mediaType || row?.media_type || '').trim().toLowerCase();
|
||||||
return raw === 'bluray' ? 'bluray' : 'disc';
|
if (raw === 'bluray') {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (raw === 'dvd' || raw === 'disc') {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMediaTypeMeta(row) {
|
||||||
|
const mediaType = resolveMediaType(row);
|
||||||
|
if (mediaType === 'bluray') {
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
icon: blurayIndicatorIcon,
|
||||||
|
label: 'Blu-ray',
|
||||||
|
alt: 'Blu-ray'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mediaType === 'dvd') {
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
icon: discIndicatorIcon,
|
||||||
|
label: 'DVD',
|
||||||
|
alt: 'DVD'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
icon: otherIndicatorIcon,
|
||||||
|
label: 'Sonstiges',
|
||||||
|
alt: 'Sonstiges Medium'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
@@ -36,10 +97,126 @@ function getQueueActionResult(response) {
|
|||||||
return response?.result && typeof response.result === 'object' ? response.result : {};
|
return response?.result && typeof response.result === 'object' ? response.result : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSortText(value) {
|
||||||
|
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ts = new Date(value).getTime();
|
||||||
|
return Number.isFinite(ts) ? ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortValues(a, b) {
|
||||||
|
const aMissing = a === null || a === undefined || a === '';
|
||||||
|
const bMissing = b === null || b === undefined || b === '';
|
||||||
|
if (aMissing && bMissing) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (aMissing) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (bMissing) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof a === 'number' && typeof b === 'number') {
|
||||||
|
if (a === b) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return a > b ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(a).localeCompare(String(b), 'de', {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSortValue(row, field) {
|
||||||
|
switch (field) {
|
||||||
|
case 'start_time':
|
||||||
|
return normalizeSortDate(row?.start_time);
|
||||||
|
case 'end_time':
|
||||||
|
return normalizeSortDate(row?.end_time);
|
||||||
|
case 'title':
|
||||||
|
return normalizeSortText(row?.title || row?.detected_title || '');
|
||||||
|
case 'mediaType': {
|
||||||
|
const mediaType = resolveMediaType(row);
|
||||||
|
return MEDIA_SORT_RANK[mediaType] ?? MEDIA_SORT_RANK.other;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRating(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw || raw.toUpperCase() === 'N/A') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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 resolveRatings(row) {
|
||||||
|
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null;
|
||||||
|
if (!omdbInfo) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
return ratings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function HistoryPage() {
|
export default function HistoryPage() {
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
|
const [mediumFilter, setMediumFilter] = useState('');
|
||||||
|
const [layout, setLayout] = useState('list');
|
||||||
|
const [sortPrimaryField, setSortPrimaryField] = useState('start_time');
|
||||||
|
const [sortPrimaryOrder, setSortPrimaryOrder] = useState(-1);
|
||||||
|
const [sortSecondaryField, setSortSecondaryField] = useState('title');
|
||||||
|
const [sortSecondaryOrder, setSortSecondaryOrder] = useState(1);
|
||||||
|
const [sortTertiaryField, setSortTertiaryField] = useState('mediaType');
|
||||||
|
const [sortTertiaryOrder, setSortTertiaryOrder] = useState(1);
|
||||||
const [selectedJob, setSelectedJob] = useState(null);
|
const [selectedJob, setSelectedJob] = useState(null);
|
||||||
const [detailVisible, setDetailVisible] = useState(false);
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -49,6 +226,7 @@ export default function HistoryPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
|
|
||||||
const queuedJobIdSet = useMemo(() => {
|
const queuedJobIdSet = useMemo(() => {
|
||||||
const next = new Set();
|
const next = new Set();
|
||||||
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
|
for (const value of Array.isArray(queuedJobIds) ? queuedJobIds : []) {
|
||||||
@@ -60,6 +238,52 @@ export default function HistoryPage() {
|
|||||||
return next;
|
return next;
|
||||||
}, [queuedJobIds]);
|
}, [queuedJobIds]);
|
||||||
|
|
||||||
|
const sortDescriptors = useMemo(() => {
|
||||||
|
const seen = new Set();
|
||||||
|
const rawDescriptors = [
|
||||||
|
{ field: String(sortPrimaryField || '').trim(), order: Number(sortPrimaryOrder || -1) >= 0 ? 1 : -1 },
|
||||||
|
{ field: String(sortSecondaryField || '').trim(), order: Number(sortSecondaryOrder || -1) >= 0 ? 1 : -1 },
|
||||||
|
{ field: String(sortTertiaryField || '').trim(), order: Number(sortTertiaryOrder || -1) >= 0 ? 1 : -1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const descriptors = [];
|
||||||
|
for (const descriptor of rawDescriptors) {
|
||||||
|
if (!descriptor.field || seen.has(descriptor.field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(descriptor.field);
|
||||||
|
descriptors.push(descriptor);
|
||||||
|
}
|
||||||
|
return descriptors;
|
||||||
|
}, [sortPrimaryField, sortPrimaryOrder, sortSecondaryField, sortSecondaryOrder, sortTertiaryField, sortTertiaryOrder]);
|
||||||
|
|
||||||
|
const visibleJobs = useMemo(() => {
|
||||||
|
const filtered = mediumFilter
|
||||||
|
? jobs.filter((job) => resolveMediaType(job) === mediumFilter)
|
||||||
|
: [...jobs];
|
||||||
|
|
||||||
|
if (sortDescriptors.length === 0) {
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
for (const descriptor of sortDescriptors) {
|
||||||
|
const valueA = resolveSortValue(a, descriptor.field);
|
||||||
|
const valueB = resolveSortValue(b, descriptor.field);
|
||||||
|
const compared = compareSortValues(valueA, valueB);
|
||||||
|
if (compared !== 0) {
|
||||||
|
return compared * descriptor.order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idA = Number(a?.id || 0);
|
||||||
|
const idB = Number(b?.id || 0);
|
||||||
|
return idB - idA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [jobs, mediumFilter, sortDescriptors]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -211,8 +435,8 @@ export default function HistoryPage() {
|
|||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
if (row?.encodeSuccess) {
|
if (row?.encodeSuccess) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
|
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n`
|
||||||
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
|
+ 'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
|
||||||
);
|
);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
@@ -279,7 +503,7 @@ export default function HistoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusBody = (row) => {
|
const renderStatusTag = (row) => {
|
||||||
const normalizedStatus = normalizeStatus(row?.status);
|
const normalizedStatus = normalizeStatus(row?.status);
|
||||||
const rowId = normalizeJobId(row?.id);
|
const rowId = normalizeJobId(row?.id);
|
||||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||||
@@ -290,46 +514,184 @@ export default function HistoryPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const mkBody = (row) => (
|
|
||||||
<span className="job-step-cell">
|
const renderPoster = (row, className = 'history-dv-poster') => {
|
||||||
{row?.backupSuccess ? <i className="pi pi-check-circle job-step-ok-icon" aria-label="Backup erfolgreich" title="Backup erfolgreich" /> : null}
|
const title = row?.title || row?.detected_title || 'Poster';
|
||||||
<span>
|
if (row?.poster_url && row.poster_url !== 'N/A') {
|
||||||
{row.makemkvInfo
|
return <img src={row.poster_url} alt={title} className={className} loading="lazy" />;
|
||||||
? `${getProcessStatusLabel(row.makemkvInfo.status)} ${typeof row.makemkvInfo.lastProgress === 'number' ? `${row.makemkvInfo.lastProgress.toFixed(1)}%` : ''}`
|
}
|
||||||
: '-'}
|
return <div className="history-dv-poster-fallback">Kein Poster</div>;
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
const hbBody = (row) => (
|
|
||||||
<span className="job-step-cell">
|
|
||||||
{row?.encodeSuccess ? <i className="pi pi-check-circle job-step-ok-icon" aria-label="Encode erfolgreich" title="Encode erfolgreich" /> : null}
|
|
||||||
<span>
|
|
||||||
{row.handbrakeInfo
|
|
||||||
? `${getProcessStatusLabel(row.handbrakeInfo.status)} ${typeof row.handbrakeInfo.lastProgress === 'number' ? `${row.handbrakeInfo.lastProgress.toFixed(1)}%` : ''}`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
const mediaBody = (row) => {
|
|
||||||
const mediaType = resolveMediaType(row);
|
|
||||||
const src = mediaType === 'bluray' ? blurayIndicatorIcon : discIndicatorIcon;
|
|
||||||
const alt = mediaType === 'bluray' ? 'Blu-ray' : 'Disc';
|
|
||||||
const title = mediaType === 'bluray' ? 'Blu-ray' : 'CD/sonstiges Medium';
|
|
||||||
return <img src={src} alt={alt} title={title} className="media-indicator-icon" />;
|
|
||||||
};
|
};
|
||||||
const posterBody = (row) =>
|
|
||||||
row.poster_url && row.poster_url !== 'N/A' ? (
|
const renderPresenceChip = (label, available) => (
|
||||||
<img src={row.poster_url} alt={row.title || row.detected_title || 'Poster'} className="poster-thumb" />
|
<span className={`history-dv-chip ${available ? 'tone-ok' : 'tone-no'}`}>
|
||||||
) : (
|
<i className={`pi ${available ? 'pi-check-circle' : 'pi-times-circle'}`} aria-hidden="true" />
|
||||||
<span>-</span>
|
<span>{label}: {available ? 'Ja' : 'Nein'}</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderRatings = (row) => {
|
||||||
|
const ratings = resolveRatings(row);
|
||||||
|
if (ratings.length === 0) {
|
||||||
|
return <span className="history-dv-subtle">Keine Ratings</span>;
|
||||||
|
}
|
||||||
|
return ratings.map((rating) => (
|
||||||
|
<span key={`${row?.id}-${rating.key}`} className="history-dv-rating-chip">
|
||||||
|
<strong>{rating.label}</strong>
|
||||||
|
<span>{rating.value}</span>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onItemKeyDown = (event, row) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
void openDetail(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderListItem = (row) => {
|
||||||
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
|
const title = row?.title || row?.detected_title || '-';
|
||||||
|
const imdb = row?.imdb_id || '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-grid">
|
<div
|
||||||
<Toast ref={toastRef} />
|
className="history-dv-item history-dv-item-list"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => onItemKeyDown(event, row)}
|
||||||
|
onClick={() => {
|
||||||
|
void openDetail(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="history-dv-poster-wrap">
|
||||||
|
{renderPoster(row)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="Historie" subTitle="Alle Jobs mit Details und Logs">
|
<div className="history-dv-main">
|
||||||
<div className="table-filters">
|
<div className="history-dv-head">
|
||||||
|
<div className="history-dv-title-block">
|
||||||
|
<strong className="history-dv-title">{title}</strong>
|
||||||
|
<small className="history-dv-subtle">
|
||||||
|
#{row?.id || '-'} | {row?.year || '-'} | {imdb}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{renderStatusTag(row)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-meta-row">
|
||||||
|
<span className="job-step-cell">
|
||||||
|
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
||||||
|
<span>{mediaMeta.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||||
|
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-flags-row">
|
||||||
|
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
|
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-ratings-row">
|
||||||
|
{renderRatings(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-actions">
|
||||||
|
<Button
|
||||||
|
label="Details"
|
||||||
|
icon="pi pi-search"
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void openDetail(row);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGridItem = (row) => {
|
||||||
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
|
const title = row?.title || row?.detected_title || '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-dv-grid-cell">
|
||||||
|
<div
|
||||||
|
className="history-dv-item history-dv-item-grid"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => onItemKeyDown(event, row)}
|
||||||
|
onClick={() => {
|
||||||
|
void openDetail(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="history-dv-grid-head">
|
||||||
|
{renderPoster(row, 'history-dv-poster-lg')}
|
||||||
|
<div className="history-dv-grid-title-wrap">
|
||||||
|
<strong className="history-dv-title">{title}</strong>
|
||||||
|
<small className="history-dv-subtle">
|
||||||
|
#{row?.id || '-'} | {row?.year || '-'} | {row?.imdb_id || '-'}
|
||||||
|
</small>
|
||||||
|
<span className="job-step-cell">
|
||||||
|
<img src={mediaMeta.icon} alt={mediaMeta.alt} title={mediaMeta.label} className="media-indicator-icon" />
|
||||||
|
<span>{mediaMeta.label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-grid-status-row">
|
||||||
|
{renderStatusTag(row)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-grid-time-row">
|
||||||
|
<span className="history-dv-subtle">Start: {formatDateTime(row?.start_time)}</span>
|
||||||
|
<span className="history-dv-subtle">Ende: {formatDateTime(row?.end_time)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-flags-row">
|
||||||
|
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
|
{renderPresenceChip('Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-ratings-row">
|
||||||
|
{renderRatings(row)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-actions history-dv-actions-grid">
|
||||||
|
<Button
|
||||||
|
label="Details"
|
||||||
|
icon="pi pi-search"
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void openDetail(row);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemTemplate = (row, currentLayout) => {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (currentLayout === 'grid') {
|
||||||
|
return renderGridItem(row);
|
||||||
|
}
|
||||||
|
return renderListItem(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataViewHeader = (
|
||||||
|
<div>
|
||||||
|
<div className="history-dv-toolbar">
|
||||||
<InputText
|
<InputText
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
@@ -343,35 +705,106 @@ export default function HistoryPage() {
|
|||||||
onChange={(event) => setStatus(event.value)}
|
onChange={(event) => setStatus(event.value)}
|
||||||
placeholder="Status"
|
placeholder="Status"
|
||||||
/>
|
/>
|
||||||
|
<Dropdown
|
||||||
|
value={mediumFilter}
|
||||||
|
options={MEDIA_FILTER_OPTIONS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setMediumFilter(event.value || '')}
|
||||||
|
placeholder="Medium"
|
||||||
|
/>
|
||||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||||
|
<div className="history-dv-layout-toggle">
|
||||||
|
<DataViewLayoutOptions
|
||||||
|
layout={layout}
|
||||||
|
onChange={(event) => setLayout(event.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-scroll-wrap table-scroll-wide">
|
<div className="history-dv-sortbar">
|
||||||
<DataTable
|
<div className="history-dv-sort-rule">
|
||||||
value={jobs}
|
<strong>1.</strong>
|
||||||
dataKey="id"
|
<Dropdown
|
||||||
paginator
|
value={sortPrimaryField}
|
||||||
rows={10}
|
options={BASE_SORT_FIELD_OPTIONS}
|
||||||
loading={loading}
|
optionLabel="label"
|
||||||
onRowClick={(event) => openDetail(event.data)}
|
optionValue="value"
|
||||||
className="clickable-table"
|
onChange={(event) => setSortPrimaryField(event.value || 'start_time')}
|
||||||
emptyMessage="Keine Einträge"
|
placeholder="Primär"
|
||||||
responsiveLayout="scroll"
|
/>
|
||||||
>
|
<Dropdown
|
||||||
<Column field="id" header="#" style={{ width: '5rem' }} />
|
value={sortPrimaryOrder}
|
||||||
<Column header="Medium" body={mediaBody} style={{ width: '6rem' }} />
|
options={SORT_DIRECTION_OPTIONS}
|
||||||
<Column header="Poster" body={posterBody} style={{ width: '7rem' }} />
|
optionLabel="label"
|
||||||
<Column field="title" header="Titel" body={(row) => row.title || row.detected_title || '-'} />
|
optionValue="value"
|
||||||
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
onChange={(event) => setSortPrimaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
||||||
<Column field="imdb_id" header="IMDb" style={{ width: '10rem' }} />
|
placeholder="Richtung"
|
||||||
<Column field="status" header="Status" body={statusBody} style={{ width: '12rem' }} />
|
/>
|
||||||
<Column header="MakeMKV" body={mkBody} style={{ width: '12rem' }} />
|
|
||||||
<Column header="HandBrake" body={hbBody} style={{ width: '12rem' }} />
|
|
||||||
<Column field="start_time" header="Start" style={{ width: '16rem' }} />
|
|
||||||
<Column field="end_time" header="Ende" style={{ width: '16rem' }} />
|
|
||||||
<Column field="output_path" header="Output" />
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-sort-rule">
|
||||||
|
<strong>2.</strong>
|
||||||
|
<Dropdown
|
||||||
|
value={sortSecondaryField}
|
||||||
|
options={OPTIONAL_SORT_FIELD_OPTIONS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setSortSecondaryField(event.value || '')}
|
||||||
|
placeholder="Sekundär"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
value={sortSecondaryOrder}
|
||||||
|
options={SORT_DIRECTION_OPTIONS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setSortSecondaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
||||||
|
placeholder="Richtung"
|
||||||
|
disabled={!sortSecondaryField}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="history-dv-sort-rule">
|
||||||
|
<strong>3.</strong>
|
||||||
|
<Dropdown
|
||||||
|
value={sortTertiaryField}
|
||||||
|
options={OPTIONAL_SORT_FIELD_OPTIONS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setSortTertiaryField(event.value || '')}
|
||||||
|
placeholder="Tertiär"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
value={sortTertiaryOrder}
|
||||||
|
options={SORT_DIRECTION_OPTIONS}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(event) => setSortTertiaryOrder(Number(event.value || -1) >= 0 ? 1 : -1)}
|
||||||
|
placeholder="Richtung"
|
||||||
|
disabled={!sortTertiaryField}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-grid">
|
||||||
|
<Toast ref={toastRef} />
|
||||||
|
|
||||||
|
<Card title="Historie" subTitle="DataView mit Poster, Status, Dateiverfügbarkeit, Encode-Status und Ratings">
|
||||||
|
<DataView
|
||||||
|
value={visibleJobs}
|
||||||
|
layout={layout}
|
||||||
|
itemTemplate={itemTemplate}
|
||||||
|
paginator
|
||||||
|
rows={12}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
header={dataViewHeader}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="Keine Einträge"
|
||||||
|
className="history-dataview"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<JobDetailDialog
|
<JobDetailDialog
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ function isSameValue(a, b) {
|
|||||||
function injectHandBrakePresetOptions(categories, presetPayload) {
|
function injectHandBrakePresetOptions(categories, presetPayload) {
|
||||||
const list = Array.isArray(categories) ? categories : [];
|
const list = Array.isArray(categories) ? categories : [];
|
||||||
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : [];
|
||||||
|
const presetSettingKeys = new Set(['handbrake_preset', 'handbrake_preset_bluray', 'handbrake_preset_dvd']);
|
||||||
|
|
||||||
return list.map((category) => ({
|
return list.map((category) => ({
|
||||||
...category,
|
...category,
|
||||||
settings: (category?.settings || []).map((setting) => {
|
settings: (category?.settings || []).map((setting) => {
|
||||||
if (setting?.key !== 'handbrake_preset') {
|
if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) {
|
||||||
return setting;
|
return setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -552,6 +552,78 @@ body {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.script-results-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-summary-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-summary-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-summary-counts .tone-success { color: #1c8a3a; font-weight: 600; }
|
||||||
|
.script-summary-counts .tone-danger { color: #9c2d2d; font-weight: 600; }
|
||||||
|
.script-summary-counts .tone-warning { color: #b45309; font-weight: 600; }
|
||||||
|
|
||||||
|
.script-result-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-result-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-result-name {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--rip-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-result-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-result-status.tone-success { color: #1c8a3a; }
|
||||||
|
.script-result-status.tone-danger { color: #9c2d2d; }
|
||||||
|
.script-result-status.tone-warning { color: #b45309; }
|
||||||
|
|
||||||
|
.script-result-error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9c2d2d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-inline-warn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
color: #b45309;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-step-inline-warn .pi {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-queue-item {
|
.pipeline-queue-item {
|
||||||
border: 1px dashed var(--rip-border);
|
border: 1px dashed var(--rip-border);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
@@ -1173,6 +1245,234 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-dataview .p-dataview-header {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dataview .p-dataview-content {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dataview .p-paginator {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-toolbar {
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 12rem 10rem auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-layout-toggle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-sortbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-sort-rule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4rem minmax(0, 1fr) 9.4rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-sort-rule strong {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--rip-brown-700);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-item {
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: var(--rip-panel-soft);
|
||||||
|
box-shadow: 0 2px 7px rgba(58, 29, 18, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring), 0 2px 7px rgba(58, 29, 18, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-item-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-title-block {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-title {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-subtle {
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-meta-row,
|
||||||
|
.history-dv-flags-row,
|
||||||
|
.history-dv-ratings-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border: 1px solid var(--rip-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.12rem 0.48rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-chip.tone-ok {
|
||||||
|
color: #176635;
|
||||||
|
border-color: #9ed5ad;
|
||||||
|
background: #dcf3e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-chip.tone-no {
|
||||||
|
color: #8b2c2c;
|
||||||
|
border-color: #d4a29e;
|
||||||
|
background: #f8e1df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-rating-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.34rem;
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.12rem 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
background: var(--rip-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-wrap {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster,
|
||||||
|
.history-dv-poster-lg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px solid #cba266;
|
||||||
|
background: #f6ebd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster {
|
||||||
|
height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-lg {
|
||||||
|
width: 66px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px dashed var(--rip-border);
|
||||||
|
background: #f3e5cc;
|
||||||
|
color: var(--rip-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-grid-cell {
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-item-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-grid-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 66px minmax(0, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-grid-title-wrap {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-grid-status-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-grid-time-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-actions-grid {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.table-scroll-wrap {
|
.table-scroll-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -1718,6 +2018,8 @@ body {
|
|||||||
.job-meta-grid,
|
.job-meta-grid,
|
||||||
.job-film-info-grid,
|
.job-film-info-grid,
|
||||||
.table-filters,
|
.table-filters,
|
||||||
|
.history-dv-toolbar,
|
||||||
|
.history-dv-sortbar,
|
||||||
.job-head-row,
|
.job-head-row,
|
||||||
.job-json-grid,
|
.job-json-grid,
|
||||||
.selected-meta,
|
.selected-meta,
|
||||||
@@ -1780,6 +2082,28 @@ body {
|
|||||||
.hardware-core-metric {
|
.hardware-core-metric {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-dv-sort-rule {
|
||||||
|
grid-template-columns: 1.4rem minmax(0, 1fr) 8.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-item-list {
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-grid-head {
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-lg {
|
||||||
|
width: 58px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -1791,6 +2115,30 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-dv-sort-rule {
|
||||||
|
grid-template-columns: 1.4rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-sort-rule .p-dropdown:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-item-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-wrap {
|
||||||
|
width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-dv-poster-fallback {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user