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

This commit is contained in:
2026-05-08 08:10:34 +02:00
parent 024a6305e2
commit 9b437fe5e5
52 changed files with 5815 additions and 2600 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.16.1-6", "version": "0.16.1-7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.16.1-6", "version": "0.16.1-7",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.16.1-6", "version": "0.16.1-7",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+1
View File
@@ -18,6 +18,7 @@ module.exports = {
port: process.env.PORT ? Number(process.env.PORT) : 3001, port: process.env.PORT ? Number(process.env.PORT) : 3001,
dbPath: resolvedDbPath, dbPath: resolvedDbPath,
dataDir, dataDir,
tempDir: resolveOutputPath(process.env.DEFAULT_TEMP_DIR, 'temp'),
corsOrigin: process.env.CORS_ORIGIN || '*', corsOrigin: process.env.CORS_ORIGIN || '*',
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir), logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
logLevel: process.env.LOG_LEVEL || 'info', logLevel: process.env.LOG_LEVEL || 'info',
+124 -17
View File
@@ -528,6 +528,7 @@ async function openAndPrepareDatabase() {
await migrateLegacyProfiledToolSettings(dbInstance); await migrateLegacyProfiledToolSettings(dbInstance);
await migrateOutputTemplates(dbInstance); await migrateOutputTemplates(dbInstance);
await removeDeprecatedSettings(dbInstance); await removeDeprecatedSettings(dbInstance);
await migrateTmdbMigrationFlags(dbInstance);
await migrateSettingsSchemaMetadata(dbInstance); await migrateSettingsSchemaMetadata(dbInstance);
await ensurePipelineStateRow(dbInstance); await ensurePipelineStateRow(dbInstance);
await backfillJobOutputFolders(dbInstance); await backfillJobOutputFolders(dbInstance);
@@ -811,7 +812,9 @@ async function removeDeprecatedSettings(db) {
'use_plugin_architecture_audiobook_analyze', 'use_plugin_architecture_audiobook_analyze',
'use_plugin_architecture_audiobook_encode', 'use_plugin_architecture_audiobook_encode',
'dvd_series_plugin_enabled', 'dvd_series_plugin_enabled',
'dvd_series_prescan_enabled' 'dvd_series_prescan_enabled',
'dvd_series_reuse_disc_profiles',
'musicbrainz_enabled'
]; ];
for (const key of deprecatedKeys) { for (const key of deprecatedKeys) {
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]); const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
@@ -828,6 +831,64 @@ async function removeDeprecatedSettings(db) {
); );
} }
async function migrateTmdbMigrationFlags(db) {
// Keep this migration idempotent. It helps initialize the new migrate_tmdb flag
// for existing rows after OMDb -> TMDb transition.
const nullableReset = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 0
WHERE migrate_tmdb IS NULL
`
);
// Audio-only domains are outside TMDb migration scope.
const nonVideoMarked = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 1
WHERE COALESCE(migrate_tmdb, 0) = 0
AND LOWER(TRIM(COALESCE(media_type, ''))) IN ('cd', 'audiobook')
`
);
// Rows with obvious TMDb assignment can be marked as migrated.
const obviousTmdbMarked = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 1
WHERE COALESCE(migrate_tmdb, 0) = 0
AND (
makemkv_info_json LIKE '%"metadataProvider":"tmdb"%'
OR makemkv_info_json LIKE '%"tmdbId":%'
OR encode_plan_json LIKE '%"metadataProvider":"tmdb"%'
OR encode_plan_json LIKE '%"tmdbId":%'
)
`
);
// Explicit OMDb footprints must stay pending for manual migration.
const explicitOmdbPending = await db.run(
`
UPDATE jobs
SET migrate_tmdb = 0
WHERE (
COALESCE(selected_from_omdb, 0) = 1
OR (omdb_json IS NOT NULL AND TRIM(omdb_json) <> '')
OR makemkv_info_json LIKE '%"metadataProvider":"omdb"%'
OR encode_plan_json LIKE '%"metadataProvider":"omdb"%'
)
`
);
logger.info('migrate:tmdb-flag:done', {
nullableReset: Number(nullableReset?.changes || 0),
nonVideoMarked: Number(nonVideoMarked?.changes || 0),
obviousTmdbMarked: Number(obviousTmdbMarked?.changes || 0),
explicitOmdbPending: Number(explicitOmdbPending?.changes || 0)
});
}
// Aktualisiert settings_schema-Metadaten (required, description, validation_json) // Aktualisiert settings_schema-Metadaten (required, description, validation_json)
// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten. // für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten.
const SETTINGS_SCHEMA_METADATA_UPDATES = [ const SETTINGS_SCHEMA_METADATA_UPDATES = [
@@ -849,6 +910,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.', description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
validation_json: '{}' validation_json: '{}'
}, },
{
key: 'dvd_series_language',
required: 1,
description: 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.',
validation_json: '{"minLength":2}'
},
{
key: 'dvd_series_fallback_languages',
required: 1,
description: 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.',
validation_json: '{"minLength":2}'
},
{ {
key: 'output_template_bluray_series_episode', key: 'output_template_bluray_series_episode',
required: 1, required: 1,
@@ -973,6 +1046,41 @@ async function migrateSettingsSchemaMetadata(db) {
}); });
} }
const metadataLanguageLabel = 'Film-Sprache';
const metadataLanguageDescription = 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.';
const metadataLanguageResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'dvd_series_language' AND (label != ? OR description != ?)`,
[metadataLanguageLabel, metadataLanguageDescription, metadataLanguageLabel, metadataLanguageDescription]
);
if (metadataLanguageResult?.changes > 0) {
logger.info('migrate:settings-schema-language-updated', {
key: 'dvd_series_language',
label: metadataLanguageLabel
});
}
const metadataFallbackLanguageLabel = 'Fallback Sprache';
const metadataFallbackLanguageDescription = 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.';
const metadataFallbackLanguageResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'dvd_series_fallback_languages' AND (label != ? OR description != ?)`,
[
metadataFallbackLanguageLabel,
metadataFallbackLanguageDescription,
metadataFallbackLanguageLabel,
metadataFallbackLanguageDescription
]
);
if (metadataFallbackLanguageResult?.changes > 0) {
logger.info('migrate:settings-schema-fallback-language-updated', {
key: 'dvd_series_fallback_languages',
label: metadataFallbackLanguageLabel
});
}
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [ const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
@@ -1063,9 +1171,18 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221)` VALUES ('series_playall_tolerance_lower_pct', 'Tools', 'Serie: PlayAll Untergrenze (%)', 'number', 1, 'Untere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 234)`
); );
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true')`); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_lower_pct', '10')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('series_playall_tolerance_upper_pct', 'Tools', 'Serie: PlayAll Obergrenze (%)', 'number', 1, 'Obere Toleranz in Prozent für die Plausibilitätsprüfung von PlayAll gegen Episodensumme (DVD/Blu-ray).', '10', '[]', '{"min":0,"max":100}', 235)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_playall_tolerance_upper_pct', '10')`);
await db.run(`DELETE FROM settings_values WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'handbrake_pre_metadata_scan_enabled'`);
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
@@ -1226,30 +1343,20 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)` VALUES ('dvd_series_language', 'Metadaten', 'Film-Sprache', 'string', 1, 'Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
); );
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)` VALUES ('dvd_series_fallback_languages', 'Metadaten', 'Fallback Sprache', 'string', 1, 'Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
); );
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`); await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);
await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`); await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`); await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
await db.run(`DELETE FROM settings_values WHERE key = 'dvd_series_order_preference'`);
await db.run(`DELETE FROM settings_schema WHERE key = 'dvd_series_order_preference'`);
// Converter-Pfade und Eigentümer // Converter-Pfade und Eigentümer
await db.run( await db.run(
+23
View File
@@ -26,6 +26,7 @@ function truncate(value, maxLen = 1500) {
module.exports = function requestLogger(req, res, next) { module.exports = function requestLogger(req, res, next) {
const reqId = randomUUID(); const reqId = randomUUID();
const startedAt = Date.now(); const startedAt = Date.now();
let completionLogged = false;
req.reqId = reqId; req.reqId = reqId;
@@ -38,15 +39,37 @@ module.exports = function requestLogger(req, res, next) {
body: truncate(req.body) body: truncate(req.body)
}); });
const logCompletion = (kind) => {
if (completionLogged) {
return;
}
completionLogged = true;
logger.info('request:end', {
reqId,
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startedAt,
completion: kind
});
};
res.on('finish', () => {
logCompletion('finish');
});
res.on('close', () => { res.on('close', () => {
if (!res.writableEnded) { if (!res.writableEnded) {
logger.warn('request:aborted', { logger.warn('request:aborted', {
reqId, reqId,
method: req.method, method: req.method,
url: req.originalUrl, url: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startedAt durationMs: Date.now() - startedAt
}); });
return;
} }
logCompletion('close');
}); });
next(); next();
+14 -10
View File
@@ -2,7 +2,7 @@
const path = require('path'); const path = require('path');
const { SourcePlugin } = require('./PluginBase'); const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService'); const tmdbService = require('../services/tmdbService');
const { spawnTrackedProcess } = require('../services/processRunner'); const { spawnTrackedProcess } = require('../services/processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers'); const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files'); const { ensureDir } = require('../utils/files');
@@ -11,7 +11,7 @@ const { ensureDir } = require('../utils/files');
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins. * Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
* *
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien: * Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
* analyze() → OMDB-Suche + Disc-Metadaten vorbereiten * analyze() → TMDb-Suche + Disc-Metadaten vorbereiten
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator) * review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
* rip() → makemkvcon backup/mkv * rip() → makemkvcon backup/mkv
* encode() → HandBrakeCLI * encode() → HandBrakeCLI
@@ -54,13 +54,13 @@ class VideoDiscPlugin extends SourcePlugin {
} }
/** /**
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch. * Führt eine TMDb-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, omdbCandidates } zurück. * Gibt { detectedTitle, metadataCandidates } zurück.
* *
* @param {string} devicePath * @param {string} devicePath
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten) * @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
* @param {PluginContext} ctx * @param {PluginContext} ctx
* @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>} * @returns {Promise<{detectedTitle: string, metadataCandidates: Array}>}
*/ */
async analyze(devicePath, job, ctx) { async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', { ctx.markExecution('analyze', {
@@ -76,8 +76,8 @@ class VideoDiscPlugin extends SourcePlugin {
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle }); ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => { const metadataCandidates = await tmdbService.searchMoviesWithDetails(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, { ctx.logger.warn(`${this.id}:analyze:tmdb-failed`, {
jobId: job?.id, jobId: job?.id,
error: error?.message || String(error) error: error?.message || String(error)
}); });
@@ -87,10 +87,10 @@ class VideoDiscPlugin extends SourcePlugin {
ctx.logger.info(`${this.id}:analyze:done`, { ctx.logger.info(`${this.id}:analyze:done`, {
jobId: job?.id, jobId: job?.id,
detectedTitle, detectedTitle,
omdbCandidates: omdbCandidates.length metadataCandidates: metadataCandidates.length
}); });
return { detectedTitle, omdbCandidates }; return { detectedTitle, metadataCandidates };
} }
/** /**
@@ -277,6 +277,9 @@ class VideoDiscPlugin extends SourcePlugin {
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log, // des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests. // Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null; const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
const makeMkvLineFilter = typeof ctx.extra?.makeMkvLineFilter === 'function'
? ctx.extra.makeMkvLineFilter
: null;
let runInfo; let runInfo;
if (runCommandFn) { if (runCommandFn) {
runInfo = await runCommandFn({ runInfo = await runCommandFn({
@@ -285,7 +288,8 @@ class VideoDiscPlugin extends SourcePlugin {
source: 'MAKEMKV_RIP', source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd, cmd: ripConfig.cmd,
args: ripConfig.args, args: ripConfig.args,
parser: parseMakeMkvProgress parser: parseMakeMkvProgress,
lineFilter: makeMkvLineFilter
}); });
} else { } else {
runInfo = await _spawnAndWait({ runInfo = await _spawnAndWait({
+4 -2
View File
@@ -2,7 +2,6 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler'); const asyncHandler = require('../middleware/asyncHandler');
@@ -10,6 +9,7 @@ const pipelineService = require('../services/pipelineService');
const converterScanService = require('../services/converterScanService'); const converterScanService = require('../services/converterScanService');
const historyService = require('../services/historyService'); const historyService = require('../services/historyService');
const logger = require('../services/logger').child('CONVERTER_ROUTE'); const logger = require('../services/logger').child('CONVERTER_ROUTE');
const { tempDir } = require('../config');
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */ /** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
function resolveTarget(rawDir, input) { function resolveTarget(rawDir, input) {
@@ -20,9 +20,11 @@ function resolveTarget(rawDir, input) {
} }
const router = express.Router(); const router = express.Router();
const converterUploadDir = path.join(tempDir, 'ripster-converter-uploads');
fs.mkdirSync(converterUploadDir, { recursive: true });
const converterUpload = multer({ const converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads') dest: converterUploadDir
}); });
// ── Scan ────────────────────────────────────────────────────────────────── // ── Scan ──────────────────────────────────────────────────────────────────
+95 -9
View File
@@ -66,6 +66,42 @@ router.get(
}) })
); );
router.get(
'/tmdb-migration/pending',
asyncHandler(async (req, res) => {
const parsedLimit = Number(req.query.limit);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.trunc(parsedLimit)
: 500;
logger.info('get:tmdb-migration:pending', {
reqId: req.reqId,
limit
});
const jobs = await historyService.getTmdbMigrationPendingJobs({
limit,
includeFsChecks: false
});
res.json({ jobs });
})
);
router.post(
'/:id/tmdb-migration',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const migrated = ['1', 'true', 'yes', 'on'].includes(
String(req.body?.migrated ?? req.body?.migrateTMDB ?? '').trim().toLowerCase()
);
logger.info('post:tmdb-migration:flag', {
reqId: req.reqId,
id,
migrated
});
const job = await historyService.setTmdbMigrationFlag(id, migrated);
res.json({ job });
})
);
router.post( router.post(
'/orphan-raw/import', '/orphan-raw/import',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
@@ -104,11 +140,21 @@ router.post(
); );
router.post( router.post(
'/:id/omdb/assign', '/orphan-raw/delete',
asyncHandler(async (req, res) => {
const rawPath = String(req.body?.rawPath || '').trim();
logger.warn('post:orphan-raw:delete', { reqId: req.reqId, rawPath });
const result = await historyService.deleteOrphanRawFolder(rawPath);
res.json(result);
})
);
router.post(
'/:id/metadata/assign',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const payload = req.body || {}; const payload = req.body || {};
logger.info('post:job:omdb:assign', { logger.info('post:job:metadata:assign', {
reqId: req.reqId, reqId: req.reqId,
id, id,
imdbId: payload?.imdbId || null, imdbId: payload?.imdbId || null,
@@ -116,11 +162,11 @@ router.post(
hasYear: Boolean(payload?.year) hasYear: Boolean(payload?.year)
}); });
const job = await historyService.assignOmdbMetadata(id, payload); const job = await historyService.assignMetadata(id, payload);
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking) // Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => { pipelineService.renameJobFolders(id).catch((err) => {
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message }); logger.warn('post:job:metadata:assign:rename-failed', { id, error: err.message });
}); });
res.json({ job }); res.json({ job });
@@ -141,7 +187,15 @@ router.post(
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0 trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
}); });
const job = await historyService.assignCdMetadata(id, payload); const job = await pipelineService.selectCdMetadata({
jobId: id,
title: payload?.title,
artist: payload?.artist,
year: payload?.year,
mbId: payload?.mbId,
coverUrl: payload?.coverUrl,
tracks: payload?.tracks
});
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking) // Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => { pipelineService.renameJobFolders(id).catch((err) => {
@@ -250,9 +304,10 @@ router.post(
}); });
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase()); const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase()); const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined const hasRequestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined;
const requestedKeepDetectedDevice = hasRequestedKeepDetectedDevice
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase()) ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !requestedResetDriveState; : null;
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths) const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
? req.body.selectedRawPaths ? req.body.selectedRawPaths
.map((item) => String(item || '').trim()) .map((item) => String(item || '').trim())
@@ -284,12 +339,35 @@ router.post(
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport) (row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
)) ))
); );
const selectedDeleteJobIds = Array.isArray(preview?.selectedJobIds)
? preview.selectedJobIds
: [id];
const selectedDeleteJobs = await historyService.getJobsByIds(selectedDeleteJobIds).catch(() => []);
const autoResetDriveStateForPhysicalCd = Array.isArray(selectedDeleteJobs) && selectedDeleteJobs.some((job) => {
const mediaType = String(job?.media_type || job?.mediaType || '').trim().toLowerCase();
const jobKind = String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
const status = String(job?.status || '').trim().toUpperCase();
const lastState = String(job?.last_state || '').trim().toUpperCase();
const isCdJob = mediaType === 'cd'
|| jobKind === 'cd'
|| status.startsWith('CD_')
|| lastState.startsWith('CD_');
if (!isCdJob) {
return false;
}
const discDevice = String(job?.disc_device || job?.discDevice || '').trim();
return Boolean(discDevice) && !discDevice.startsWith('__virtual__');
});
const requestedResetDriveStateEffective = requestedResetDriveState || autoResetDriveStateForPhysicalCd;
const resetDriveState = containsOrphanRawImportJob const resetDriveState = containsOrphanRawImportJob
? false ? false
: requestedResetDriveState; : requestedResetDriveStateEffective;
const keepDetectedDevice = containsOrphanRawImportJob const keepDetectedDevice = containsOrphanRawImportJob
? true ? true
: requestedKeepDetectedDevice; : (hasRequestedKeepDetectedDevice
? requestedKeepDetectedDevice
: !requestedResetDriveStateEffective);
if (containsOrphanRawImportJob && requestedResetDriveState) { if (containsOrphanRawImportJob && requestedResetDriveState) {
logger.info('post:delete-job:orphan-drive-reset-skipped', { logger.info('post:delete-job:orphan-drive-reset-skipped', {
@@ -298,6 +376,14 @@ router.post(
}); });
} }
if (autoResetDriveStateForPhysicalCd && !requestedResetDriveState) {
logger.info('post:delete-job:auto-drive-reset-cd', {
reqId: req.reqId,
id,
selectedJobIds: selectedDeleteJobIds
});
}
const relatedDevicePaths = Array.isArray(preview?.relatedJobs) const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs ? preview.relatedJobs
.filter((row) => Boolean(row?.selected)) .filter((row) => Boolean(row?.selected))
+46 -114
View File
@@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const multer = require('multer'); const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler'); const asyncHandler = require('../middleware/asyncHandler');
@@ -11,86 +10,16 @@ const hardwareMonitorService = require('../services/hardwareMonitorService');
const settingsService = require('../services/settingsService'); const settingsService = require('../services/settingsService');
const logger = require('../services/logger').child('PIPELINE_ROUTE'); const logger = require('../services/logger').child('PIPELINE_ROUTE');
const activationBytesService = require('../services/activationBytesService'); const activationBytesService = require('../services/activationBytesService');
const { defaultAudiobookDir } = require('../config'); const { tempDir } = require('../config');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const router = express.Router(); const router = express.Router();
const audiobookUploadDir = path.join(tempDir, 'ripster-audiobook-uploads');
fs.mkdirSync(audiobookUploadDir, { recursive: true });
const audiobookUpload = multer({ const audiobookUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads') dest: audiobookUploadDir
}); });
const AUDIOBOOK_TREE_MAX_DEPTH = 8;
function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) {
if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) {
return [];
}
const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
let dirents = [];
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_error) {
return [];
}
dirents.sort((left, right) => {
const leftOrder = left.isDirectory() ? 0 : 1;
const rightOrder = right.isDirectory() ? 0 : 1;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) {
continue;
}
const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name;
const childAbsPath = path.join(rootDir, childRelPath);
if (dirent.isDirectory()) {
const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
let mtime = null;
try {
mtime = fs.statSync(childAbsPath).mtime.toISOString();
} catch (_error) {
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'folder',
path: childRelPath,
size,
mtime,
children
});
continue;
}
if (dirent.isFile()) {
let size = 0;
let mtime = null;
try {
const stat = fs.statSync(childAbsPath);
size = Number(stat?.size || 0);
mtime = stat?.mtime ? stat.mtime.toISOString() : null;
} catch (_error) {
size = 0;
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'file',
path: childRelPath,
size,
mtime
});
}
}
return nodes;
}
router.get( router.get(
'/state', '/state',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
@@ -146,11 +75,11 @@ router.post(
); );
router.get( router.get(
'/omdb/search', '/tmdb/movie/search',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const query = req.query.q || ''; const query = req.query.q || '';
logger.info('get:omdb:search', { reqId: req.reqId, query }); logger.info('get:tmdb:movie-search', { reqId: req.reqId, query });
const results = await pipelineService.searchOmdb(String(query)); const results = await pipelineService.searchTmdbMovies(String(query));
res.json({ results }); res.json({ results });
}) })
); );
@@ -170,8 +99,12 @@ router.get(
'/cd/musicbrainz/search', '/cd/musicbrainz/search',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const query = req.query.q || ''; const query = req.query.q || '';
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query }); const trackCountRaw = Number(req.query.trackCount);
const results = await pipelineService.searchMusicBrainz(String(query)); const trackCount = Number.isFinite(trackCountRaw) && trackCountRaw > 0
? Math.trunc(trackCountRaw)
: null;
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query, trackCount });
const results = await pipelineService.searchMusicBrainz(String(query), { trackCount });
res.json({ results }); res.json({ results });
}) })
); );
@@ -318,47 +251,19 @@ router.get(
}) })
); );
router.get(
'/audiobook/output-tree',
asyncHandler(async (_req, res) => {
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
const configuredOutputDir = String(settings?.movie_dir || '').trim();
const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null;
const outputDir = rawOutputDir
? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir))
: null;
if (!outputDir || !fs.existsSync(outputDir)) {
res.json({ outputDir, tree: null });
return;
}
const children = buildAudiobookOutputTree(outputDir, '', 0);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
res.json({
outputDir,
tree: {
name: path.basename(outputDir) || 'audiobooks',
type: 'folder',
path: '',
size,
mtime: null,
children
}
});
})
);
router.post( router.post(
'/select-metadata', '/select-metadata',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { const {
jobId, jobId,
title, title,
artist,
year, year,
imdbId, imdbId,
poster, poster,
fromOmdb, mbId,
coverUrl,
tracks,
selectedPlaylist, selectedPlaylist,
selectedHandBrakeTitleId, selectedHandBrakeTitleId,
selectedHandBrakeTitleIds, selectedHandBrakeTitleIds,
@@ -387,10 +292,13 @@ router.post(
reqId: req.reqId, reqId: req.reqId,
jobId, jobId,
title, title,
artist,
year, year,
imdbId, imdbId,
poster, poster,
fromOmdb, mbId,
coverUrl,
trackCount: Array.isArray(tracks) ? tracks.length : null,
selectedPlaylist, selectedPlaylist,
selectedHandBrakeTitleId, selectedHandBrakeTitleId,
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null, selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
@@ -405,13 +313,37 @@ router.post(
existingDiscNumber existingDiscNumber
}); });
const looksLikeCdMetadataPayload = (
artist !== undefined
|| mbId !== undefined
|| coverUrl !== undefined
|| Array.isArray(tracks)
);
if (looksLikeCdMetadataPayload) {
logger.warn('post:select-metadata:compat-cd-route', {
reqId: req.reqId,
jobId,
trackCount: Array.isArray(tracks) ? tracks.length : 0
});
const job = await pipelineService.selectCdMetadata({
jobId: Number(jobId),
title,
artist,
year,
mbId,
coverUrl: coverUrl || poster || null,
tracks
});
res.json({ job });
return;
}
const job = await pipelineService.selectMetadata({ const job = await pipelineService.selectMetadata({
jobId: Number(jobId), jobId: Number(jobId),
title, title,
year, year,
imdbId, imdbId,
poster, poster,
fromOmdb,
selectedPlaylist, selectedPlaylist,
selectedHandBrakeTitleId, selectedHandBrakeTitleId,
selectedHandBrakeTitleIds, selectedHandBrakeTitleIds,
@@ -130,12 +130,6 @@ function collectCoverCandidates(row) {
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive'); push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
} }
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
const omdbPoster = String(omdbInfo?.Poster || '').trim();
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
push(omdbPoster, 'omdb_json.Poster');
}
return candidates; return candidates;
} }
@@ -280,8 +274,7 @@ class CoverArtRecoveryService {
status, status,
poster_url, poster_url,
imdb_id, imdb_id,
makemkv_info_json, makemkv_info_json
omdb_json
FROM jobs FROM jobs
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
` `
@@ -1096,7 +1096,9 @@ class HardwareMonitorService {
} }
const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint; const mountPoint = parsed?.mountPoint ? path.resolve(parsed.mountPoint) : parsed.mountPoint;
const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint); const hasDedicatedMount = Boolean(mountPoint) && !isRootPath(mountPoint);
const shouldShowExternal = exists || hasDedicatedMount; // External storage entries must only be visible when a dedicated filesystem
// mount is present. Existing directories alone are not enough.
const shouldShowExternal = hasDedicatedMount;
if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) { if (hideWhenUnavailable && requireDedicatedMount && !shouldShowExternal) {
return hiddenResult({ return hiddenResult({
path: resolvedPath, path: resolvedPath,
File diff suppressed because it is too large Load Diff
+33 -22
View File
@@ -1,4 +1,3 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('MUSICBRAINZ'); const logger = require('./logger').child('MUSICBRAINZ');
const MB_BASE = 'https://musicbrainz.org/ws/2'; const MB_BASE = 'https://musicbrainz.org/ws/2';
@@ -74,6 +73,17 @@ function normalizeRelease(release) {
artist: artistFromTrack || artistFromRecording || artistCredit || null artist: artistFromTrack || artistFromRecording || artistCredit || null
}; };
}); });
const rawReleaseTrackCount = Number(release['track-count']);
const mediaTrackCount = media.reduce((sum, medium) => {
const mediumCount = Number(medium?.['track-count'] ?? medium?.trackCount);
if (!Number.isFinite(mediumCount) || mediumCount <= 0) {
return sum;
}
return sum + Math.trunc(mediumCount);
}, 0);
const trackCount = Number.isFinite(rawReleaseTrackCount) && rawReleaseTrackCount > 0
? Math.trunc(rawReleaseTrackCount)
: (mediaTrackCount > 0 ? mediaTrackCount : normalizedTracks.length);
// Always generate the CAA URL when an id is present; the browser/onError // Always generate the CAA URL when an id is present; the browser/onError
// handles 404s for releases that have no front cover. // handles 404s for releases that have no front cover.
@@ -92,27 +102,21 @@ function normalizeRelease(release) {
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null ? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
: null, : null,
coverArtUrl, coverArtUrl,
tracks: normalizedTracks tracks: normalizedTracks,
trackCount
}; };
} }
class MusicBrainzService { class MusicBrainzService {
async isEnabled() { async searchByTitle(query, options = {}) {
const settings = await settingsService.getSettingsMap();
return settings.musicbrainz_enabled !== 'false';
}
async searchByTitle(query) {
const q = String(query || '').trim(); const q = String(query || '').trim();
if (!q) { if (!q) {
return []; return [];
} }
const expectedTrackCountRaw = Number(options?.trackCount);
const enabled = await this.isEnabled(); const expectedTrackCount = Number.isFinite(expectedTrackCountRaw) && expectedTrackCountRaw > 0
if (!enabled) { ? Math.trunc(expectedTrackCountRaw)
return []; : null;
}
logger.info('search:start', { query: q }); logger.info('search:start', { query: q });
const url = new URL(`${MB_BASE}/release`); const url = new URL(`${MB_BASE}/release`);
@@ -124,11 +128,23 @@ class MusicBrainzService {
try { try {
const data = await mbFetch(url.toString()); const data = await mbFetch(url.toString());
const releases = Array.isArray(data.releases) ? data.releases : []; const releases = Array.isArray(data.releases) ? data.releases : [];
const results = releases.map(normalizeRelease).filter(Boolean); const normalizedReleases = releases.map(normalizeRelease).filter(Boolean);
logger.info('search:done', { query: q, count: results.length }); const results = expectedTrackCount
? normalizedReleases.filter((release) => Number(release?.trackCount || 0) === expectedTrackCount)
: normalizedReleases;
logger.info('search:done', {
query: q,
expectedTrackCount,
sourceCount: normalizedReleases.length,
count: results.length
});
return results; return results;
} catch (error) { } catch (error) {
logger.warn('search:failed', { query: q, error: String(error?.message || error) }); logger.warn('search:failed', {
query: q,
expectedTrackCount,
error: String(error?.message || error)
});
return []; return [];
} }
} }
@@ -143,11 +159,6 @@ class MusicBrainzService {
return null; return null;
} }
const enabled = await this.isEnabled();
if (!enabled) {
return null;
}
logger.info('getById:start', { mbId: id }); logger.info('getById:start', { mbId: id });
const url = new URL(`${MB_BASE}/release/${id}`); const url = new URL(`${MB_BASE}/release/${id}`);
-225
View File
@@ -1,225 +0,0 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
const OMDB_BASE_URL = 'https://www.omdbapi.com/';
const OMDB_TIMEOUT_MS = 10000;
const OMDB_MAX_ATTEMPTS = 2;
const OMDB_RETRY_BASE_DELAY_MS = 300;
function normalizeOmdbTimeoutMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return OMDB_TIMEOUT_MS;
}
return Math.max(1000, Math.trunc(parsed));
}
function isRetryableOmdbError(error, aborted = false) {
if (aborted) {
return true;
}
const code = String(error?.code || '').trim().toUpperCase();
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
return true;
}
const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
return true;
}
const message = String(error?.message || '').toLowerCase();
return message.includes('timed out') || message.includes('timeout');
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
class OmdbService {
mapSearchResults(data) {
if (!data || typeof data !== 'object') {
return [];
}
if (data.Response === 'False' || !Array.isArray(data.Search)) {
return [];
}
return data.Search.map((item) => ({
title: item.Title,
year: item.Year,
imdbId: item.imdbID,
type: item.Type,
poster: item.Poster
}));
}
async requestJson(url, meta = {}, options = {}) {
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
logger.warn('request:http-failed', {
...meta,
status: response.status,
attempt,
maxAttempts
});
return null;
}
return await response.json();
} catch (error) {
const aborted = error?.name === 'AbortError';
const retryable = isRetryableOmdbError(error, aborted);
const willRetry = retryable && attempt < maxAttempts;
logger[willRetry ? 'info' : 'warn']('request:failed', {
...meta,
timeoutMs,
aborted,
retryable,
attempt,
maxAttempts,
willRetry,
message: error?.message || String(error)
});
if (willRetry) {
await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
continue;
}
return null;
} finally {
clearTimeout(timer);
}
}
return null;
}
async search(query) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) {
return [];
}
logger.info('search:start', { query: normalizedQuery });
// Allow direct IMDb-ID lookups in the search field (useful for History remapping).
const imdbLike = normalizedQuery.toLowerCase();
if (/^tt\d{6,12}$/.test(imdbLike)) {
const byId = await this.fetchByImdbId(imdbLike);
return byId
? [{
title: byId.title,
year: byId.year != null ? String(byId.year) : null,
imdbId: byId.imdbId,
type: byId.type,
poster: byId.poster
}]
: [];
}
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return [];
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
const requestSearch = async (type = null) => {
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', normalizedQuery);
if (type) {
url.searchParams.set('type', type);
}
const data = await this.requestJson(
url,
{ query: normalizedQuery, action: 'search', type: type || null },
{ timeoutMs }
);
return {
data,
results: this.mapSearchResults(data)
};
};
let { data, results } = await requestSearch(configuredType);
if (results.length === 0 && configuredType) {
logger.info('search:fallback-no-type', {
query: normalizedQuery,
configuredType,
response: data?.Response || null,
error: data?.Error || null
});
({ data, results } = await requestSearch(null));
}
if (results.length === 0) {
logger.warn('search:no-results', {
query: normalizedQuery,
response: data?.Response || null,
error: data?.Error || null,
configuredType
});
return [];
}
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
return results;
}
async fetchByImdbId(imdbId) {
const normalizedId = String(imdbId || '').trim().toLowerCase();
if (!/^tt\d{6,12}$/.test(normalizedId)) {
return null;
}
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return null;
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
if (!data || typeof data !== 'object') {
return null;
}
if (data.Response === 'False') {
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
return null;
}
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
const year = yearMatch ? Number(yearMatch[0]) : null;
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
const result = {
title: data.Title || null,
year: Number.isFinite(year) ? year : null,
imdbId: String(data.imdbID || normalizedId),
type: data.Type || null,
poster,
raw: data
};
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
return result;
}
}
module.exports = new OmdbService();
File diff suppressed because it is too large Load Diff
+28 -10
View File
@@ -56,24 +56,27 @@ function mapStepRow(row) {
function terminateChildProcess(child, { immediate = false } = {}) { function terminateChildProcess(child, { immediate = false } = {}) {
if (!child) { if (!child) {
return; return false;
} }
const signal = immediate ? 'SIGKILL' : 'SIGTERM'; const signal = immediate ? 'SIGKILL' : 'SIGTERM';
const pid = Number(child.pid); const pid = Number(child.pid);
let signaled = false;
if (Number.isFinite(pid) && pid > 0) { if (Number.isFinite(pid) && pid > 0) {
try { try {
// For detached children this targets the full process group. // For detached children this targets the full process group.
process.kill(-pid, signal); process.kill(-pid, signal);
return; signaled = true;
} catch (_error) { } catch (_error) {
// Fall through to direct child signal. // ignore and continue with direct child signal
} }
} }
try { try {
child.kill(signal); child.kill(signal);
signaled = true;
} catch (_error) { } catch (_error) {
return; // ignore
} }
return signaled;
} }
function appendTailText(currentValue, nextChunk, maxChars = 12000) { function appendTailText(currentValue, nextChunk, maxChars = 12000) {
@@ -443,6 +446,7 @@ class ScriptChainService {
currentStepType: null, currentStepType: null,
activeWaitResolve: null, activeWaitResolve: null,
activeChild: null, activeChild: null,
activeProcessHandle: null,
activeChildTermination: null activeChildTermination: null
}; };
const emitRuntimeStep = (payload = {}) => { const emitRuntimeStep = (payload = {}) => {
@@ -469,6 +473,23 @@ class ScriptChainService {
message: 'Abbruch angefordert', message: 'Abbruch angefordert',
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert' currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
}); });
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel';
let handleCancelled = false;
if (typeof controlState.activeProcessHandle?.cancel === 'function') {
controlState.activeProcessHandle.cancel();
handleCancelled = true;
}
const signaled = terminateChildProcess(controlState.activeChild, { immediate: true });
if (!handleCancelled && !signaled) {
logger.warn('chain:cancel:no-signal-sent', {
chainId,
scriptPid: Number(controlState.activeChild?.pid || 0) || null
});
}
}
if (typeof appendLog === 'function') { if (typeof appendLog === 'function') {
try { try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`); await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
@@ -476,12 +497,6 @@ class ScriptChainService {
// ignore appendLog failures for control actions // ignore appendLog failures for control actions
} }
} }
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel';
terminateChildProcess(controlState.activeChild, { immediate: true });
}
return { accepted: true, message: 'Abbruch angefordert.' }; return { accepted: true, message: 'Abbruch angefordert.' };
}; };
const requestNextStep = async () => { const requestNextStep = async () => {
@@ -662,6 +677,7 @@ class ScriptChainService {
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line }); runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
} }
}); });
controlState.activeProcessHandle = processHandle;
let runError = null; let runError = null;
let exitCode = 0; let exitCode = 0;
let signal = null; let signal = null;
@@ -676,6 +692,7 @@ class ScriptChainService {
} }
const termination = controlState.activeChildTermination; const termination = controlState.activeChildTermination;
controlState.activeChild = null; controlState.activeChild = null;
controlState.activeProcessHandle = null;
controlState.activeChildTermination = null; controlState.activeChildTermination = null;
if (runError && exitCode === null && !termination) { if (runError && exitCode === null && !termination) {
throw runError; throw runError;
@@ -829,6 +846,7 @@ class ScriptChainService {
break; break;
} finally { } finally {
controlState.activeChild = null; controlState.activeChild = null;
controlState.activeProcessHandle = null;
controlState.activeChildTermination = null; controlState.activeChildTermination = null;
if (prepared?.cleanup) { if (prepared?.cleanup) {
await prepared.cleanup(); await prepared.cleanup();
+7 -6
View File
@@ -1,5 +1,4 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
@@ -8,6 +7,7 @@ const settingsService = require('./settingsService');
const runtimeActivityService = require('./runtimeActivityService'); const runtimeActivityService = require('./runtimeActivityService');
const { streamLines } = require('./processRunner'); const { streamLines } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta'); const { errorToMeta } = require('../utils/errorMeta');
const { tempDir } = require('../config');
const SCRIPT_NAME_MAX_LENGTH = 120; const SCRIPT_NAME_MAX_LENGTH = 120;
const SCRIPT_BODY_MAX_LENGTH = 200000; const SCRIPT_BODY_MAX_LENGTH = 200000;
@@ -532,8 +532,9 @@ class ScriptService {
async createExecutableScriptFile(script, context = {}) { async createExecutableScriptFile(script, context = {}) {
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`; const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
const scriptBody = normalizeScriptBody(script?.scriptBody); const scriptBody = normalizeScriptBody(script?.scriptBody);
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-')); fs.mkdirSync(tempDir, { recursive: true });
const scriptPath = path.join(tempDir, 'script.sh'); const scriptTempDir = await fs.promises.mkdtemp(path.join(tempDir, 'ripster-script-'));
const scriptPath = path.join(scriptTempDir, 'script.sh');
const wrapped = buildScriptWrapper(scriptBody, { const wrapped = buildScriptWrapper(scriptBody, {
...context, ...context,
scriptId: script?.id ?? context?.scriptId ?? '', scriptId: script?.id ?? context?.scriptId ?? '',
@@ -548,19 +549,19 @@ class ScriptService {
const cleanup = async () => { const cleanup = async () => {
try { try {
await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.rm(scriptTempDir, { recursive: true, force: true });
} catch (error) { } catch (error) {
logger.warn('script:temp-cleanup-failed', { logger.warn('script:temp-cleanup-failed', {
scriptId: script?.id ?? null, scriptId: script?.id ?? null,
scriptName: name, scriptName: name,
tempDir, tempDir: scriptTempDir,
error: errorToMeta(error) error: errorToMeta(error)
}); });
} }
}; };
return { return {
tempDir, tempDir: scriptTempDir,
scriptPath, scriptPath,
cmd: '/usr/bin/env', cmd: '/usr/bin/env',
args: ['bash', scriptPath], args: ['bash', scriptPath],
+20 -7
View File
@@ -1,5 +1,4 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const { spawn, spawnSync } = require('child_process'); const { spawn, spawnSync } = require('child_process');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
@@ -29,7 +28,8 @@ const {
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR, defaultDownloadDir: DEFAULT_DOWNLOAD_DIR,
defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR, defaultConverterRawDir: DEFAULT_CONVERTER_RAW_DIR,
defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR, defaultConverterMovieDir: DEFAULT_CONVERTER_MOVIE_DIR,
defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR defaultConverterAudioDir: DEFAULT_CONVERTER_AUDIO_DIR,
tempDir: DEFAULT_TEMP_DIR
} = require('../config'); } = require('../config');
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac']; const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
@@ -44,11 +44,15 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
]); ]);
const SENSITIVE_SETTING_KEYS = new Set([ const SENSITIVE_SETTING_KEYS = new Set([
'makemkv_registration_key', 'makemkv_registration_key',
'omdb_api_key',
'tmdb_api_read_access_token', 'tmdb_api_read_access_token',
'pushover_token', 'pushover_token',
'pushover_user' 'pushover_user'
]); ]);
const LEGACY_HIDDEN_SETTING_KEYS = new Set([
'omdb_api_key',
'omdb_default_type',
'omdb_timeout_ms'
]);
const IMMUTABLE_SETTING_KEYS = new Set([ const IMMUTABLE_SETTING_KEYS = new Set([
'makemkv_command', 'makemkv_command',
'mediainfo_command', 'mediainfo_command',
@@ -197,6 +201,11 @@ function normalizeBooleanSetting(rawValue, fallback = true) {
return toBoolean(normalizedRaw); return toBoolean(normalizedRaw);
} }
function isHiddenSettingKey(key) {
const normalized = String(key || '').trim().toLowerCase();
return LEGACY_HIDDEN_SETTING_KEYS.has(normalized);
}
function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) { function applyRuntimeServerConsoleLoggingSettingsFromMap(settingsMap = {}) {
const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {}; const source = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true); const enabled = normalizeBooleanSetting(source[SERVER_CONSOLE_LOG_OUTPUT_ENABLED_KEY], true);
@@ -856,7 +865,8 @@ class SettingsService {
async getSchemaRows() { async getSchemaRows() {
const db = await getDb(); const db = await getDb();
return db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC'); const rows = await db.all('SELECT * FROM settings_schema ORDER BY category ASC, order_index ASC');
return rows.filter((row) => !isHiddenSettingKey(row?.key));
} }
async getSettingsMap(options = {}) { async getSettingsMap(options = {}) {
@@ -1012,7 +1022,9 @@ class SettingsService {
` `
); );
return rows.map((row) => ({ return rows
.filter((row) => !isHiddenSettingKey(row?.key))
.map((row) => ({
key: row.key, key: row.key,
category: row.category, category: row.category,
label: row.label, label: row.label,
@@ -1025,7 +1037,7 @@ class SettingsService {
value: normalizeValueByType(row.type, row.current_value ?? row.default_value), value: normalizeValueByType(row.type, row.current_value ?? row.default_value),
orderIndex: row.order_index, orderIndex: row.order_index,
depends_on: row.depends_on ?? null depends_on: row.depends_on ?? null
})); }));
} }
async getFlatSettings(options = {}) { async getFlatSettings(options = {}) {
@@ -1554,7 +1566,8 @@ class SettingsService {
} }
const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`; const exportName = `ripster-export-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const exportFile = path.join(os.tmpdir(), `${exportName}.json`); fs.mkdirSync(DEFAULT_TEMP_DIR, { recursive: true });
const exportFile = path.join(DEFAULT_TEMP_DIR, `${exportName}.json`);
const args = [ const args = [
'--scan', '--scan',
'-i', '-i',
+3 -2
View File
@@ -1,9 +1,9 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const logger = require('./logger').child('TEMP_CLEANUP'); const logger = require('./logger').child('TEMP_CLEANUP');
const { tempDir } = require('../config');
const TMP_ROOT = os.tmpdir(); const TMP_ROOT = tempDir;
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000; const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
@@ -65,6 +65,7 @@ class TempCleanupService {
} }
async runSweep(reason = 'manual') { async runSweep(reason = 'manual') {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const deleted = []; const deleted = [];
const skipped = []; const skipped = [];
const failed = []; const failed = [];
+447 -64
View File
@@ -8,6 +8,49 @@ const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
const TMDB_TIMEOUT_MS = 15000; const TMDB_TIMEOUT_MS = 15000;
class TmdbService { class TmdbService {
hasCreditsPayload(details = null) {
const credits = details?.credits && typeof details.credits === 'object'
? details.credits
: null;
if (!credits) {
return false;
}
const crew = Array.isArray(credits.crew) ? credits.crew : [];
const cast = Array.isArray(credits.cast) ? credits.cast : [];
return crew.length > 0 || cast.length > 0;
}
mergeCreditsIntoDetails(details = null, credits = null) {
const sourceDetails = details && typeof details === 'object' ? details : {};
const sourceCredits = credits && typeof credits === 'object' ? credits : null;
if (!sourceCredits) {
return sourceDetails;
}
return {
...sourceDetails,
credits: {
...(sourceDetails?.credits && typeof sourceDetails.credits === 'object' ? sourceDetails.credits : {}),
...(sourceCredits && typeof sourceCredits === 'object' ? sourceCredits : {})
}
};
}
extractDirectorNames(crewValues = []) {
const crew = Array.isArray(crewValues) ? crewValues : [];
const byJob = this.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
if (byJob.length > 0) {
return byJob;
}
// Fallback for edge cases where "Director" job is missing in localized/partial payloads.
return this.normalizeNameList(
crew.filter((member) => String(member?.department || '').trim().toLowerCase() === 'directing'),
{ maxItems: 5 }
);
}
isAbortError(error) { isAbortError(error) {
const name = String(error?.name || '').trim().toLowerCase(); const name = String(error?.name || '').trim().toLowerCase();
const message = String(error?.message || '').trim().toLowerCase(); const message = String(error?.message || '').trim().toLowerCase();
@@ -103,9 +146,11 @@ class TmdbService {
async getConfig() { async getConfig() {
const settings = await settingsService.getSettingsMap(); const settings = await settingsService.getSettingsMap();
const fallbackLanguages = this.parseLanguageList(settings.dvd_series_fallback_languages);
return { return {
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null, readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE' language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE',
fallbackLanguages
}; };
} }
@@ -119,6 +164,45 @@ class TmdbService {
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE'; return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
} }
parseLanguageList(value) {
const source = Array.isArray(value)
? value
: String(value || '').split(',');
const normalized = [];
const seen = new Set();
for (const item of source) {
const lang = String(item || '').trim();
if (!lang) {
continue;
}
const dedupeKey = lang.toLowerCase();
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
normalized.push(lang);
}
return normalized;
}
buildLanguageCandidates(primaryLanguage = null, fallbackLanguages = []) {
const preferred = String(primaryLanguage || '').trim() || 'de-DE';
const fallback = this.parseLanguageList(fallbackLanguages);
const combined = [preferred, ...fallback];
return this.parseLanguageList(combined);
}
async resolveLanguageCandidates(options = {}) {
const config = await this.getConfig();
const explicitLanguage = String(options.language || '').trim() || null;
const explicitFallbackLanguages = options.fallbackLanguages !== undefined
? this.parseLanguageList(options.fallbackLanguages)
: null;
const primaryLanguage = explicitLanguage || String(config.language || '').trim() || 'de-DE';
const fallbackLanguages = explicitFallbackLanguages ?? config.fallbackLanguages;
return this.buildLanguageCandidates(primaryLanguage, fallbackLanguages);
}
async request(pathName, options = {}) { async request(pathName, options = {}) {
const config = await this.getConfig(); const config = await this.getConfig();
if (!config.readAccessToken) { if (!config.readAccessToken) {
@@ -177,47 +261,135 @@ class TmdbService {
if (!normalizedQuery || !(await this.isConfigured())) { if (!normalizedQuery || !(await this.isConfigured())) {
return []; return [];
} }
const language = await this.resolveLanguage(options.language); const languageCandidates = await this.resolveLanguageCandidates(options);
let lastFailureCode = null;
let lastFailureStatusCode = null;
let lastFailureMessage = null;
let data = null; for (const language of languageCandidates) {
try { let data = null;
data = await this.request('/search/tv', { try {
query: { data = await this.request('/search/tv', {
query: {
query: normalizedQuery,
first_air_date_year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
lastFailureCode = failureCode;
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
lastFailureMessage = error?.message || String(error);
logger.warn('search:failed', {
query: normalizedQuery, query: normalizedQuery,
first_air_date_year: options.year || undefined,
language, language,
page: options.page || 1, error: lastFailureMessage,
include_adult: false failureCode,
} statusCode: lastFailureStatusCode
}); });
} catch (error) { continue;
const failureCode = this.classifyRequestError(error); }
logger.warn('search:failed', {
query: normalizedQuery,
error: error?.message || String(error),
failureCode,
statusCode: Number(error?.statusCode || 0) || null
});
return this.attachFailureCode([], failureCode);
}
const rows = Array.isArray(data?.results) ? data.results : []; const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows const normalizedRows = rows
.map((row) => ({ .map((row) => ({
id: Number(row?.id || 0) || null, id: Number(row?.id || 0) || null,
title: String(row?.name || row?.original_name || '').trim() || null, title: String(row?.name || row?.original_name || '').trim() || null,
originalTitle: String(row?.original_name || '').trim() || null, originalTitle: String(row?.original_name || '').trim() || null,
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null, year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null, overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null, posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'), poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null, backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'), backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null, originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0 popularity: Number(row?.popularity || 0) || 0
})) }))
.filter((row) => row.id && row.title); .filter((row) => row.id && row.title);
return this.attachFailureCode(normalizedRows, null); if (normalizedRows.length > 0) {
return this.attachFailureCode(normalizedRows, null);
}
}
if (lastFailureCode) {
logger.warn('search:failed:all-languages', {
query: normalizedQuery,
error: lastFailureMessage,
failureCode: lastFailureCode,
statusCode: lastFailureStatusCode
});
}
return this.attachFailureCode([], lastFailureCode);
}
async searchMovies(query, options = {}) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery || !(await this.isConfigured())) {
return [];
}
const languageCandidates = await this.resolveLanguageCandidates(options);
let lastFailureCode = null;
let lastFailureStatusCode = null;
let lastFailureMessage = null;
for (const language of languageCandidates) {
let data = null;
try {
data = await this.request('/search/movie', {
query: {
query: normalizedQuery,
year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
lastFailureCode = failureCode;
lastFailureStatusCode = Number(error?.statusCode || 0) || null;
lastFailureMessage = error?.message || String(error);
logger.warn('movie:search:failed', {
query: normalizedQuery,
language,
error: lastFailureMessage,
failureCode,
statusCode: lastFailureStatusCode
});
continue;
}
const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows
.map((row) => ({
id: Number(row?.id || 0) || null,
title: String(row?.title || row?.original_title || '').trim() || null,
originalTitle: String(row?.original_title || '').trim() || null,
year: Number(String(row?.release_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0
}))
.filter((row) => row.id && row.title);
if (normalizedRows.length > 0) {
return this.attachFailureCode(normalizedRows, null);
}
}
if (lastFailureCode) {
logger.warn('movie:search:failed:all-languages', {
query: normalizedQuery,
error: lastFailureMessage,
failureCode: lastFailureCode,
statusCode: lastFailureStatusCode
});
}
return this.attachFailureCode([], lastFailureCode);
} }
async getSeriesDetails(seriesId, options = {}) { async getSeriesDetails(seriesId, options = {}) {
@@ -225,24 +397,111 @@ class TmdbService {
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null; return null;
} }
const language = await this.resolveLanguage(options.language); const languageCandidates = await this.resolveLanguageCandidates(options);
const appendToResponse = Array.isArray(options.appendToResponse) const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean) ? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: []; : [];
return this.request(`/tv/${Math.trunc(id)}`, { let fallbackDetails = null;
for (const language of languageCandidates) {
const details = await this.request(`/tv/${Math.trunc(id)}`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
}
}).catch((error) => {
logger.warn('series:details:failed', {
seriesId: Math.trunc(id),
language,
error: error?.message || String(error)
});
return null;
});
if (!details) {
continue;
}
const hasLocalizedPayload = Boolean(
String(details?.name || '').trim()
|| String(details?.overview || '').trim()
);
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
}
async getMovieDetails(movieId, options = {}) {
const id = Number(movieId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const languageCandidates = await this.resolveLanguageCandidates(options);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
let fallbackDetails = null;
for (const language of languageCandidates) {
const details = await this.request(`/movie/${Math.trunc(id)}`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
}
}).catch((error) => {
logger.warn('movie:details:failed', {
movieId: Math.trunc(id),
language,
error: error?.message || String(error)
});
return null;
});
if (!details) {
continue;
}
const hasLocalizedPayload = Boolean(
String(details?.title || '').trim()
|| String(details?.overview || '').trim()
);
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
}
async getMovieCredits(movieId, options = {}) {
const id = Number(movieId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/movie/${Math.trunc(id)}/credits`, {
query: { query: {
language, language
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
} }
}).catch((error) => { }).catch((error) => {
logger.warn('series:details:failed', { logger.warn('movie:credits:failed', {
seriesId: Math.trunc(id), movieId: Math.trunc(id),
error: error?.message || String(error) error: error?.message || String(error)
}); });
return null; return null;
}); });
} }
async getMovieDetailsWithCredits(movieId, options = {}) {
const details = await this.getMovieDetails(movieId, options);
if (!details) {
return null;
}
const ensureCredits = options?.ensureCredits === true;
if (!ensureCredits || this.hasCreditsPayload(details)) {
return details;
}
const credits = await this.getMovieCredits(movieId, options);
return this.mergeCreditsIntoDetails(details, credits);
}
async getEpisodeGroups(seriesId) { async getEpisodeGroups(seriesId) {
const id = Number(seriesId); const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) { if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
@@ -263,18 +522,31 @@ class TmdbService {
if (!normalizedId || !(await this.isConfigured())) { if (!normalizedId || !(await this.isConfigured())) {
return null; return null;
} }
const language = await this.resolveLanguage(options.language); const languageCandidates = await this.resolveLanguageCandidates(options);
return this.request(`/tv/episode_group/${normalizedId}`, { let fallbackDetails = null;
query: { for (const language of languageCandidates) {
language const details = await this.request(`/tv/episode_group/${normalizedId}`, {
} query: {
}).catch((error) => { language
logger.warn('episode-group:details:failed', { }
groupId: normalizedId, }).catch((error) => {
error: error?.message || String(error) logger.warn('episode-group:details:failed', {
groupId: normalizedId,
language,
error: error?.message || String(error)
});
return null;
}); });
return null; if (!details) {
}); continue;
}
const hasLocalizedPayload = Boolean(String(details?.name || '').trim() || String(details?.description || '').trim());
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
} }
async getSeasonDetails(seriesId, seasonNumber, options = {}) { async getSeasonDetails(seriesId, seasonNumber, options = {}) {
@@ -283,12 +555,28 @@ class TmdbService {
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) { if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
return null; return null;
} }
const language = await this.resolveLanguage(options.language); const languageCandidates = await this.resolveLanguageCandidates(options);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, { let fallbackDetails = null;
query: { for (const language of languageCandidates) {
language const details = await this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
query: {
language
}
}).catch(() => null);
if (!details) {
continue;
} }
}).catch(() => null); const hasLocalizedPayload = Boolean(
String(details?.name || '').trim()
|| String(details?.overview || '').trim()
|| (Array.isArray(details?.episodes) && details.episodes.length > 0)
);
if (hasLocalizedPayload) {
return details;
}
fallbackDetails = fallbackDetails || details;
}
return fallbackDetails;
} }
async getSeasonCredits(seriesId, seasonNumber, options = {}) { async getSeasonCredits(seriesId, seasonNumber, options = {}) {
@@ -343,10 +631,7 @@ class TmdbService {
const creators = Array.isArray(details?.created_by) ? details.created_by : []; const creators = Array.isArray(details?.created_by) ? details.created_by : [];
const genres = Array.isArray(details?.genres) ? details.genres : []; const genres = Array.isArray(details?.genres) ? details.genres : [];
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time); const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
const directorNames = this.normalizeNameList( const directorNames = this.extractDirectorNames(crew);
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 }); const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
const actorNames = this.normalizeNameList(cast, { maxItems: 10 }); const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
const genreNames = this.normalizeNameList(genres, { maxItems: 10 }); const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
@@ -375,6 +660,39 @@ class TmdbService {
}; };
} }
buildMovieDetailsSummary(movieDetails = null) {
const details = movieDetails && typeof movieDetails === 'object' ? movieDetails : {};
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
const genres = Array.isArray(details?.genres) ? details.genres : [];
const directorNames = this.extractDirectorNames(crew);
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
const voteAverageRaw = Number(details?.vote_average || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const voteCount = Number(details?.vote_count || 0);
const imdbId = String(details?.external_ids?.imdb_id || details?.imdb_id || '').trim() || null;
const releaseDate = String(details?.release_date || '').trim() || null;
const runtime = this.formatRuntimeLabel(details?.runtime);
return {
director: directorNames.length > 0 ? directorNames.join(', ') : null,
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
runtime,
runtimeLabel: runtime,
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
voteAverage,
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
rottenTomatoes: null,
imdbId,
tmdbId: Number(details?.id || 0) || null,
releaseDate
};
}
buildSeriesMetadataCandidate(series = {}, options = {}) { buildSeriesMetadataCandidate(series = {}, options = {}) {
const season = series?.season && typeof series.season === 'object' const season = series?.season && typeof series.season === 'object'
? series.season ? series.season
@@ -404,6 +722,35 @@ class TmdbService {
}; };
} }
buildMovieMetadataCandidate(movie = {}, options = {}) {
const detailsSummary = options?.detailsSummary && typeof options.detailsSummary === 'object'
? options.detailsSummary
: null;
const tmdbId = Number(movie?.id || movie?.tmdbId || 0) || null;
const releaseDate = String(movie?.releaseDate || detailsSummary?.releaseDate || '').trim() || null;
const year = Number(movie?.year || Number(String(releaseDate || '').slice(0, 4)) || 0) || null;
const imdbId = String(movie?.imdbId || detailsSummary?.imdbId || '').trim() || null;
const providerId = tmdbId !== null ? `tmdb:${tmdbId}` : null;
return {
provider: 'tmdb',
providerId,
metadataKind: 'movie',
workflowKind: 'film',
tmdbId,
imdbId,
title: String(movie?.title || movie?.originalTitle || '').trim() || null,
originalTitle: String(movie?.originalTitle || '').trim() || null,
year,
overview: String(movie?.overview || '').trim() || null,
poster: movie?.poster || this.buildImageUrl(movie?.posterPath, 'w342'),
backdrop: movie?.backdrop || this.buildImageUrl(movie?.backdropPath, 'w780'),
runtime: detailsSummary?.runtime || null,
genre: detailsSummary?.genre || null,
voteAverage: detailsSummary?.voteAverage ?? null
};
}
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) { buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {}; const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
const seasons = Array.isArray(details.seasons) ? details.seasons : []; const seasons = Array.isArray(details.seasons) ? details.seasons : [];
@@ -483,6 +830,42 @@ class TmdbService {
})); }));
return this.attachFailureCode(normalized, failureCode); return this.attachFailureCode(normalized, failureCode);
} }
async searchMoviesWithDetails(query, options = {}) {
const candidates = await this.searchMovies(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) {
return this.attachFailureCode([], failureCode);
}
const limit = Math.max(1, Math.min(15, Number(options.limit || 8)));
const selectedCandidates = candidates.slice(0, limit);
const language = await this.resolveLanguage(options.language);
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
const details = await this.getMovieDetailsWithCredits(candidate.id, {
language,
appendToResponse: ['credits', 'external_ids'],
ensureCredits: true
});
const summary = this.buildMovieDetailsSummary(details);
const normalized = this.buildMovieMetadataCandidate({
...candidate,
releaseDate: details?.release_date || null,
imdbId: summary?.imdbId || null,
posterPath: details?.poster_path || candidate?.posterPath || null
}, {
detailsSummary: summary
});
return {
...normalized,
tmdbDetails: summary && typeof summary === 'object' ? summary : null
};
}));
const normalized = expanded.filter((item) => item?.tmdbId && item?.title);
return this.attachFailureCode(normalized, failureCode);
}
} }
module.exports = new TmdbService(); module.exports = new TmdbService();
+1 -11
View File
@@ -32,6 +32,7 @@ CREATE TABLE jobs (
poster_url TEXT, poster_url TEXT,
omdb_json TEXT, omdb_json TEXT,
selected_from_omdb INTEGER DEFAULT 0, selected_from_omdb INTEGER DEFAULT 0,
migrate_tmdb INTEGER NOT NULL DEFAULT 0,
start_time TEXT, start_time TEXT,
end_time TEXT, end_time TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
@@ -429,10 +430,6 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220); VALUES ('handbrake_restart_delete_incomplete_output', 'Tools', 'Encode-Neustart: unvollständige Ausgabe löschen', 'boolean', 1, 'Wenn aktiv, wird bei "Encode neu starten" der bisherige (nicht erfolgreiche) Output vor Start entfernt.', 'true', '[]', '{}', 220);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_restart_delete_incomplete_output', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('handbrake_pre_metadata_scan_enabled', 'Tools', 'Vorab-HandBrake-Scan vor Metadaten', 'boolean', 1, 'Wenn deaktiviert, wird der erste HandBrake-Scan vor der Metadatenauswahl übersprungen.', 'true', '[]', '{}', 221);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('handbrake_pre_metadata_scan_enabled', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225); VALUES ('pipeline_max_parallel_jobs', 'Tools', 'Max. parallele Film/Video Encodes', 'number', 1, 'Maximale Anzahl parallel laufender Film/Video Encode-Jobs. Gilt zusätzlich zum Gesamtlimit.', '1', '[]', '{"min":1,"max":12}', 225);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pipeline_max_parallel_jobs', '1');
@@ -635,18 +632,11 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405); VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410); VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('musicbrainz_enabled', 'Metadaten', 'MusicBrainz aktiviert', 'boolean', 1, 'MusicBrainz-Metadatensuche für CDs aktivieren.', 'true', '[]', '{}', 420);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('musicbrainz_enabled', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430); VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true');
+3 -3
View File
@@ -142,9 +142,9 @@ Typische Auswirkungen:
| GUI-Feld | Key | Typ | Default | Wirkung im System | | GUI-Feld | Key | Typ | Default | Wirkung im System |
|---|---|---|---|---| |---|---|---|---|---|
| `OMDb API Key` | `omdb_api_key` | `string` | `leer` | API Key für Metadatensuche. | | `OMDb API Key` | `omdb_api_key` | `string` | `leer` | API Key für Metadatensuche. |
| `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Read Access Token für Serien-Metadaten über TheMovieDB (TMDb). | | `TMDb Read Access Token` | `tmdb_api_read_access_token` | `string` | `leer` | Read Access Token für Film- und Serien-Metadaten über TheMovieDB (TMDb). |
| `DVD Serien-Sprache` | `dvd_series_language` | `string` | `de-DE` | Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US. | | `Film-Sprache` | `dvd_series_language` | `string` | `de-DE` | Bevorzugte TMDb-Sprache für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE oder en-US. |
| `DVD Serien-Fallback-Sprachen` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR. | | `Fallback Sprache` | `dvd_series_fallback_languages` | `string` | `de-DE,en-US` | Kommagetrennte TMDb-Fallback-Sprachen für Film- und Serienmetadaten (DVD/Blu-ray), z.B. de-DE,en-US,fr-FR. |
| `DVD Serien-Ordnung` | `dvd_series_order_preference` | `select` | `episode_group` | Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden. | | `DVD Serien-Ordnung` | `dvd_series_order_preference` | `select` | `episode_group` | Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden. |
| `DVD Disc-Profile wiederverwenden` | `dvd_series_reuse_disc_profiles` | `boolean` | `true` | Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen. | | `DVD Disc-Profile wiederverwenden` | `dvd_series_reuse_disc_profiles` | `boolean` | `true` | Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen. |
| `OMDb Typ` | `omdb_default_type` | `select` | `movie` | Vorauswahl für Suche. | | `OMDb Typ` | `omdb_default_type` | `select` | `movie` | Vorauswahl für Suche. |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.16.1-6", "version": "0.16.1-7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.16.1-6", "version": "0.16.1-7",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.16.1-6", "version": "0.16.1-7",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+114 -149
View File
@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Outlet, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { ConfirmDialog } from 'primereact/confirmdialog'; import { ConfirmDialog } from 'primereact/confirmdialog';
import { api } from './api/client'; import { api } from './api/client';
@@ -14,6 +12,7 @@ import DatabasePage from './pages/DatabasePage';
import DownloadsPage from './pages/DownloadsPage'; import DownloadsPage from './pages/DownloadsPage';
import ConverterPage from './pages/ConverterPage'; import ConverterPage from './pages/ConverterPage';
import AudiobooksPage from './pages/AudiobooksPage'; import AudiobooksPage from './pages/AudiobooksPage';
import TmdbMigrationPage from './pages/TmdbMigrationPage';
function normalizeJobId(value) { function normalizeJobId(value) {
const parsed = Number(value); const parsed = Number(value);
@@ -23,6 +22,20 @@ function normalizeJobId(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
function clampPercent(value) { function clampPercent(value) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {
@@ -51,25 +64,6 @@ function isTerminalStage(value) {
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED'; return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
} }
function formatBytes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 'n/a';
}
if (parsed === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
let current = parsed;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
const digits = unitIndex <= 1 ? 0 : 2;
return `${current.toFixed(digits)} ${units[unitIndex]}`;
}
function createInitialAudiobookUploadState() { function createInitialAudiobookUploadState() {
return { return {
phase: 'idle', phase: 'idle',
@@ -80,28 +74,14 @@ function createInitialAudiobookUploadState() {
statusText: null, statusText: null,
errorMessage: null, errorMessage: null,
jobId: null, jobId: null,
uploadSessionId: null,
fileLastModified: null,
fileFingerprint: null,
startedAt: null, startedAt: null,
finishedAt: null finishedAt: null
}; };
} }
function getAudiobookUploadTagMeta(phase) {
const normalized = String(phase || '').trim().toLowerCase();
if (normalized === 'uploading') {
return { label: 'Upload läuft', severity: 'warning' };
}
if (normalized === 'processing') {
return { label: 'Server verarbeitet', severity: 'info' };
}
if (normalized === 'completed') {
return { label: 'Bereit', severity: 'success' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Inaktiv', severity: 'secondary' };
}
function getDownloadIndicatorMeta(summary) { function getDownloadIndicatorMeta(summary) {
const activeCount = Number(summary?.activeCount || 0); const activeCount = Number(summary?.activeCount || 0);
const failedCount = Number(summary?.failedCount || 0); const failedCount = Number(summary?.failedCount || 0);
@@ -144,12 +124,13 @@ function App() {
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0); const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0); const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
const [downloadSummary, setDownloadSummary] = useState(null); const [downloadSummary, setDownloadSummary] = useState(null);
const [pendingRipperJobId, setPendingRipperJobId] = useState(null);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const globalToastRef = useRef(null); const globalToastRef = useRef(null);
const prevCdDrivesRef = useRef({}); const prevCdDrivesRef = useRef({});
const prevJobProgressStatesRef = useRef({});
const hardwareWarmupRef = useRef({ timer: null, attempts: 0 }); const hardwareWarmupRef = useRef({ timer: null, attempts: 0 });
const audiobookUploadAbortRef = useRef(null);
const clearHardwareWarmupRetry = () => { const clearHardwareWarmupRetry = () => {
if (hardwareWarmupRef.current?.timer) { if (hardwareWarmupRef.current?.timer) {
@@ -198,6 +179,39 @@ function App() {
prevCdDrivesRef.current = current; prevCdDrivesRef.current = current;
}, [pipeline?.cdDrives]); }, [pipeline?.cdDrives]);
// Trigger a jobs refresh when a tracked job transitions into a terminal
// state through PIPELINE_PROGRESS, so Ripper/History update immediately.
useEffect(() => {
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
? pipeline.jobProgress
: {};
const previousStates = prevJobProgressStatesRef.current || {};
const nextStates = {};
let shouldRefresh = false;
for (const [jobId, entry] of Object.entries(current)) {
const currentState = normalizeStage(entry?.state);
const previousState = normalizeStage(previousStates[jobId]);
if (currentState) {
nextStates[jobId] = currentState;
}
if (
currentState
&& isTerminalStage(currentState)
&& currentState !== previousState
&& !isTerminalStage(previousState)
) {
shouldRefresh = true;
}
}
prevJobProgressStatesRef.current = nextStates;
if (shouldRefresh) {
setRipperJobsRefreshToken((token) => token + 1);
setHistoryJobsRefreshToken((token) => token + 1);
}
}, [pipeline?.jobProgress]);
const refreshPipeline = async () => { const refreshPipeline = async () => {
const response = await api.getPipelineState(); const response = await api.getPipelineState();
setPipeline(response.pipeline); setPipeline(response.pipeline);
@@ -221,83 +235,84 @@ function App() {
return response; return response;
}; };
const clearAudiobookUpload = () => {
setAudiobookUpload(createInitialAudiobookUploadState());
};
const handleAudiobookUpload = async (file, payload = {}) => { const handleAudiobookUpload = async (file, payload = {}) => {
if (!file) { if (!file) {
throw new Error('Bitte zuerst eine AAX-Datei auswählen.'); throw new Error('Bitte zuerst eine AAX-Datei auswählen.');
} }
const fileName = String(file.name || '').trim() || 'upload.aax';
const fallbackTotalBytes = Number.isFinite(Number(file.size)) && Number(file.size) > 0 const totalBytes = Number(file.size || 0);
? Number(file.size) const abortController = new AbortController();
: 0; audiobookUploadAbortRef.current = abortController;
setAudiobookUpload({ setAudiobookUpload({
phase: 'uploading', phase: 'uploading',
fileName: String(file.name || '').trim() || 'upload.aax', fileName,
loadedBytes: 0, loadedBytes: 0,
totalBytes: fallbackTotalBytes, totalBytes,
progressPercent: 0, progressPercent: 0,
statusText: 'AAX-Datei wird hochgeladen ...', statusText: 'AAX-Datei wird hochgeladen ...',
errorMessage: null, errorMessage: null,
jobId: null, jobId: null,
uploadSessionId: null,
fileLastModified: Number.isFinite(Number(file.lastModified))
? Math.trunc(Number(file.lastModified))
: null,
fileFingerprint: null,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
finishedAt: null finishedAt: null
}); });
try { try {
const response = await api.uploadAudiobook(file, payload, { const response = await api.uploadAudiobook(file, payload, {
signal: abortController.signal,
onProgress: ({ loaded, total, percent }) => { onProgress: ({ loaded, total, percent }) => {
const nextLoaded = Number.isFinite(Number(loaded)) && Number(loaded) >= 0 const loadedBytes = Number.isFinite(Number(loaded)) ? Number(loaded) : 0;
? Number(loaded) const totalSize = Number.isFinite(Number(total)) && Number(total) > 0 ? Number(total) : totalBytes;
: 0; const progressPercent = Number.isFinite(Number(percent))
const nextTotal = Number.isFinite(Number(total)) && Number(total) > 0
? Number(total)
: fallbackTotalBytes;
const nextPercent = Number.isFinite(Number(percent))
? clampPercent(Number(percent)) ? clampPercent(Number(percent))
: (nextTotal > 0 ? clampPercent((nextLoaded / nextTotal) * 100) : 0); : (totalSize > 0 ? clampPercent((loadedBytes / totalSize) * 100) : 0);
const transferComplete = nextTotal > 0 && nextLoaded >= nextTotal;
setAudiobookUpload((prev) => ({ setAudiobookUpload((prev) => ({
...prev, ...prev,
phase: transferComplete ? 'processing' : 'uploading', phase: 'uploading',
loadedBytes: nextLoaded, loadedBytes,
totalBytes: nextTotal, totalBytes: totalSize,
progressPercent: nextPercent, progressPercent,
statusText: transferComplete statusText: 'AAX-Datei wird hochgeladen ...',
? 'Upload abgeschlossen, AAX wird serverseitig verarbeitet ...' errorMessage: null
: 'AAX-Datei wird hochgeladen ...'
})); }));
} }
}); });
const uploadedJobId = extractUploadJobIdFromResponse(response);
const uploadedJobId = normalizeJobId(response?.result?.jobId); const started = Boolean(response?.result?.started);
const queued = Boolean(response?.result?.queued);
await refreshPipeline().catch(() => null); await refreshPipeline().catch(() => null);
setRipperJobsRefreshToken((prev) => prev + 1); setRipperJobsRefreshToken((prev) => prev + 1);
setHistoryJobsRefreshToken((prev) => prev + 1); setHistoryJobsRefreshToken((prev) => prev + 1);
if (uploadedJobId) {
setPendingRipperJobId(uploadedJobId);
}
setAudiobookUpload((prev) => ({ setAudiobookUpload((prev) => ({
...prev, ...prev,
phase: 'completed', phase: 'completed',
loadedBytes: prev.totalBytes || prev.loadedBytes || fallbackTotalBytes, loadedBytes: prev.totalBytes || totalBytes,
totalBytes: prev.totalBytes || fallbackTotalBytes, totalBytes: prev.totalBytes || totalBytes,
progressPercent: 100, progressPercent: 100,
statusText: uploadedJobId statusText: uploadedJobId
? `Upload abgeschlossen. Job #${uploadedJobId} ist bereit fuer den naechsten Schritt.` ? (
queued
? `Upload abgeschlossen. Job #${uploadedJobId} wurde in die Queue eingereiht.`
: (started
? `Upload abgeschlossen. Job #${uploadedJobId} wurde gestartet.`
: `Upload abgeschlossen. Job #${uploadedJobId} ist bereit.`)
)
: 'Upload abgeschlossen.', : 'Upload abgeschlossen.',
errorMessage: null, errorMessage: null,
jobId: uploadedJobId, jobId: uploadedJobId,
finishedAt: new Date().toISOString() finishedAt: new Date().toISOString()
})); }));
return response; return response;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') {
setAudiobookUpload(createInitialAudiobookUploadState());
throw error;
}
setAudiobookUpload((prev) => ({ setAudiobookUpload((prev) => ({
...prev, ...prev,
phase: 'error', phase: 'error',
@@ -306,17 +321,20 @@ function App() {
finishedAt: new Date().toISOString() finishedAt: new Date().toISOString()
})); }));
throw error; throw error;
} finally {
if (audiobookUploadAbortRef.current === abortController) {
audiobookUploadAbortRef.current = null;
}
} }
}; };
const handleRipperJobFocusConsumed = (jobId) => { const handleCancelAudiobookUpload = () => {
const normalizedJobId = normalizeJobId(jobId); const activeAbortController = audiobookUploadAbortRef.current;
if (!normalizedJobId) { if (activeAbortController) {
activeAbortController.abort();
return; return;
} }
setPendingRipperJobId((prev) => ( setAudiobookUpload(createInitialAudiobookUploadState());
normalizeJobId(prev) === normalizedJobId ? null : prev
));
}; };
useEffect(() => { useEffect(() => {
@@ -401,6 +419,11 @@ function App() {
) )
) )
); );
const allowRunningTransitionFromTerminal = Boolean(
normalizedProgressJobId
&& incomingIsRunning
&& prevActiveJobId === normalizedProgressJobId
);
// Ignore late/stale progress packets that arrive after a terminal state // Ignore late/stale progress packets that arrive after a terminal state
// or regress a job from an interactive/ready state back into an // or regress a job from an interactive/ready state back into an
@@ -408,6 +431,7 @@ function App() {
if ( if (
normalizedProgressJobId normalizedProgressJobId
&& !incomingIsTerminal && !incomingIsTerminal
&& !allowRunningTransitionFromTerminal
&& ( && (
prevJobProgressIsTerminal prevJobProgressIsTerminal
|| matchingCdDriveIsTerminal || matchingCdDriveIsTerminal
@@ -421,7 +445,9 @@ function App() {
if (progressJobId != null) { if (progressJobId != null) {
const previousJobProgress = prev?.jobProgress?.[progressJobId] || {}; const previousJobProgress = prev?.jobProgress?.[progressJobId] || {};
const previousJobStage = normalizeStage(previousJobProgress?.state); const previousJobStage = normalizeStage(previousJobProgress?.state);
const keepPreviousJobStage = isTerminalStage(previousJobStage) && !incomingIsTerminal; const keepPreviousJobStage = isTerminalStage(previousJobStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
const mergedJobContext = contextPatch const mergedJobContext = contextPatch
? { ? {
...(previousJobProgress?.context && typeof previousJobProgress.context === 'object' ...(previousJobProgress?.context && typeof previousJobProgress.context === 'object'
@@ -445,7 +471,9 @@ function App() {
}; };
} }
if (progressJobId === prev?.activeJobId || progressJobId == null) { if (progressJobId === prev?.activeJobId || progressJobId == null) {
const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage) && !incomingIsTerminal; const keepPreviousGlobalStage = isTerminalStage(previousGlobalStage)
&& !incomingIsTerminal
&& !allowRunningTransitionFromTerminal;
next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state); next.state = keepPreviousGlobalStage ? prev?.state : (payload.state ?? prev?.state);
next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress); next.progress = keepPreviousGlobalStage ? prev?.progress : (payload.progress ?? prev?.progress);
next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta); next.eta = keepPreviousGlobalStage ? prev?.eta : (payload.eta ?? prev?.eta);
@@ -613,18 +641,6 @@ function App() {
{ label: 'Downloads', path: '/downloads' }, { label: 'Downloads', path: '/downloads' },
...(expertMode ? [{ label: 'Database', path: '/database' }] : []) ...(expertMode ? [{ label: 'Database', path: '/database' }] : [])
]; ];
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const showAudiobookUploadBanner = uploadPhase !== 'idle';
const uploadProgress = clampPercent(audiobookUpload?.progressPercent);
const uploadTagMeta = getAudiobookUploadTagMeta(uploadPhase);
const uploadLoadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const uploadTotalBytes = Number(audiobookUpload?.totalBytes || 0);
const uploadBytesLabel = uploadTotalBytes > 0
? `${formatBytes(uploadLoadedBytes)} / ${formatBytes(uploadTotalBytes)}`
: (uploadLoadedBytes > 0 ? `${formatBytes(uploadLoadedBytes)} hochgeladen` : null);
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
const isAudiobooksRoute = location.pathname === '/audiobooks';
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary); const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
const isNavActive = (path) => { const isNavActive = (path) => {
if (path === '/ripper') { if (path === '/ripper') {
@@ -664,57 +680,6 @@ function App() {
</div> </div>
</header> </header>
{showAudiobookUploadBanner ? (
<section className={`app-upload-banner phase-${uploadPhase}`}>
<div className="app-upload-banner-copy">
<div className="app-upload-banner-head">
<strong>Audiobook Upload</strong>
<Tag value={uploadTagMeta.label} severity={uploadTagMeta.severity} />
</div>
<small>{audiobookUpload?.statusText || 'Upload aktiv.'}</small>
{audiobookUpload?.fileName ? <small>Datei: {audiobookUpload.fileName}</small> : null}
</div>
<div
className="app-upload-banner-progress"
aria-label={`Audiobook Upload ${Math.trunc(uploadProgress)} Prozent`}
>
<ProgressBar value={uploadProgress} showValue={false} />
<small>
{uploadPhase === 'processing'
? `100% | ${uploadBytesLabel || 'Upload abgeschlossen'}`
: uploadBytesLabel
? `${Math.trunc(uploadProgress)}% | ${uploadBytesLabel}`
: `${Math.trunc(uploadProgress)}%`}
</small>
</div>
<div className="app-upload-banner-actions">
{hasUploadedJob && !isAudiobooksRoute ? (
<Button
label="Zu Audiobooks"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => {
navigate('/audiobooks');
}}
/>
) : null}
{canDismissUploadBanner ? (
<Button
icon="pi pi-times"
rounded
text
severity="secondary"
aria-label="Upload-Hinweis schliessen"
onClick={clearAudiobookUpload}
/>
) : null}
</div>
</section>
) : null}
<main className="app-main"> <main className="app-main">
<Routes> <Routes>
<Route <Route
@@ -726,8 +691,6 @@ function App() {
lastDiscEvent={lastDiscEvent} lastDiscEvent={lastDiscEvent}
refreshPipeline={refreshPipeline} refreshPipeline={refreshPipeline}
jobsRefreshToken={ripperJobsRefreshToken} jobsRefreshToken={ripperJobsRefreshToken}
pendingExpandedJobId={pendingRipperJobId}
onPendingExpandedJobHandled={handleRipperJobFocusConsumed}
downloadSummary={downloadSummary} downloadSummary={downloadSummary}
> >
<Outlet /> <Outlet />
@@ -738,6 +701,7 @@ function App() {
<Route path="ripper" element={null} /> <Route path="ripper" element={null} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} /> <Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
<Route path="tmdb-migration" element={<TmdbMigrationPage />} />
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} /> <Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
<Route path="database" element={<DatabasePage />} /> <Route path="database" element={<DatabasePage />} />
<Route path="converter" element={<ConverterPage />} /> <Route path="converter" element={<ConverterPage />} />
@@ -747,6 +711,7 @@ function App() {
<AudiobooksPage <AudiobooksPage
audiobookUpload={audiobookUpload} audiobookUpload={audiobookUpload}
onAudiobookUpload={handleAudiobookUpload} onAudiobookUpload={handleAudiobookUpload}
onCancelAudiobookUpload={handleCancelAudiobookUpload}
/> />
} }
/> />
+44 -9
View File
@@ -267,6 +267,12 @@ async function requestWithXhr(path, options = {}) {
xhr.onerror = () => { xhr.onerror = () => {
settle(() => { settle(() => {
if (Number(xhr.status || 0) === 0) {
const abortLikeError = new Error('Request unterbrochen.');
abortLikeError.name = 'AbortError';
reject(abortLikeError);
return;
}
reject(new Error('Netzwerkfehler')); reject(new Error('Netzwerkfehler'));
}); });
}; };
@@ -564,8 +570,8 @@ export const api = {
}); });
return result; return result;
}, },
searchOmdb(q) { searchTmdbMovie(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); return request(`/pipeline/tmdb/movie/search?q=${encodeURIComponent(q)}`);
}, },
searchTmdbSeries(q, seasonNumber = null) { searchTmdbSeries(q, seasonNumber = null) {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -575,8 +581,14 @@ export const api = {
} }
return request(`/pipeline/tmdb/series/search?${params.toString()}`); return request(`/pipeline/tmdb/series/search?${params.toString()}`);
}, },
searchMusicBrainz(q) { searchMusicBrainz(q, options = {}) {
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`); const params = new URLSearchParams();
params.set('q', String(q || ''));
const expectedTrackCount = Number(options?.trackCount);
if (Number.isFinite(expectedTrackCount) && expectedTrackCount > 0) {
params.set('trackCount', String(Math.trunc(expectedTrackCount)));
}
return request(`/pipeline/cd/musicbrainz/search?${params.toString()}`);
}, },
getMusicBrainzRelease(mbId) { getMusicBrainzRelease(mbId) {
return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`); return request(`/pipeline/cd/musicbrainz/release/${encodeURIComponent(String(mbId || '').trim())}`);
@@ -628,9 +640,6 @@ export const api = {
getAudiobookJobs() { getAudiobookJobs() {
return request('/pipeline/audiobook/jobs'); return request('/pipeline/audiobook/jobs');
}, },
getAudiobookOutputTree() {
return request('/pipeline/audiobook/output-tree');
},
async selectMetadata(payload) { async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', { const result = await request('/pipeline/select-metadata', {
method: 'POST', method: 'POST',
@@ -816,6 +825,24 @@ export const api = {
const suffix = query.toString() ? `?${query.toString()}` : ''; const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history${suffix}`); return request(`/history${suffix}`);
}, },
getTmdbMigrationPendingJobs(params = {}) {
const query = new URLSearchParams();
if (Number.isFinite(Number(params.limit)) && Number(params.limit) > 0) {
query.set('limit', String(Math.trunc(Number(params.limit))));
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history/tmdb-migration/pending${suffix}`);
},
async setJobTmdbMigrationFlag(jobId, migrated = false) {
const result = await request(`/history/${jobId}/tmdb-migration`, {
method: 'POST',
body: JSON.stringify({
migrated: Boolean(migrated)
})
});
afterMutationInvalidate(['/history']);
return result;
},
getOrphanRawFolders() { getOrphanRawFolders() {
return request('/history/orphan-raw'); return request('/history/orphan-raw');
}, },
@@ -827,8 +854,16 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']); afterMutationInvalidate(['/history', '/pipeline/queue']);
return result; return result;
}, },
async assignJobOmdb(jobId, payload = {}) { async deleteOrphanRawFolder(rawPath) {
const result = await request(`/history/${jobId}/omdb/assign`, { const result = await request('/history/orphan-raw/delete', {
method: 'POST',
body: JSON.stringify({ rawPath })
});
afterMutationInvalidate(['/history']);
return result;
},
async assignJobMetadata(jobId, payload = {}) {
const result = await request(`/history/${jobId}/metadata/assign`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload || {}) body: JSON.stringify(payload || {})
}); });
+560 -89
View File
@@ -3,10 +3,10 @@ import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown'; import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider'; import { Slider } from 'primereact/slider';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas'; import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { api } from '../api/client';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
function normalizeJobId(value) { function normalizeJobId(value) {
@@ -84,6 +84,108 @@ function normalizeEditableChapters(chapters = []) {
}); });
} }
function formatChapterDuration(startSecondsValue, endSecondsValue) {
const startSeconds = Number(startSecondsValue || 0);
const endSeconds = Number(endSecondsValue || 0);
if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds) || endSeconds <= startSeconds) {
return '-';
}
return formatChapterTime(endSeconds - startSeconds);
}
function normalizeTrackStageStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'done' || raw === 'complete' || raw === 'completed' || raw === 'ok' || raw === 'success') {
return 'done';
}
if (raw === 'in_progress' || raw === 'running' || raw === 'active' || raw === 'processing') {
return 'in_progress';
}
if (raw === 'error' || raw === 'failed' || raw === 'cancelled' || raw === 'aborted') {
return 'error';
}
return 'pending';
}
function trackStatusTagMeta(value) {
const normalized = normalizeTrackStageStatus(value);
if (normalized === 'done') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'in_progress') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'error') {
return { label: 'Fehler', severity: 'danger' };
}
return { label: 'Offen', severity: 'secondary' };
}
function normalizeScriptId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeIdList(values, kind = 'script') {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = kind === 'chain' ? normalizeChainId(value) : normalizeScriptId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function buildEncodeItemsFromConfig(config, phase) {
const source = config && typeof config === 'object' ? config : {};
const prefix = phase === 'post' ? 'post' : 'pre';
const explicitItems = Array.isArray(source[`${prefix}EncodeItems`]) ? source[`${prefix}EncodeItems`] : [];
const fromExplicit = explicitItems
.map((item) => {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'script' && type !== 'chain') {
return null;
}
const id = type === 'chain'
? normalizeChainId(item?.id ?? item?.chainId)
: normalizeScriptId(item?.id ?? item?.scriptId);
if (!id) {
return null;
}
return { type, id };
})
.filter(Boolean);
if (fromExplicit.length > 0) {
return fromExplicit;
}
const scriptIds = normalizeIdList(source[`${prefix}EncodeScriptIds`], 'script');
const chainIds = normalizeIdList(source[`${prefix}EncodeChainIds`], 'chain');
return [
...scriptIds.map((id) => ({ type: 'script', id })),
...chainIds.map((id) => ({ type: 'chain', id }))
];
}
function FormatField({ field, value, onChange, disabled }) { function FormatField({ field, value, onChange, disabled }) {
if (field.type === 'slider') { if (field.type === 'slider') {
return ( return (
@@ -128,7 +230,6 @@ export default function AudiobookConfigPanel({
pipeline, pipeline,
onStart, onStart,
onCancel, onCancel,
onRetry,
onDeleteJob, onDeleteJob,
busy busy
}) { }) {
@@ -149,6 +250,18 @@ export default function AudiobookConfigPanel({
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions)); const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters)); const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false); const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
const [scriptCatalog, setScriptCatalog] = useState([]);
const [chainCatalog, setChainCatalog] = useState([]);
const [preRipItems, setPreRipItems] = useState([]);
const [postRipItems, setPostRipItems] = useState([]);
const audiobookConfigKey = JSON.stringify({
preEncodeScriptIds: normalizeIdList(audiobookConfig?.preEncodeScriptIds, 'script'),
postEncodeScriptIds: normalizeIdList(audiobookConfig?.postEncodeScriptIds, 'script'),
preEncodeChainIds: normalizeIdList(audiobookConfig?.preEncodeChainIds, 'chain'),
postEncodeChainIds: normalizeIdList(audiobookConfig?.postEncodeChainIds, 'chain'),
preEncodeItems: Array.isArray(audiobookConfig?.preEncodeItems) ? audiobookConfig.preEncodeItems : [],
postEncodeItems: Array.isArray(audiobookConfig?.postEncodeItems) ? audiobookConfig.postEncodeItems : []
});
useEffect(() => { useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format); const nextFormat = normalizeFormat(audiobookConfig?.format);
@@ -160,13 +273,51 @@ export default function AudiobookConfigPanel({
setEditableChapters(normalizeEditableChapters(chapters)); setEditableChapters(normalizeEditableChapters(chapters));
}, [jobId, JSON.stringify(chapters || [])]); }, [jobId, JSON.stringify(chapters || [])]);
useEffect(() => {
let cancelled = false;
const loadCatalog = async () => {
try {
const [scriptsResponse, chainsResponse] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
if (cancelled) {
return;
}
const scripts = scriptsResponse.status === 'fulfilled'
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
: [];
const chains = chainsResponse.status === 'fulfilled'
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
: [];
setScriptCatalog(scripts.map((item) => ({ id: item?.id, name: item?.name })));
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
} catch (_error) {
if (!cancelled) {
setScriptCatalog([]);
setChainCatalog([]);
}
}
};
void loadCatalog();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
setPreRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'pre'));
setPostRipItems(buildEncodeItemsFromConfig(audiobookConfig, 'post'));
}, [jobId, audiobookConfigKey]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3; const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED'); const canStart = Boolean(jobId) && (
state === 'READY_TO_START'
|| state === 'READY_TO_ENCODE'
|| state === 'ERROR'
|| state === 'CANCELLED'
);
const isRunning = state === 'ENCODING'; const isRunning = state === 'ENCODING';
const isFinished = state === 'FINISHED'; const isFinished = state === 'FINISHED';
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const isSplitOutput = format === 'mp3' || format === 'flac'; const isSplitOutput = format === 'mp3' || format === 'flac';
const showEditableChapters = !(isSplitOutput && isRunning);
const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null; const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null;
const completedChapterCount = Number(context?.completedChapterCount ?? -1); const completedChapterCount = Number(context?.completedChapterCount ?? -1);
const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0); const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0);
@@ -182,6 +333,88 @@ export default function AudiobookConfigPanel({
[schema, formatOptions] [schema, formatOptions]
); );
const moveEncodeItem = (phase, index, direction) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const list = Array.isArray(prev) ? [...prev] : [];
const from = Number(index);
const to = from + (direction === 'up' ? -1 : 1);
if (!Number.isInteger(from) || from < 0 || from >= list.length || to < 0 || to >= list.length) {
return list;
}
const [moved] = list.splice(from, 1);
list.splice(to, 0, moved);
return list;
});
};
const addEncodeItem = (phase, type) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const selectedIds = new Set(
current
.filter((item) => item?.type === normalizedType)
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const catalog = normalizedType === 'chain' ? chainCatalog : scriptCatalog;
const candidate = (Array.isArray(catalog) ? catalog : [])
.map((item) => normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id))
.find((id) => id !== null && !selectedIds.has(String(id)));
if (candidate === undefined || candidate === null) {
return current;
}
return [...current, { type: normalizedType, id: candidate }];
});
};
const changeEncodeItem = (phase, index, type, nextId) => {
const normalizedType = type === 'chain' ? 'chain' : 'script';
const normalizedId = normalizedType === 'chain' ? normalizeChainId(nextId) : normalizeScriptId(nextId);
if (normalizedId === null) {
return;
}
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
const duplicate = current.some((item, itemIndex) => {
if (itemIndex === rowIndex) {
return false;
}
if (item?.type !== normalizedType) {
return false;
}
const existingId = normalizedType === 'chain' ? normalizeChainId(item?.id) : normalizeScriptId(item?.id);
return existingId !== null && String(existingId) === String(normalizedId);
});
if (duplicate) {
return current;
}
const next = [...current];
next[rowIndex] = { type: normalizedType, id: normalizedId };
return next;
});
};
const removeEncodeItem = (phase, index) => {
const updater = phase === 'post' ? setPostRipItems : setPreRipItems;
updater((prev) => {
const current = Array.isArray(prev) ? prev : [];
const rowIndex = Number(index);
if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= current.length) {
return current;
}
return current.filter((_, itemIndex) => itemIndex !== rowIndex);
});
};
return ( return (
<div className="audiobook-config-panel"> <div className="audiobook-config-panel">
<div className="audiobook-config-head"> <div className="audiobook-config-head">
@@ -217,11 +450,8 @@ export default function AudiobookConfigPanel({
</div> </div>
</div> </div>
<div className="audiobook-config-tags"> <div className="ripper-job-badges">
<Tag value={statusLabel} severity={statusSeverity} /> <Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
</div> </div>
</div> </div>
@@ -263,78 +493,316 @@ export default function AudiobookConfigPanel({
</small> </small>
</div> </div>
<div className="audiobook-config-chapters"> {showEditableChapters ? (
<h4>Kapitel</h4> <div className="audiobook-config-chapters">
{editableChapters.length === 0 ? ( <h4>Kapitel</h4>
<small>Keine Kapitel in der Quelle erkannt.</small> {editableChapters.length === 0 ? (
) : ( <small>Keine Kapitel in der Quelle erkannt.</small>
<div className="audiobook-chapter-list"> ) : (
{editableChapters.map((chapter, index) => ( <div className="audiobook-chapter-list">
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable"> {editableChapters.map((chapter, index) => (
<div className="audiobook-chapter-row-head"> <div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
<strong>#{chapter.index || index + 1}</strong> <div className="audiobook-chapter-row-head">
<small> <strong>#{chapter.index || index + 1}</strong>
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)} <small>
</small> {formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
</small>
</div>
<InputText
value={chapter.title}
onChange={(event) => {
const nextTitle = event.target.value;
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div> </div>
<InputText ))}
value={chapter.title} </div>
onChange={(event) => { )}
const nextTitle = event.target.value; </div>
setEditableChapters((prev) => prev.map((entry, entryIndex) => ( ) : null}
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div>
))}
</div>
)}
</div>
</div> </div>
{isRunning ? (
<div className="ripper-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.trunc(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.trunc(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div>
) : null}
{isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? ( {isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
<div className="audiobook-chapter-status"> <div className="audiobook-chapter-status">
<strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong> <strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong>
<div className="audiobook-chapter-status-list"> <div className="cd-track-selection">
{editableChapters.map((chapter, idx) => { <div className="cd-track-list">
const chIdx = chapter.index || idx + 1; <table className="cd-track-table">
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount); <thead>
const isActive = !isDone && currentChapter?.index === chIdx; <tr>
const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle'; <th className="check">Auswahl</th>
const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending'; <th className="num">Nr</th>
return ( <th className="title">Titel</th>
<div key={chIdx} className={`audiobook-chapter-status-row ${statusClass}`}> <th className="duration">Länge</th>
<i className={statusIcon} /> <th className="status">Rip</th>
<span className="chapter-status-nr">#{String(chIdx).padStart(2, '0')}</span> <th className="status">Encode</th>
<span className="chapter-status-title">{chapter.title}</span> </tr>
</div> </thead>
); <tbody>
})} {editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const ripMeta = trackStatusTagMeta('done');
const encodeMeta = trackStatusTagMeta(isDone ? 'done' : (isActive ? 'in_progress' : 'pending'));
return (
<tr key={chIdx} className="selected">
<td className="check">Ja</td>
<td className="num">{String(chIdx).padStart(2, '0')}</td>
<td className="title">{chapter.title || '-'}</td>
<td className="duration">{formatChapterDuration(chapter.startSeconds, chapter.endSeconds)}</td>
<td className="status"><Tag value={ripMeta.label} severity={ripMeta.severity} /></td>
<td className="status"><Tag value={encodeMeta.label} severity={encodeMeta.severity} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
) : null} ) : null}
{outputPath ? ( <div className="encode-automation-grid">
<div className="audiobook-output-path"> <div className="post-script-box">
<strong>Ausgabe:</strong> <code>{outputPath}</code> <h4>Pre-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{preRipItems.length === 0 ? (
<small>Keine Pre-Rip Ausführungen ausgewählt.</small>
) : null}
{preRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('pre', rowIndex, 'down')}
disabled={busy || rowIndex >= preRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('pre', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('pre', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div> </div>
) : null}
<div className="actions-row"> <div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null}
{postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null}
{postRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script';
const usedScriptIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'script' && index !== rowIndex)
.map((entry) => normalizeScriptId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postRipItems
.filter((entry, index) => entry?.type === 'chain' && index !== rowIndex)
.map((entry) => normalizeChainId(entry?.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((entry) => ({
label: entry?.name || `Skript #${entry?.id}`,
value: normalizeScriptId(entry?.id),
disabled: usedScriptIds.has(String(normalizeScriptId(entry?.id)))
})).filter((entry) => entry.value !== null);
const chainOptions = chainCatalog.map((entry) => ({
label: entry?.name || `Kette #${entry?.id}`,
value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null);
return (
<div key={`ab-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order">
<Button
icon="pi pi-angle-up"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'up')}
disabled={busy || rowIndex <= 0}
/>
<Button
icon="pi pi-angle-down"
severity="secondary"
text
rounded
onClick={() => moveEncodeItem('post', rowIndex, 'down')}
disabled={busy || rowIndex >= postRipItems.length - 1}
/>
</div>
{isScript ? (
<Dropdown
value={normalizeScriptId(item?.id)}
options={scriptOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'script', event.value)}
className="full-width"
disabled={busy}
/>
) : (
<Dropdown
value={normalizeChainId(item?.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => changeEncodeItem('post', rowIndex, 'chain', event.value)}
className="full-width"
disabled={busy}
/>
)}
<Button
icon="pi pi-times"
severity="danger"
outlined
onClick={() => removeEncodeItem('post', rowIndex)}
disabled={busy}
/>
</div>
);
})}
<div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button
label="Skript hinzufügen"
icon="pi pi-code"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'script')}
disabled={busy}
/>
) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button
label="Kette hinzufügen"
icon="pi pi-link"
severity="secondary"
outlined
onClick={() => addEncodeItem('post', 'chain')}
disabled={busy}
/>
) : null}
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div>
</div>
<div className="actions-row" style={{ marginTop: '1rem' }}>
{canStart ? ( {canStart ? (
<Button <Button
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'} label={(state === 'READY_TO_START' || state === 'READY_TO_ENCODE')
? 'Encode starten'
: 'Mit diesen Einstellungen starten'}
icon="pi pi-play" icon="pi pi-play"
severity="success" severity="success"
onClick={() => onStart?.({ onClick={() => onStart?.({
@@ -347,7 +815,23 @@ export default function AudiobookConfigPanel({
endSeconds: chapter.endSeconds, endSeconds: chapter.endSeconds,
startMs: chapter.startMs, startMs: chapter.startMs,
endMs: chapter.endMs endMs: chapter.endMs
})) })),
selectedPreEncodeScriptIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPostEncodeScriptIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'script').map((item) => item?.id),
'script'
),
selectedPreEncodeChainIds: normalizeIdList(
preRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
),
selectedPostEncodeChainIds: normalizeIdList(
postRipItems.filter((item) => item?.type === 'chain').map((item) => item?.id),
'chain'
)
})} })}
loading={busy} loading={busy}
disabled={!jobId} disabled={!jobId}
@@ -365,28 +849,15 @@ export default function AudiobookConfigPanel({
/> />
) : null} ) : null}
{isRunning ? ( <Button
<Button label="Abbrechen"
label="Abbrechen" severity="secondary"
icon="pi pi-stop" outlined
severity="danger" onClick={() => onCancel?.()}
onClick={() => onCancel?.()} loading={busy}
loading={busy} disabled={!jobId}
disabled={!jobId} />
/>
) : null}
{(state === 'ERROR' || state === 'CANCELLED') ? (
<Button
label="Retry-Job anlegen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
</div> </div>
<Dialog <Dialog
@@ -1,298 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from 'primereact/button';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
function formatBytes(value) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
return '';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let index = 0;
let current = n;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current.toFixed(index <= 1 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value) {
if (!value) {
return '-';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return '-';
}
return parsed.toLocaleString('de-DE');
}
function getNodeByPath(root, targetPath) {
if (!root) {
return null;
}
if ((root.path || '') === (targetPath || '')) {
return root;
}
for (const child of (root.children || [])) {
if (child.type !== 'folder') {
continue;
}
const found = getNodeByPath(child, targetPath);
if (found) {
return found;
}
}
return null;
}
function listChildren(node) {
if (!node || !Array.isArray(node.children)) {
return [];
}
return node.children;
}
function buildBreadcrumb(pathValue) {
if (!pathValue) {
return [];
}
const parts = String(pathValue).split('/').filter(Boolean);
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/')
}));
}
function filterFolderTree(node, query) {
if (!node || node.type !== 'folder') {
return null;
}
if (!query || !query.trim()) {
return node;
}
const normalized = query.toLowerCase();
const children = (node.children || [])
.filter((child) => child.type === 'folder')
.map((child) => filterFolderTree(child, query))
.filter(Boolean);
const nameMatches = String(node.name || '').toLowerCase().includes(normalized);
if (nameMatches || children.length > 0) {
return { ...node, children };
}
return null;
}
function defaultExpandedSet(tree) {
const next = new Set(['']);
const firstLevel = Array.isArray(tree?.children) ? tree.children : [];
for (const entry of firstLevel) {
if (entry?.type === 'folder' && entry?.path) {
next.add(entry.path);
}
}
return next;
}
export default function AudiobookOutputExplorer({ refreshToken = 0 }) {
const [tree, setTree] = useState(null);
const [outputDir, setOutputDir] = useState(null);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [expandedFolders, setExpandedFolders] = useState(() => new Set(['']));
const [sidebarQuery, setSidebarQuery] = useState('');
const loadTree = useCallback(async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await api.getAudiobookOutputTree();
const nextTree = response?.tree || null;
setTree(nextTree);
setOutputDir(response?.outputDir || null);
setExpandedFolders(defaultExpandedSet(nextTree));
setCurrentPath('');
} catch (error) {
setTree(null);
setErrorMessage(error?.message || 'Explorer konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadTree();
}, [loadTree, refreshToken]);
const navigateTo = (pathValue) => {
const normalized = String(pathValue || '').trim();
const node = getNodeByPath(tree, normalized);
if (node && node.type === 'folder') {
setCurrentPath(normalized);
}
};
const toggleFolder = (pathValue) => {
const normalized = String(pathValue || '').trim();
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(normalized)) {
next.delete(normalized);
} else {
next.add(normalized);
}
return next;
});
};
const renderTreeNode = (node, depth = 0) => {
if (!node || node.type !== 'folder') {
return null;
}
const key = node.path || '';
const isExpanded = expandedFolders.has(key);
const isCurrent = currentPath === key;
const children = Array.isArray(node.children) ? node.children.filter((entry) => entry.type === 'folder') : [];
return (
<div key={key || '__root__'} className={`tree-node depth-${depth}`}>
<button
type="button"
className={`tree-row folder${isCurrent ? ' active' : ''}`}
onClick={() => navigateTo(key)}
>
{children.length > 0 ? (
<span
className="tree-caret"
onClick={(event) => {
event.stopPropagation();
toggleFolder(key);
}}
>
<i className={`pi ${isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'}`} />
</span>
) : (
<span className="tree-caret disabled" aria-hidden="true" />
)}
<span className="tree-icon folder">
<i className="pi pi-folder" />
</span>
<span className="tree-label">{node.name || 'audiobooks'}</span>
</button>
{isExpanded && children.map((child) => renderTreeNode(child, depth + 1))}
</div>
);
};
const filteredTree = useMemo(() => filterFolderTree(tree, sidebarQuery), [tree, sidebarQuery]);
const currentNode = getNodeByPath(tree, currentPath);
const currentChildren = listChildren(currentNode);
const breadcrumb = buildBreadcrumb(currentPath);
if (loading && !tree) {
return (
<div className="explorer-loading">
<ProgressSpinner style={{ width: '2.2rem', height: '2.2rem' }} strokeWidth="5" />
</div>
);
}
if (!tree) {
return (
<div className="explorer-empty">
{errorMessage ? (
<small className="error-text">{errorMessage}</small>
) : (
<small>Kein Audiobook-Output vorhanden. Ausgabepfad: <code>{outputDir || 'nicht konfiguriert'}</code></small>
)}
<div style={{ marginTop: '0.6rem' }}>
<Button icon="pi pi-refresh" label="Neu laden" outlined size="small" onClick={() => void loadTree()} />
</div>
</div>
);
}
return (
<div className="explorer audiobook-output-explorer">
<div className="explorer-sidebar">
<div className="explorer-toolbar sidebar-toolbar">
<input
type="text"
placeholder="Ordner filtern..."
value={sidebarQuery}
onChange={(event) => setSidebarQuery(event.target.value)}
className="sidebar-search"
/>
</div>
<div className="sidebar-tree">
{filteredTree ? renderTreeNode(filteredTree, 0) : <small>Keine Ordner gefunden.</small>}
</div>
</div>
<div className="explorer-main">
<div className="explorer-toolbar">
<Button
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
label="Aktualisieren"
outlined
size="small"
onClick={() => void loadTree()}
disabled={loading}
/>
<div className="explorer-path">
<Button text label="/" onClick={() => navigateTo('')} />
{breadcrumb.map((crumb) => (
<Button key={crumb.path} text label={crumb.name} onClick={() => navigateTo(crumb.path)} />
))}
</div>
</div>
<div className="explorer-list">
<div className="explorer-row header audiobook-output-row">
<span>Name</span>
<span>Größe</span>
<span>Geändert</span>
</div>
{currentChildren.length === 0 ? (
<div className="explorer-row audiobook-output-row">
<span>Keine Einträge in diesem Ordner.</span>
<span />
<span />
</div>
) : (
currentChildren.map((entry) => (
<button
type="button"
key={entry.path || entry.name}
className="explorer-row audiobook-output-row"
onClick={() => {
if (entry.type === 'folder') {
navigateTo(entry.path);
}
}}
style={{ cursor: entry.type === 'folder' ? 'pointer' : 'default' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
<i className={`pi ${entry.type === 'folder' ? 'pi-folder' : 'pi-file'}`} />
{entry.name}
</span>
<span>{entry.type === 'file' ? (formatBytes(entry.size) || '-') : '-'}</span>
<span>{formatDateTime(entry.mtime)}</span>
</button>
))
)}
</div>
<div className="explorer-footer">
<small>
Root: <code>{outputDir || '-'}</code>
</small>
</div>
</div>
</div>
);
}
+178 -89
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { FileUpload } from 'primereact/fileupload'; import { FileUpload } from 'primereact/fileupload';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar'; import { ProgressBar } from 'primereact/progressbar';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
@@ -32,15 +31,30 @@ function normalizeJobId(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
export default function AudiobookUploadPanel({ export default function AudiobookUploadPanel({
audiobookUpload, audiobookUpload,
onAudiobookUpload, onAudiobookUpload,
onCancelUpload = null,
onUploaded = null onUploaded = null
}) { }) {
const toastRef = useRef(null); const toastRef = useRef(null);
const fileUploadRef = useRef(null); const fileUploadRef = useRef(null);
const fallbackFileInputRef = useRef(null);
const [uploadFile, setUploadFile] = useState(null); const [uploadFile, setUploadFile] = useState(null);
const [statusVisible, setStatusVisible] = useState(false);
const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase(); const phase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
const uploadBusy = phase === 'uploading' || phase === 'processing'; const uploadBusy = phase === 'uploading' || phase === 'processing';
@@ -49,40 +63,15 @@ export default function AudiobookUploadPanel({
: 0; : 0;
const loadedBytes = Number(audiobookUpload?.loadedBytes || 0); const loadedBytes = Number(audiobookUpload?.loadedBytes || 0);
const totalBytes = Number(audiobookUpload?.totalBytes || 0); const totalBytes = Number(audiobookUpload?.totalBytes || 0);
const fileName = String(audiobookUpload?.fileName || '').trim() const progressLabel = phase === 'processing'
|| String(uploadFile?.name || '').trim() ? '100% | Upload fertig, Job wird vorbereitet ...'
|| null; : totalBytes > 0
const statusTone = phase === 'error' ? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
? 'danger' : `${Math.trunc(progress)}%`;
: phase === 'completed' const hasHeaderStatus = phase !== 'idle' || Boolean(uploadFile);
? 'success' const canStartUpload = Boolean(uploadFile) && !uploadBusy;
: phase === 'processing' const canCancelUpload = uploadBusy && typeof onCancelUpload === 'function';
? 'info' const canClearSelection = Boolean(uploadFile) && !uploadBusy;
: phase === 'uploading'
? 'warning'
: 'secondary';
const statusLabel = phase === 'uploading'
? 'Upload laeuft'
: phase === 'processing'
? 'Server verarbeitet'
: phase === 'completed'
? 'Bereit'
: phase === 'error'
? 'Fehler'
: 'Inaktiv';
useEffect(() => {
if (phase === 'idle') {
setStatusVisible(false);
return;
}
setStatusVisible(true);
if (phase === 'completed') {
const timer = setTimeout(() => setStatusVisible(false), 5000);
return () => clearTimeout(timer);
}
return undefined;
}, [phase]);
const handleUpload = async () => { const handleUpload = async () => {
if (!uploadFile) { if (!uploadFile) {
@@ -96,7 +85,7 @@ export default function AudiobookUploadPanel({
} }
try { try {
const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false }); const response = await onAudiobookUpload?.(uploadFile, { startImmediately: false });
const uploadedJobId = normalizeJobId(response?.result?.jobId); const uploadedJobId = extractUploadJobIdFromResponse(response);
if (uploadedJobId) { if (uploadedJobId) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
@@ -116,6 +105,15 @@ export default function AudiobookUploadPanel({
fileUploadRef.current?.clear?.(); fileUploadRef.current?.clear?.();
onUploaded?.(uploadedJobId, response); onUploaded?.(uploadedJobId, response);
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') {
toastRef.current?.show({
severity: 'info',
summary: 'Upload abgebrochen',
detail: 'Der Audiobook-Upload wurde gestoppt.',
life: 2800
});
return;
}
toastRef.current?.show({ toastRef.current?.show({
severity: 'error', severity: 'error',
summary: 'Upload fehlgeschlagen', summary: 'Upload fehlgeschlagen',
@@ -125,6 +123,72 @@ export default function AudiobookUploadPanel({
} }
}; };
const handleFileSelected = (file) => {
if (!file) {
setUploadFile(null);
return;
}
setUploadFile(file);
};
const handleFileCleared = () => {
setUploadFile(null);
};
const handleChooseFile = () => {
if (uploadBusy) {
return;
}
fallbackFileInputRef.current?.click();
};
const handleFileItemAction = (onRemove = null) => {
if (canCancelUpload) {
void onCancelUpload();
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
return;
}
onRemove?.();
fileUploadRef.current?.clear?.();
handleFileCleared();
};
const renderFileRow = (file, onRemove = null) => (
<div className="aax-file-item">
<i className="pi pi-headphones aax-file-icon" />
<div className="aax-file-info">
<span className="aax-file-name" title={file?.name}>{file?.name || 'upload.aax'}</span>
<small>{formatBytes(Number(file?.size || 0))}</small>
{hasHeaderStatus ? (
<div className="aax-file-status">
<>
<small className="audiobook-upload-inline-text">{progressLabel}</small>
<ProgressBar value={progress} showValue={false} />
{audiobookUpload?.statusText ? (
<small className="audiobook-upload-inline-text">{audiobookUpload.statusText}</small>
) : null}
</>
</div>
) : null}
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={() => {
handleFileItemAction(onRemove);
}}
disabled={!canCancelUpload && !canClearSelection}
tooltip={canCancelUpload ? 'Upload abbrechen' : 'Auswahl entfernen'}
tooltipOptions={{ position: 'left' }}
/>
</div>
);
return ( return (
<div className="audiobook-upload-panel"> <div className="audiobook-upload-panel">
<Toast ref={toastRef} position="top-right" /> <Toast ref={toastRef} position="top-right" />
@@ -135,66 +199,91 @@ export default function AudiobookUploadPanel({
customUpload customUpload
uploadHandler={() => void handleUpload()} uploadHandler={() => void handleUpload()}
disabled={uploadBusy} disabled={uploadBusy}
onSelect={(event) => setUploadFile(event.files[0] || null)} onSelect={(event) => {
onClear={() => setUploadFile(null)} handleFileSelected(event.files[0] || null);
onRemove={() => setUploadFile(null)} }}
onClear={() => {
handleFileCleared();
}}
onRemove={() => {
handleFileCleared();
}}
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }} chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }} uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }} cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
itemTemplate={(file, options) => ( headerTemplate={(options) => (
<div className="aax-file-item"> <div className={options.className}>
<i className="pi pi-headphones aax-file-icon" /> <div className="audiobook-upload-header">
<div className="aax-file-info"> <div className="audiobook-upload-header-actions">
<span className="aax-file-name" title={file.name}>{file.name}</span> <Button
<small>{options.formatSize}</small> icon="pi pi-images"
rounded
outlined
aria-label="Datei auswählen"
onClick={() => {
void handleChooseFile();
}}
disabled={uploadBusy}
/>
<Button
icon="pi pi-cloud-upload"
rounded
outlined
severity="success"
aria-label="Upload starten"
onClick={() => {
void handleUpload();
}}
disabled={!canStartUpload}
/>
<Button
icon="pi pi-times"
rounded
outlined
severity="secondary"
aria-label="Auswahl entfernen"
onClick={() => {
fileUploadRef.current?.clear?.();
handleFileCleared();
}}
disabled={!canClearSelection}
/>
</div>
</div> </div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
onClick={options.onRemove}
disabled={uploadBusy}
/>
</div> </div>
)} )}
itemTemplate={(file, options) => (
renderFileRow(file, () => {
options.onRemove?.();
})
)}
emptyTemplate={() => ( emptyTemplate={() => (
<div className="aax-drop-zone"> uploadFile
<i className="pi pi-headphones aax-drop-icon" /> ? renderFileRow(uploadFile, () => {
<p>AAX-Datei hier ablegen</p> // handled by handleFileItemAction
<small>oder oben "Auswaehlen" klicken</small> })
</div> : (
<div className="aax-drop-zone">
<i className="pi pi-headphones aax-drop-icon" />
<p>AAX-Datei hier ablegen</p>
<small>oder oben "Auswaehlen" klicken</small>
</div>
)
)} )}
/> />
<input
{statusVisible ? ( ref={fallbackFileInputRef}
<div className={`audiobook-upload-status tone-${statusTone}`}> type="file"
<div className="audiobook-upload-status-head"> accept=".aax,audio/vnd.audible.aax"
<strong>{statusLabel}</strong> style={{ display: 'none' }}
<Tag value={statusLabel} severity={statusTone} /> onChange={(event) => {
</div> const file = event.target?.files?.[0] || null;
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null} handleFileSelected(file);
{fileName ? ( if (event.target) {
<small className="audiobook-upload-file" title={fileName}> event.target.value = '';
Datei: {fileName} }
</small> }}
) : null} />
<div
className="ripper-job-row-progress audiobook-upload-progress"
aria-label={`Audiobook Upload ${Math.trunc(progress)} Prozent`}
>
<ProgressBar value={progress} showValue={false} />
<small>
{phase === 'processing'
? '100% | Upload fertig, Job wird vorbereitet ...'
: totalBytes > 0
? `${Math.trunc(progress)}% | ${formatBytes(loadedBytes)} / ${formatBytes(totalBytes)}`
: `${Math.trunc(progress)}%`}
</small>
</div>
</div>
) : null}
</div> </div>
); );
} }
+5 -1
View File
@@ -142,7 +142,10 @@ export default function CdMetadataDialog({
const searchRunId = searchRunRef.current + 1; const searchRunId = searchRunRef.current + 1;
searchRunRef.current = searchRunId; searchRunRef.current = searchRunId;
try { try {
const searchResults = await onSearch(trimmedQuery); const expectedTrackCount = Array.isArray(tocTracks) ? tocTracks.length : 0;
const searchResults = await onSearch(trimmedQuery, {
trackCount: expectedTrackCount > 0 ? expectedTrackCount : null
});
const normalizedResults = Array.isArray(searchResults) ? searchResults : []; const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl))); await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
if (searchRunRef.current !== searchRunId) { if (searchRunRef.current !== searchRunId) {
@@ -264,6 +267,7 @@ export default function CdMetadataDialog({
emptyMessage="Keine Treffer" emptyMessage="Keine Treffer"
> >
<Column header="Album" body={mbTitleBody} /> <Column header="Album" body={mbTitleBody} />
<Column field="trackCount" header="Tracks" style={{ width: '6rem' }} />
<Column field="year" header="Jahr" style={{ width: '6rem' }} /> <Column field="year" header="Jahr" style={{ width: '6rem' }} />
<Column field="country" header="Land" style={{ width: '6rem' }} /> <Column field="country" header="Land" style={{ width: '6rem' }} />
</DataTable> </DataTable>
+151 -88
View File
@@ -239,6 +239,7 @@ export default function CdRipConfigPanel({
const selectedMeta = context.selectedMetadata || {}; const selectedMeta = context.selectedMetadata || {};
const state = String(pipeline?.state || '').trim().toUpperCase(); const state = String(pipeline?.state || '').trim().toUpperCase();
const jobId = normalizePosition(context?.jobId); const jobId = normalizePosition(context?.jobId);
const isPostRipWorkflow = context?.skipRip === true;
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING'; const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
const isFinished = state === 'FINISHED'; const isFinished = state === 'FINISHED';
@@ -662,6 +663,7 @@ export default function CdRipConfigPanel({
|| '' || ''
) || null; ) || null;
const devicePath = normalizeTrackText(context?.devicePath) || '-'; const devicePath = normalizeTrackText(context?.devicePath) || '-';
const rawPath = normalizeTrackText(context?.rawPath) || '-';
const outputPath = normalizeTrackText(context?.outputPath) || '-'; const outputPath = normalizeTrackText(context?.outputPath) || '-';
const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase(); const formatValue = String(cdRipConfig?.format || '').trim().toLowerCase();
const formatLabel = (Array.isArray(CD_FORMATS) const formatLabel = (Array.isArray(CD_FORMATS)
@@ -795,7 +797,9 @@ export default function CdRipConfigPanel({
displayValueTemplate={(value) => formatProgressLabel(value)} displayValueTemplate={(value) => formatProgressLabel(value)}
/> />
<small> <small>
{`Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`} {isPostRipWorkflow
? `Fortschritt: ${formatProgressLabel(wholeProgress)} | ${livePhaseLabel} ${completedRipCount}/${selectedTrackRows.length || effectiveTrackRows.length} Rip | ${completedEncodeCount}/${selectedTrackRows.length || effectiveTrackRows.length} Encode`
: `Fortschritt: ${formatProgressLabel(wholeProgress)}`}
</small> </small>
<small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small> <small>{eta ? `ETA ${eta}` : 'ETA unbekannt'}</small>
</div> </div>
@@ -892,25 +896,30 @@ export default function CdRipConfigPanel({
<div><strong>Jahr:</strong> {albumYear}</div> <div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div> <div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
<div><strong>Status:</strong> {stateLabel}</div> <div><strong>Status:</strong> {stateLabel}</div>
<div><strong>Format:</strong> {formatLabel}</div> {!isRipping ? <div><strong>Format:</strong> {formatLabel}</div> : null}
<div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div> {isPostRipWorkflow ? (
<div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div> <>
<div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div> <div><strong>Rip fertig:</strong> {completedRipCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div> <div><strong>Encode fertig:</strong> {completedEncodeCount} / {selectedTrackRows.length || effectiveTrackRows.length}</div>
<div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div> <div><strong>Aktueller Track:</strong> {liveCurrentTrackPosition ? String(liveCurrentTrackPosition).padStart(2, '0') : '-'}</div>
<div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div> <div><strong>Pre-Skripte:</strong> {preScriptNames.length > 0 ? preScriptNames.join(' | ') : '-'}</div>
<div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div> <div><strong>Pre-Ketten:</strong> {preChainNames.length > 0 ? preChainNames.join(' | ') : '-'}</div>
<div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div> <div><strong>Post-Skripte:</strong> {postScriptNames.length > 0 ? postScriptNames.join(' | ') : '-'}</div>
<div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div> <div><strong>Post-Ketten:</strong> {postChainNames.length > 0 ? postChainNames.join(' | ') : '-'}</div>
<div><strong>Laufwerk:</strong> {devicePath}</div> <div><strong>Auswahl:</strong> {selectedTrackNumbers || '-'}</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div> <div><strong>Gesamtdauer:</strong> {formatTotalDuration(selectedTrackDurationSec)}</div>
</>
) : null}
{!isRipping ? <div><strong>Laufwerk:</strong> {devicePath}</div> : null}
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
{!isRipping ? <div><strong>Output-Pfad:</strong> {outputPath}</div> : null}
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null} {lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null} {!isRipping && jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
</div> </div>
</div> </div>
</div> </div>
{displayTrackRows.length > 0 ? ( {isPostRipWorkflow && displayTrackRows.length > 0 ? (
<div className="cd-track-selection"> <div className="cd-track-selection">
<strong>Zu rippende Tracks ({displayTrackRows.length})</strong> <strong>Zu rippende Tracks ({displayTrackRows.length})</strong>
<div className="cd-track-list"> <div className="cd-track-list">
@@ -951,6 +960,58 @@ export default function CdRipConfigPanel({
); );
} }
if (!isPostRipWorkflow) {
return (
<div className="cd-rip-config-panel">
<div className="status-row">
<Tag value={stateLabel} severity={stateSeverity} />
<span>{statusText || 'Bereit'}</span>
</div>
<div className="cd-meta-summary">
<strong>CD-Details</strong>
<div className="device-meta" style={{ marginTop: '0.55rem' }}>
<div><strong>Album:</strong> {albumTitle}</div>
<div><strong>Interpret:</strong> {albumArtist}</div>
<div><strong>Jahr:</strong> {albumYear}</div>
<div><strong>MusicBrainz:</strong> {musicBrainzId}</div>
<div><strong>Status:</strong> {stateLabel}</div>
<div><strong>Laufwerk:</strong> {devicePath}</div>
<div><strong>RAW-Pfad:</strong> {rawPath}</div>
<div><strong>Output-Pfad:</strong> {outputPath}</div>
{lastState ? <div><strong>Letzter Pipeline-State:</strong> {lastStateLabel}</div> : null}
{jobId ? <div><strong>Job-ID:</strong> #{jobId}</div> : null}
</div>
</div>
<div className="actions-row">
<Button
label="Rip starten"
icon="pi pi-play"
severity="success"
onClick={() => handleStart({ forceSkipRip: false })}
loading={busy}
/>
<Button
label="Metadaten ändern"
icon="pi pi-pencil"
severity="secondary"
onClick={() => onOpenMetadata && onOpenMetadata()}
loading={busy}
/>
{jobId ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob && onDeleteJob(jobId)}
loading={busy}
/>
) : null}
</div>
</div>
);
}
return ( return (
<div className="cd-rip-config-panel"> <div className="cd-rip-config-panel">
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4> <h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
@@ -1082,15 +1143,16 @@ export default function CdRipConfigPanel({
</div> </div>
) : null} ) : null}
<div className="post-script-box"> <div className="encode-automation-grid">
<h4>Pre-Rip Ausführungen (optional)</h4> <div className="post-script-box">
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( <h4>Pre-Rip Ausführungen (optional)</h4>
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small> {scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
) : null} <small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
{preRipItems.length === 0 ? ( ) : null}
<small>Keine Pre-Rip Ausführungen ausgewählt.</small> {preRipItems.length === 0 ? (
) : null} <small>Keine Pre-Rip Ausführungen ausgewählt.</small>
{preRipItems.map((item, rowIndex) => { ) : null}
{preRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script'; const isScript = item?.type === 'script';
const usedScriptIds = new Set( const usedScriptIds = new Set(
preRipItems preRipItems
@@ -1116,8 +1178,8 @@ export default function CdRipConfigPanel({
value: normalizeChainId(entry?.id), value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null); })).filter((entry) => entry.value !== null);
return ( return (
<div key={`cd-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable"> <div key={`cd-pre-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} /> <i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order"> <div className="cd-encode-item-order">
<Button <Button
@@ -1167,43 +1229,43 @@ export default function CdRipConfigPanel({
onClick={() => removeEncodeItem('pre', rowIndex)} onClick={() => removeEncodeItem('pre', rowIndex)}
disabled={busy} disabled={busy}
/> />
</div> </div>
); );
})} })}
<div className="actions-row"> <div className="actions-row">
{scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? ( {scriptCatalog.length > preRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button <Button
label="Skript hinzufügen" label="Skript hinzufügen"
icon="pi pi-code" icon="pi pi-code"
severity="secondary" severity="secondary"
outlined outlined
onClick={() => addEncodeItem('pre', 'script')} onClick={() => addEncodeItem('pre', 'script')}
disabled={busy} disabled={busy}
/> />
) : null} ) : null}
{chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? ( {chainCatalog.length > preRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button <Button
label="Kette hinzufügen" label="Kette hinzufügen"
icon="pi pi-link" icon="pi pi-link"
severity="secondary" severity="secondary"
outlined outlined
onClick={() => addEncodeItem('pre', 'chain')} onClick={() => addEncodeItem('pre', 'chain')}
disabled={busy} disabled={busy}
/> />
) : null} ) : null}
</div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
</div> </div>
<small>Ausführung vor dem Rippen, strikt nacheinander. Bei Fehler wird der CD-Rip abgebrochen.</small>
</div>
<div className="post-script-box"> <div className="post-script-box">
<h4>Post-Rip Ausführungen (optional)</h4> <h4>Post-Rip Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( {scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small> <small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
) : null} ) : null}
{postRipItems.length === 0 ? ( {postRipItems.length === 0 ? (
<small>Keine Post-Rip Ausführungen ausgewählt.</small> <small>Keine Post-Rip Ausführungen ausgewählt.</small>
) : null} ) : null}
{postRipItems.map((item, rowIndex) => { {postRipItems.map((item, rowIndex) => {
const isScript = item?.type === 'script'; const isScript = item?.type === 'script';
const usedScriptIds = new Set( const usedScriptIds = new Set(
postRipItems postRipItems
@@ -1229,8 +1291,8 @@ export default function CdRipConfigPanel({
value: normalizeChainId(entry?.id), value: normalizeChainId(entry?.id),
disabled: usedChainIds.has(String(normalizeChainId(entry?.id))) disabled: usedChainIds.has(String(normalizeChainId(entry?.id)))
})).filter((entry) => entry.value !== null); })).filter((entry) => entry.value !== null);
return ( return (
<div key={`cd-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable"> <div key={`cd-post-${rowIndex}-${item?.type}-${item?.id}`} className="post-script-row editable">
<i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} /> <i className={`post-script-type-icon pi ${isScript ? 'pi-code' : 'pi-link'}`} title={isScript ? 'Skript' : 'Kette'} />
<div className="cd-encode-item-order"> <div className="cd-encode-item-order">
<Button <Button
@@ -1280,32 +1342,33 @@ export default function CdRipConfigPanel({
onClick={() => removeEncodeItem('post', rowIndex)} onClick={() => removeEncodeItem('post', rowIndex)}
disabled={busy} disabled={busy}
/> />
</div> </div>
); );
})} })}
<div className="actions-row"> <div className="actions-row">
{scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? ( {scriptCatalog.length > postRipItems.filter((entry) => entry?.type === 'script').length ? (
<Button <Button
label="Skript hinzufügen" label="Skript hinzufügen"
icon="pi pi-code" icon="pi pi-code"
severity="secondary" severity="secondary"
outlined outlined
onClick={() => addEncodeItem('post', 'script')} onClick={() => addEncodeItem('post', 'script')}
disabled={busy} disabled={busy}
/> />
) : null} ) : null}
{chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? ( {chainCatalog.length > postRipItems.filter((entry) => entry?.type === 'chain').length ? (
<Button <Button
label="Kette hinzufügen" label="Kette hinzufügen"
icon="pi pi-link" icon="pi pi-link"
severity="secondary" severity="secondary"
outlined outlined
onClick={() => addEncodeItem('post', 'chain')} onClick={() => addEncodeItem('post', 'chain')}
disabled={busy} disabled={busy}
/> />
) : null} ) : null}
</div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div> </div>
<small>Ausführung nach erfolgreichem Rippen/Encodieren, strikt nacheinander.</small>
</div> </div>
{/* Actions */} {/* Actions */}
+81 -28
View File
@@ -154,6 +154,59 @@ function deriveAudioTracks(plan) {
return []; return [];
} }
function normalizePositiveInt(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function buildVideoMetadataPayload(sourceMetadata, fallbackTitle = '') {
const source = sourceMetadata && typeof sourceMetadata === 'object' ? sourceMetadata : {};
const normalized = {
title: String(source?.title || fallbackTitle || '').trim() || null,
year: Number.isFinite(Number(source?.year)) ? Math.trunc(Number(source.year)) : null,
imdbId: String(source?.imdbId || '').trim() || null,
poster: String(source?.poster || '').trim() || null
};
const metadataProvider = String(source?.metadataProvider || '').trim() || null;
const workflowKind = String(source?.workflowKind || '').trim() || null;
const providerId = String(source?.providerId || '').trim() || null;
const metadataKind = String(source?.metadataKind || '').trim() || null;
const seasonName = String(source?.seasonName || '').trim() || null;
const imdbRating = String(source?.imdbRating || '').trim() || null;
const tmdbId = normalizePositiveInt(source?.tmdbId);
const seasonNumber = normalizePositiveInt(source?.seasonNumber);
const discNumber = normalizePositiveInt(source?.discNumber);
const episodeCount = Number(source?.episodeCount || 0);
const voteAverageRaw = Number(source?.voteAverage || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const episodes = Array.isArray(source?.episodes) ? source.episodes : [];
const tmdbDetails = source?.tmdbDetails && typeof source.tmdbDetails === 'object' && !Array.isArray(source.tmdbDetails)
? source.tmdbDetails
: null;
if (metadataProvider) normalized.metadataProvider = metadataProvider;
if (workflowKind) normalized.workflowKind = workflowKind;
if (providerId) normalized.providerId = providerId;
if (metadataKind) normalized.metadataKind = metadataKind;
if (tmdbId) normalized.tmdbId = tmdbId;
if (seasonNumber) normalized.seasonNumber = seasonNumber;
if (seasonName) normalized.seasonName = seasonName;
if (discNumber) normalized.discNumber = discNumber;
if (Number.isFinite(episodeCount) && episodeCount > 0) normalized.episodeCount = Math.trunc(episodeCount);
if (episodes.length > 0) normalized.episodes = episodes;
if (voteAverage !== null) normalized.voteAverage = voteAverage;
if (imdbRating) normalized.imdbRating = imdbRating;
if (tmdbDetails) normalized.tmdbDetails = tmdbDetails;
return normalized;
}
// Status helpers // Status helpers
function mediaTypeBadge(type) { function mediaTypeBadge(type) {
@@ -260,10 +313,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
// Video metadata // Video metadata
const [videoMetadata, setVideoMetadata] = useState(() => ({ const [videoMetadata, setVideoMetadata] = useState(() => ({
title: String(planMetadata?.title || job.title || job.detected_title || '').trim(), ...buildVideoMetadataPayload(
year: Number.isFinite(Number(planMetadata?.year)) ? Math.trunc(Number(planMetadata.year)) : null, planMetadata,
imdbId: String(planMetadata?.imdbId || '').trim() || null, String(job.title || job.detected_title || '').trim()
poster: String(planMetadata?.poster || '').trim() || null )
})); }));
// Script / Chain catalog // Script / Chain catalog
@@ -378,10 +431,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
coverUrl: coverUrl || null coverUrl: coverUrl || null
} }
: { : {
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, ...buildVideoMetadataPayload(
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, videoMetadata,
imdbId: String(videoMetadata?.imdbId || '').trim() || null, String(job.title || job.detected_title || '').trim()
poster: String(videoMetadata?.poster || '').trim() || null )
}; };
return { return {
@@ -436,12 +489,13 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
jobId: job.id, jobId: job.id,
detectedTitle: videoMetadata?.title || job.title || job.detected_title || '', detectedTitle: videoMetadata?.title || job.title || job.detected_title || '',
selectedMetadata: { selectedMetadata: {
...videoMetadata,
title: videoMetadata?.title || job.title || job.detected_title || '', title: videoMetadata?.title || job.title || job.detected_title || '',
year: videoMetadata?.year || null, year: videoMetadata?.year || null,
imdbId: videoMetadata?.imdbId || null, imdbId: videoMetadata?.imdbId || null,
poster: videoMetadata?.poster || null poster: videoMetadata?.poster || null
}, },
omdbCandidates: [] metadataCandidates: []
}), [job.id, job.title, job.detected_title, videoMetadata]); }), [job.id, job.title, job.detected_title, videoMetadata]);
const cdMetadataDialogContext = useMemo(() => ({ const cdMetadataDialogContext = useMemo(() => ({
@@ -454,31 +508,28 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
}), [job.id, audioTracks, trackFields]); }), [job.id, audioTracks, trackFields]);
// Dialog handlers // Dialog handlers
const handleOmdbSearch = async (query) => { const handleMetadataSearch = async (query) => {
try { try {
const response = await api.searchOmdb(query); const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : []; return Array.isArray(response?.results) ? response.results : [];
} catch { return []; } } catch { return []; }
}; };
const handleOmdbSubmit = async (payload) => { const handleMetadataSubmit = async (payload) => {
setSearchDialogBusy(true); setSearchDialogBusy(true);
try { try {
setVideoMetadata({ setVideoMetadata(buildVideoMetadataPayload(payload, String(job.title || job.detected_title || '').trim()));
title: String(payload?.title || job.title || job.detected_title || '').trim(),
year: Number.isFinite(Number(payload?.year)) ? Math.trunc(Number(payload.year)) : null,
imdbId: String(payload?.imdbId || '').trim() || null,
poster: String(payload?.poster || '').trim() || null
});
setMetadataDialogVisible(false); setMetadataDialogVisible(false);
} finally { } finally {
setSearchDialogBusy(false); setSearchDialogBusy(false);
} }
}; };
const handleMusicBrainzSearchDialog = async (query) => { const handleMusicBrainzSearchDialog = async (query, options = {}) => {
try { try {
const response = await api.searchMusicBrainz(query); const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return Array.isArray(response?.results) ? response.results : []; return Array.isArray(response?.results) ? response.results : [];
} catch { return []; } } catch { return []; }
}; };
@@ -642,10 +693,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
coverUrl: coverUrl || null coverUrl: coverUrl || null
} }
: { : {
title: String(videoMetadata?.title || job.title || job.detected_title || '').trim() || null, ...buildVideoMetadataPayload(
year: Number.isFinite(Number(videoMetadata?.year)) ? Math.trunc(Number(videoMetadata.year)) : null, videoMetadata,
imdbId: String(videoMetadata?.imdbId || '').trim() || null, String(job.title || job.detected_title || '').trim()
poster: String(videoMetadata?.poster || '').trim() || null )
}; };
await api.startConverterJob(job.id, { await api.startConverterJob(job.id, {
@@ -880,8 +931,10 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
)} )}
{/* Pre / Post Ausführungen */} {/* Pre / Post Ausführungen */}
{renderEncodeSection('pre', preItems)} <div className="encode-automation-grid">
{renderEncodeSection('post', postItems)} {renderEncodeSection('pre', preItems)}
{renderEncodeSection('post', postItems)}
</div>
{/* Aktionen */} {/* Aktionen */}
<div className="actions-row" style={{ marginTop: '1rem' }}> <div className="actions-row" style={{ marginTop: '1rem' }}>
@@ -1025,8 +1078,8 @@ function InlineConfig({ job, plan, onStarted, onDeleted, onCancelled, onInputsCh
visible={metadataDialogVisible} visible={metadataDialogVisible}
context={metadataDialogContext} context={metadataDialogContext}
onHide={() => setMetadataDialogVisible(false)} onHide={() => setMetadataDialogVisible(false)}
onSubmit={handleOmdbSubmit} onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch} onSearch={handleMetadataSearch}
busy={searchDialogBusy} busy={searchDialogBusy}
/> />
</div> </div>
+35 -6
View File
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
import { InputSwitch } from 'primereact/inputswitch'; import { InputSwitch } from 'primereact/inputswitch';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { api } from '../api/client'; import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import { confirmModal } from '../utils/confirmModal'; import { confirmModal } from '../utils/confirmModal';
// Hilfsfunktionen // Hilfsfunktionen
@@ -61,7 +62,7 @@ const EMPTY_FORM = {
// Hauptkomponente // Hauptkomponente
export default function CronJobsTab({ onWsMessage }) { export default function CronJobsTab() {
const toastRef = useRef(null); const toastRef = useRef(null);
const [jobs, setJobs] = useState([]); const [jobs, setJobs] = useState([]);
@@ -89,6 +90,7 @@ export default function CronJobsTab({ onWsMessage }) {
// Aktionen Busy-State per Job-ID // Aktionen Busy-State per Job-ID
const [busyId, setBusyId] = useState(null); const [busyId, setBusyId] = useState(null);
const wsReloadTimerRef = useRef(null);
// Daten laden // Daten laden
@@ -135,6 +137,16 @@ export default function CronJobsTab({ onWsMessage }) {
}; };
}, []); }, []);
const scheduleLoadAll = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void loadAll();
}, delayMs);
};
const activeCronRunByJobId = useMemo(() => { const activeCronRunByJobId = useMemo(() => {
const map = new Map(); const map = new Map();
for (const item of activeCronRuns) { for (const item of activeCronRuns) {
@@ -146,11 +158,28 @@ export default function CronJobsTab({ onWsMessage }) {
return map; return map;
}, [activeCronRuns]); }, [activeCronRuns]);
// WebSocket: Cronjob-Updates empfangen useEffect(() => () => {
useEffect(() => { if (wsReloadTimerRef.current) {
if (!onWsMessage) return; clearTimeout(wsReloadTimerRef.current);
// onWsMessage ist eine Funktion, die wir anmelden wsReloadTimerRef.current = null;
}, [onWsMessage]); }
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (type === 'RUNTIME_ACTIVITY_CHANGED') {
setActiveCronRuns(normalizeActiveCronRuns(message?.payload));
return;
}
if (type === 'CRON_JOBS_UPDATED' || type === 'CRON_JOB_UPDATED') {
scheduleLoadAll(80);
}
}
});
// Cron-Ausdruck validieren (debounced) // Cron-Ausdruck validieren (debounced)
@@ -28,7 +28,6 @@ const GENERAL_TOOL_KEYS = new Set([
'mkvmerge_command', 'mkvmerge_command',
'ffprobe_command', 'ffprobe_command',
'handbrake_restart_delete_incomplete_output', 'handbrake_restart_delete_incomplete_output',
'handbrake_pre_metadata_scan_enabled',
'script_test_timeout_ms' 'script_test_timeout_ms'
]); ]);
@@ -58,7 +57,8 @@ const ALWAYS_HIDDEN_SETTING_KEYS = new Set([
'makemkv_rip_mode', 'makemkv_rip_mode',
'makemkv_rip_mode_bluray', 'makemkv_rip_mode_bluray',
'makemkv_rip_mode_dvd', 'makemkv_rip_mode_dvd',
'makemkv_backup_mode' 'makemkv_backup_mode',
'handbrake_pre_metadata_scan_enabled'
]); ]);
const EXPERT_ONLY_SETTING_KEYS = new Set([ const EXPERT_ONLY_SETTING_KEYS = new Set([
'pushover_device', 'pushover_device',
@@ -78,6 +78,9 @@ const EXPERT_ONLY_SETTING_KEYS = new Set([
'cdparanoia_command', 'cdparanoia_command',
'script_test_timeout_ms' 'script_test_timeout_ms'
]); ]);
const EXPERT_ONLY_CATEGORY_NAMES = new Set([
'logging'
]);
function toBoolean(value) { function toBoolean(value) {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
@@ -436,6 +439,14 @@ function filterSettingsByVisibility(settings, expertModeEnabled) {
return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled)); return list.filter((setting) => !shouldHideSettingByExpertMode(setting?.key, expertModeEnabled));
} }
function shouldHideCategoryByExpertMode(categoryName, expertModeEnabled) {
const normalizedCategory = normalizeText(categoryName);
if (!normalizedCategory) {
return false;
}
return !expertModeEnabled && EXPERT_ONLY_CATEGORY_NAMES.has(normalizedCategory);
}
function buildToolSections(settings) { function buildToolSections(settings) {
const list = Array.isArray(settings) ? settings : []; const list = Array.isArray(settings) ? settings : [];
const generalBucket = { const generalBucket = {
@@ -1118,6 +1129,7 @@ export default function DynamicSettingsForm({
const safeCategories = Array.isArray(categories) ? categories : []; const safeCategories = Array.isArray(categories) ? categories : [];
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]); const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
const visibleCategories = safeCategories const visibleCategories = safeCategories
.filter((category) => !shouldHideCategoryByExpertMode(category?.category, expertModeEnabled))
.map((category) => ({ .map((category) => ({
...category, ...category,
settings: filterSettingsByVisibility(category?.settings, expertModeEnabled) settings: filterSettingsByVisibility(category?.settings, expertModeEnabled)
+177 -100
View File
@@ -641,24 +641,39 @@ function statusBadgeMeta(status, queued = false, options = {}) {
if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') { if (normalized === 'READY_TO_ENCODE' || normalized === 'READY_TO_START') {
return { label, icon: 'pi-play-circle', tone: 'info' }; return { label, icon: 'pi-play-circle', tone: 'info' };
} }
if (normalized === 'CD_READY_TO_RIP') {
return { label, icon: 'pi-play-circle', tone: 'info' };
}
if (normalized === 'WAITING_FOR_USER_DECISION') { if (normalized === 'WAITING_FOR_USER_DECISION') {
return { label, icon: 'pi-exclamation-circle', tone: 'warning' }; return { label, icon: 'pi-exclamation-circle', tone: 'warning' };
} }
if (normalized === 'METADATA_SELECTION') { if (normalized === 'METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' }; return { label, icon: 'pi-list', tone: 'warning' };
} }
if (normalized === 'CD_METADATA_SELECTION') {
return { label, icon: 'pi-list', tone: 'warning' };
}
if (normalized === 'ANALYZING') { if (normalized === 'ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' }; return { label, icon: 'pi-search', tone: 'warning' };
} }
if (normalized === 'CD_ANALYZING') {
return { label, icon: 'pi-search', tone: 'warning' };
}
if (normalized === 'RIPPING') { if (normalized === 'RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' }; return { label, icon: 'pi-download', tone: 'warning' };
} }
if (normalized === 'CD_RIPPING') {
return { label, icon: 'pi-download', tone: 'warning' };
}
if (normalized === 'MEDIAINFO_CHECK') { if (normalized === 'MEDIAINFO_CHECK') {
return { label, icon: 'pi-sliders-h', tone: 'warning' }; return { label, icon: 'pi-sliders-h', tone: 'warning' };
} }
if (normalized === 'ENCODING') { if (normalized === 'ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' }; return { label, icon: 'pi-cog', tone: 'warning' };
} }
if (normalized === 'CD_ENCODING') {
return { label, icon: 'pi-cog', tone: 'warning' };
}
if (normalized === 'MERGING') { if (normalized === 'MERGING') {
return { label, icon: 'pi-spinner pi-spin', tone: 'warning' }; return { label, icon: 'pi-spinner pi-spin', tone: 'warning' };
} }
@@ -669,6 +684,25 @@ function normalizePathForCompare(value) {
return String(value || '').trim().replace(/[\\/]+$/, ''); return String(value || '').trim().replace(/[\\/]+$/, '');
} }
function isIncompleteRawPath(value) {
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
if (!normalized) {
return false;
}
const segments = normalized.split('/').filter(Boolean);
const baseName = segments[segments.length - 1] || '';
return /^incomplete_/i.test(baseName);
}
function isIncompleteOutputPath(value) {
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
if (!normalized) {
return false;
}
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized)
|| /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
}
function buildMergeToolLogFallback(job = null) { function buildMergeToolLogFallback(job = null) {
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object'
? job.handbrakeInfo ? job.handbrakeInfo
@@ -695,19 +729,13 @@ function buildMergeToolLogFallback(job = null) {
return lines.join('\n'); return lines.join('\n');
} }
function omdbField(value) { function metadataField(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
return raw || '-'; return raw || '-';
} }
function omdbRottenTomatoesScore(omdbInfo) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : [];
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === 'rotten tomatoes');
return omdbField(entry?.Value);
}
function normalizeMetadataProvider(value) { function normalizeMetadataProvider(value) {
return String(value || '').trim().toLowerCase() || 'omdb'; return String(value || '').trim().toLowerCase() || 'tmdb';
} }
function resolveJobMetadataContext(job) { function resolveJobMetadataContext(job) {
@@ -734,7 +762,7 @@ function resolveJobMetadataContext(job) {
const metadataProvider = normalizeMetadataProvider( const metadataProvider = normalizeMetadataProvider(
mergedSelectedMetadata?.metadataProvider mergedSelectedMetadata?.metadataProvider
|| analyzeContext?.metadataProvider || analyzeContext?.metadataProvider
|| 'omdb' || 'tmdb'
); );
return { return {
analyzeContext, analyzeContext,
@@ -744,7 +772,13 @@ function resolveJobMetadataContext(job) {
} }
function uniqueNameList(values, maxItems = 10) { function uniqueNameList(values, maxItems = 10) {
const source = Array.isArray(values) ? values : []; const source = Array.isArray(values)
? values
: (typeof values === 'string'
? values.split(',')
: (values && typeof values === 'object'
? [values]
: []));
const limit = Math.max(1, Number(maxItems || 10)); const limit = Math.max(1, Number(maxItems || 10));
const output = []; const output = [];
const seen = new Set(); const seen = new Set();
@@ -787,53 +821,34 @@ function formatRuntimeLabel(value) {
return text || null; return text || null;
} }
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) { function resolveMetadataDetailsForDisplay(job) {
const metadataContext = resolveJobMetadataContext(job); const metadataContext = resolveJobMetadataContext(job);
const provider = metadataContext.metadataProvider;
const selectedMetadata = metadataContext.selectedMetadata; const selectedMetadata = metadataContext.selectedMetadata;
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object' const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails ? selectedMetadata.tmdbDetails
: {}; : {};
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb'; const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
if (isTmdbProvider) { const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null; const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null; const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null; const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime); ? ratingNumber.toFixed(1)
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage); : null;
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0 const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
? ratingNumber.toFixed(1) const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
: null;
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
return {
provider: 'tmdb',
title: 'TMDb Details',
matchLabel: 'TMDb Match',
hasMatch,
imdbId,
director: director || '-',
actors: actors || '-',
runtime: runtime || '-',
genre: genre || '-',
tmdbRating: tmdbRating || '-'
};
}
return { return {
provider: 'omdb', provider: 'tmdb',
title: 'OMDb Details', title: 'TMDb Details',
matchLabel: 'OMDb Match', matchLabel: 'TMDb Match',
hasMatch: Boolean(job?.selected_from_omdb), hasMatch,
imdbId: String(job?.imdb_id || '').trim() || null, imdbId,
director: omdbField(omdbInfo?.Director), director: director || '-',
actors: omdbField(omdbInfo?.Actors), actors: actors || '-',
runtime: omdbField(omdbInfo?.Runtime), runtime: runtime || '-',
genre: omdbField(omdbInfo?.Genre), genre: genre || '-',
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo), tmdbRating: tmdbRating || '-'
imdbRating: omdbField(omdbInfo?.imdbRating)
}; };
} }
@@ -1058,7 +1073,7 @@ export default function JobDetailDialog({
detailLoading = false, detailLoading = false,
onLoadLog, onLoadLog,
logLoadingMode = null, logLoadingMode = null,
onAssignOmdb, onAssignMetadata,
onAssignCdMetadata, onAssignCdMetadata,
onGenerateNfo, onGenerateNfo,
onAcknowledgeError, onAcknowledgeError,
@@ -1076,7 +1091,7 @@ export default function JobDetailDialog({
onRestoreMultipartMerge, onRestoreMultipartMerge,
onCancel, onCancel,
isQueued = false, isQueued = false,
omdbAssignBusy = false, metadataAssignBusy = false,
cdMetadataAssignBusy = false, cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false, acknowledgeErrorBusy = false,
generateNfoBusy = false, generateNfoBusy = false,
@@ -1234,8 +1249,7 @@ export default function JobDetailDialog({
job?.rawStatus?.exists job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true && job?.rawStatus?.isEmpty !== true
&& !running && !running
&& mediaType !== 'audiobook' && (isAudiobook || !blockMetadataAndReviewUntilRetry)
&& !blockMetadataAndReviewUntilRetry
&& typeof onRestartReview === 'function' && typeof onRestartReview === 'function'
); );
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object' const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
@@ -1328,9 +1342,8 @@ export default function JobDetailDialog({
queueLocked, queueLocked,
{ label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' } { label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' }
); );
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const metadataContext = resolveJobMetadataContext(job); const metadataContext = resolveJobMetadataContext(job);
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); const metadataDetails = resolveMetadataDetailsForDisplay(job);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container'; const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
@@ -1455,17 +1468,15 @@ export default function JobDetailDialog({
&& Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0) && Number(multipartMergeSummary?.inputReady || 0) >= Number(multipartMergeSummary?.inputExpected || 0)
); );
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null; const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info'; const metadataJsonTitle = 'TMDb Info';
const metadataJsonValue = metadataDetails.provider === 'tmdb' const metadataJsonValue = metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null;
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
: job?.omdbInfo;
const metadataAssignProvider = isDvdSeries const metadataAssignProvider = isDvdSeries
? 'tmdb' ? 'tmdb'
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb'); : 'tmdb';
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen'; const metadataAssignButtonLabel = 'TMDb neu zuweisen';
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb' const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.' ? 'Öffnet die TMDb-Suche, um Metadaten (Titel, Staffel/Episoden bzw. Film) neu zuzuweisen.'
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.'; : 'Öffnet die TMDb-Suche, um Metadaten neu zuzuweisen.';
const configuredSelection = buildConfiguredScriptAndChainSelection(job); const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0 const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0 || configuredSelection.postScriptIds.length > 0
@@ -1490,8 +1501,24 @@ export default function JobDetailDialog({
|| '' || ''
).trim() || null; ).trim() || null;
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null; const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function'); const jobMediaType = resolveMediaType(job);
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function'); const isDiscMediaJob = ['dvd', 'bluray', 'cd'].includes(jobMediaType);
const jobStatusUpper = String(job?.status || '').trim().toUpperCase();
const outputDownloadReady = Boolean(
job?.output_path
&& job?.outputStatus?.exists
&& !isIncompleteOutputPath(job.output_path)
&& (job?.encodeSuccess || jobStatusUpper === 'FINISHED')
);
const rawDownloadReady = Boolean(
resolvedRawPath
&& job?.rawStatus?.exists
&& job?.rawStatus?.isEmpty !== true
&& !isIncompleteRawPath(resolvedRawPath)
&& (!isDiscMediaJob || Boolean(job?.ripSuccessful))
);
const canDownloadRaw = Boolean(rawDownloadReady && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(outputDownloadReady && typeof onDownloadArchive === 'function');
const outputFolders = (() => { const outputFolders = (() => {
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : []; const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
if (!isDiskContainer) { if (!isDiskContainer) {
@@ -1507,7 +1534,9 @@ export default function JobDetailDialog({
seen.add(outputPath); seen.add(outputPath);
merged.push({ merged.push({
id: folder?.id ?? fallbackId ?? outputPath, id: folder?.id ?? fallbackId ?? outputPath,
output_path: outputPath output_path: outputPath,
job_id: normalizePositiveInteger(folder?.job_id ?? folder?.jobId ?? null) || null,
exists: folder?.exists === undefined ? undefined : Boolean(folder?.exists)
}); });
}; };
for (const folder of baseFolders) { for (const folder of baseFolders) {
@@ -1516,11 +1545,17 @@ export default function JobDetailDialog({
for (const child of childJobs) { for (const child of childJobs) {
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : []; const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
for (const childFolder of childOutputFolders) { for (const childFolder of childOutputFolders) {
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`); addFolder(
{
...childFolder,
job_id: childFolder?.job_id ?? child?.id ?? null
},
`child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`
);
} }
if (child?.output_path) { if (child?.output_path) {
addFolder( addFolder(
{ output_path: child.output_path }, { output_path: child.output_path, job_id: child?.id ?? null },
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}` `child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
); );
} }
@@ -1843,37 +1878,24 @@ export default function JobDetailDialog({
<div className="job-meta-list"> <div className="job-meta-list">
<div className="job-meta-item"> <div className="job-meta-item">
<strong>Regisseur:</strong> <strong>Regisseur:</strong>
<span>{omdbField(metadataDetails.director)}</span> <span>{metadataField(metadataDetails.director)}</span>
</div> </div>
<div className="job-meta-item"> <div className="job-meta-item">
<strong>Schauspieler:</strong> <strong>Schauspieler:</strong>
<span>{omdbField(metadataDetails.actors)}</span> <span>{metadataField(metadataDetails.actors)}</span>
</div> </div>
<div className="job-meta-item"> <div className="job-meta-item">
<strong>Laufzeit:</strong> <strong>Laufzeit:</strong>
<span>{omdbField(metadataDetails.runtime)}</span> <span>{metadataField(metadataDetails.runtime)}</span>
</div> </div>
<div className="job-meta-item"> <div className="job-meta-item">
<strong>Genre:</strong> <strong>Genre:</strong>
<span>{omdbField(metadataDetails.genre)}</span> <span>{metadataField(metadataDetails.genre)}</span>
</div>
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{metadataField(metadataDetails.tmdbRating)}</span>
</div> </div>
{metadataDetails.provider === 'tmdb' ? (
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{omdbField(metadataDetails.tmdbRating)}</span>
</div>
) : (
<>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(metadataDetails.imdbRating)}</span>
</div>
</>
)}
</div> </div>
</section> </section>
) : null} ) : null}
@@ -1954,7 +1976,16 @@ export default function JobDetailDialog({
const childDiscNumber = resolveSeriesDiscNumber(child); const childDiscNumber = resolveSeriesDiscNumber(child);
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:'; const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
const childRawPath = child?.raw_path || child?.output_path || null; const childRawPath = child?.raw_path || child?.output_path || null;
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function'); const childMediaType = resolveMediaType(child);
const childIsDiscMedia = ['dvd', 'bluray', 'cd'].includes(childMediaType);
const childRawDownloadReady = Boolean(
childRawPath
&& child?.rawStatus?.exists
&& child?.rawStatus?.isEmpty !== true
&& !isIncompleteRawPath(childRawPath)
&& (!childIsDiscMedia || Boolean(child?.ripSuccessful))
);
const canDownloadChildRaw = Boolean(childRawDownloadReady && typeof onDownloadArchive === 'function');
return ( return (
<PathField <PathField
key={`child-raw-${child.id}`} key={`child-raw-${child.id}`}
@@ -1973,6 +2004,19 @@ export default function JobDetailDialog({
if (!folderPath) { if (!folderPath) {
return null; return null;
} }
const folderOwnerJobId = normalizePositiveInteger(folder?.job_id) || normalizePositiveInteger(job?.id);
const folderOwner = folderOwnerJobId === normalizePositiveInteger(job?.id)
? job
: (childJobs.find((child) => normalizePositiveInteger(child?.id) === folderOwnerJobId) || job);
const folderOwnerStatusUpper = String(folderOwner?.status || '').trim().toUpperCase();
const folderExists = folder?.exists !== undefined
? Boolean(folder?.exists)
: Boolean(folderOwner?.outputStatus?.exists);
const folderDownloadReady = Boolean(
folderExists
&& !isIncompleteOutputPath(folderPath)
&& (folderOwner?.encodeSuccess || folderOwnerStatusUpper === 'FINISHED')
);
const normalizedFolderPath = normalizePathForCompare(folderPath); const normalizedFolderPath = normalizePathForCompare(folderPath);
const isMergeOutput = Boolean( const isMergeOutput = Boolean(
(normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath)) (normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath))
@@ -1980,13 +2024,13 @@ export default function JobDetailDialog({
); );
const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output'; const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output';
const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`; const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`;
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function'); const canDownloadFolder = Boolean(folderDownloadReady && typeof onDownloadOutputFolder === 'function');
return ( return (
<PathField <PathField
key={folder.id || folderPath} key={folder.id || folderPath}
label={label} label={label}
value={folderPath} value={folderPath}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null} onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath, folderOwnerJobId) : null}
downloadDisabled={!canDownloadFolder} downloadDisabled={!canDownloadFolder}
downloadLoading={downloadFolderBusyPath === folderPath} downloadLoading={downloadFolderBusyPath === folderPath}
/> />
@@ -2610,7 +2654,7 @@ export default function JobDetailDialog({
</div> </div>
) : null} ) : null}
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? ( {!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignMetadata === 'function' ? (
<div className="actions-group"> <div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div> <div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item"> <div className="action-item">
@@ -2620,8 +2664,8 @@ export default function JobDetailDialog({
severity="secondary" severity="secondary"
outlined outlined
size="small" size="small"
onClick={() => onAssignOmdb?.(child)} onClick={() => onAssignMetadata?.(child)}
loading={omdbAssignBusy} loading={metadataAssignBusy}
disabled={!childActionState.childCanAssignMetadata} disabled={!childActionState.childCanAssignMetadata}
/> />
<span className="action-desc">{metadataAssignActionDescription}</span> <span className="action-desc">{metadataAssignActionDescription}</span>
@@ -2917,7 +2961,7 @@ export default function JobDetailDialog({
loading={actionBusy} loading={actionBusy}
disabled={!canCdStartReview} disabled={!canCdStartReview}
/> />
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen dann Encode aus vorhandenen WAV-Daten starten.</span> <span className="action-desc">Öffnet den Vorprüfungs-Workflow mit den zuletzt verwendeten CD-Metadaten: Trackauswahl und Ausgabeeinstellungen prüfen, dann Encode aus vorhandenen WAV-Daten starten.</span>
</div> </div>
<div className="action-item"> <div className="action-item">
<Button <Button
@@ -2933,6 +2977,39 @@ export default function JobDetailDialog({
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span> <span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div> </div>
</div> </div>
) : isAudiobook ? (
<div className="actions-group">
<div className="actions-group-label"><i className="pi pi-play-circle" /> Audiobook</div>
{typeof onReencode === 'function' ? (
<div className="action-item">
<Button
label="Encode neu starten"
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<span className="action-desc">Startet FFmpeg direkt mit den zuletzt bestätigten Einstellungen neu. Es gibt dabei keinen Anpassungsschritt.</span>
</div>
) : null}
{typeof onRestartReview === 'function' ? (
<div className="action-item">
<Button
label="Vorprüfung starten"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview}
/>
<span className="action-desc">Legt den Job neu aus der Quelldatei an und öffnet ihn im Ripper, damit Einstellungen vor dem Encode angepasst werden können.</span>
</div>
) : null}
</div>
) : ( ) : (
canShowGeneralEncodeActions ? ( canShowGeneralEncodeActions ? (
<div className="actions-group"> <div className="actions-group">
@@ -3025,7 +3102,7 @@ export default function JobDetailDialog({
</div> </div>
) : null} ) : null}
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? ( {!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignMetadata === 'function' ? (
<div className="actions-group"> <div className="actions-group">
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div> <div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item"> <div className="action-item">
@@ -3035,8 +3112,8 @@ export default function JobDetailDialog({
severity="secondary" severity="secondary"
outlined outlined
size="small" size="small"
onClick={() => onAssignOmdb?.(job)} onClick={() => onAssignMetadata?.(job)}
loading={omdbAssignBusy} loading={metadataAssignBusy}
disabled={running} disabled={running}
/> />
<span className="action-desc">{metadataAssignActionDescription}</span> <span className="action-desc">{metadataAssignActionDescription}</span>
@@ -301,6 +301,17 @@ function resolveSubtitleTrackVariantFlags(track) {
return { isForcedOnly, fullHasForced }; return { isForcedOnly, fullHasForced };
} }
function isClosedCaptionSubtitleTrack(track) {
if (Boolean(track?.sdhLikely)) {
return true;
}
const title = String(track?.title || track?.description || '').trim();
if (!title) {
return false;
}
return /\b(closed\s*caption|cc|sdh|hoh|hearing\s*impaired)\b/i.test(title);
}
function compareForcedSubtitleCandidate(a, b) { function compareForcedSubtitleCandidate(a, b) {
const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag)); const defaultDiff = Number(Boolean(b?.defaultFlag)) - Number(Boolean(a?.defaultFlag));
if (defaultDiff !== 0) { if (defaultDiff !== 0) {
@@ -1153,6 +1164,7 @@ function TrackList({
if (type === 'subtitle') { if (type === 'subtitle') {
const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {}); const selectedVariants = normalizeSubtitleVariantSelectionMap(selectedSubtitleVariantSelection || {});
const selectedTrackIdSet = new Set(normalizeTrackIdList(selectedTrackIds).map((id) => String(id)));
const sortedSubtitleTracks = orderedTracks const sortedSubtitleTracks = orderedTracks
.filter((track) => !isDuplicateSubtitleTrack(track)); .filter((track) => !isDuplicateSubtitleTrack(track));
@@ -1206,10 +1218,13 @@ function TrackList({
<div className="media-track-list"> <div className="media-track-list">
{sortedSubtitleTracks.map((track) => { {sortedSubtitleTracks.map((track) => {
const burned = isBurnedSubtitleTrack(track); const burned = isBurnedSubtitleTrack(track);
const trackId = normalizeTrackId(track?.id);
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und'); const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und'); const displayLanguage = toLang2(track?.language || track?.languageLabel || 'und');
const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title); const displayCodec = simplifyCodec('subtitle', track?.format, track?.description || track?.title);
const flags = resolveSubtitleTrackVariantFlags(track); const flags = resolveSubtitleTrackVariantFlags(track);
const isClosedCaption = isClosedCaptionSubtitleTrack(track);
const ccSuffix = isClosedCaption ? ' | Closed Caption (CC/SDH)' : '';
const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase(); const confidenceRaw = String(track?.sourceConfidence || '').trim().toLowerCase();
const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low' const confidence = confidenceRaw === 'high' || confidenceRaw === 'medium' || confidenceRaw === 'low'
? confidenceRaw ? confidenceRaw
@@ -1232,7 +1247,7 @@ function TrackList({
} }
onToggleSubtitleVariant(language, 'forced', event.target.checked); onToggleSubtitleVariant(language, 'forced', event.target.checked);
}, },
text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})`, text: `#${track.id} | ${displayLanguage} | Automatische Übersetzungen (${displayCodec})${ccSuffix}`,
confidence confidence
})} })}
<small className="track-action-note">Untertitel: {actionInfo}</small> <small className="track-action-note">Untertitel: {actionInfo}</small>
@@ -1248,7 +1263,7 @@ function TrackList({
return ( return (
<div key={`${title}-${track.id}-combo`} className="media-track-item"> <div key={`${title}-${track.id}-combo`} className="media-track-item">
<div className="media-track-item"> <div className="media-track-item">
<small className="track-action-note">{`#${track.id} | ${displayLanguage}`}</small> <small className="track-action-note">{`#${track.id} | ${displayLanguage}${ccSuffix}`}</small>
</div> </div>
{renderVariantRow({ {renderVariantRow({
key: `${title}-${track.id}-combo-full`, key: `${title}-${track.id}-combo-full`,
@@ -1282,7 +1297,9 @@ function TrackList({
); );
} }
const checked = allowSelection ? Boolean(languageSelection.full) : Boolean(track?.selectedForEncode); const checked = allowSelection
? (trackId !== null && selectedTrackIdSet.has(String(trackId)))
: Boolean(track?.selectedForEncode);
const disabled = !allowSelection || burned; const disabled = !allowSelection || burned;
const actionInfo = resolveSubtitleActionInfo(track, checked); const actionInfo = resolveSubtitleActionInfo(track, checked);
return ( return (
@@ -1292,12 +1309,12 @@ function TrackList({
checked, checked,
disabled, disabled,
onChange: (event) => { onChange: (event) => {
if (disabled || typeof onToggleSubtitleVariant !== 'function') { if (disabled || typeof onToggleTrack !== 'function' || trackId === null) {
return; return;
} }
onToggleSubtitleVariant(language, 'full', event.target.checked); onToggleTrack(trackId, event.target.checked);
}, },
text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})` text: `#${track.id} | ${displayLanguage} | Komplette Untertitel (${displayCodec})${ccSuffix}`
})} })}
<small className="track-action-note">Untertitel: {actionInfo}</small> <small className="track-action-note">Untertitel: {actionInfo}</small>
</div> </div>
@@ -2177,9 +2194,11 @@ export default function MediaInfoReviewPanel({
</div> </div>
) : null} ) : null}
{/* Pre-Encode Items (scripts + chains unified) */} {!compactTitleSelection ? (
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? ( <div className="encode-automation-grid">
<div className="post-script-box"> {/* Pre-Encode Items (scripts + chains unified) */}
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
<div className="post-script-box">
<h4>Pre-Encode Ausführungen (optional)</h4> <h4>Pre-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( {scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small> <small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
@@ -2294,13 +2313,12 @@ export default function MediaInfoReviewPanel({
) : null} ) : null}
</div> </div>
) : null} ) : null}
<small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small> <small>Ausführung vor dem Encoding, strikt nacheinander. Bei Fehler wird der Encode abgebrochen.</small>
</div> </div>
) : null} ) : null}
{/* Post-Encode Items (scripts + chains unified) */} {/* Post-Encode Items (scripts + chains unified) */}
{!compactTitleSelection ? ( <div className="post-script-box">
<div className="post-script-box">
<h4>Post-Encode Ausführungen (optional)</h4> <h4>Post-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? ( {scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small> <small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
@@ -2415,7 +2433,8 @@ export default function MediaInfoReviewPanel({
) : null} ) : null}
</div> </div>
) : null} ) : null}
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small> <small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
</div> </div>
) : null} ) : null}
@@ -2716,6 +2735,12 @@ export default function MediaInfoReviewPanel({
selectedTrackIds={selectedSubtitleTrackIds} selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection} selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder} selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
}}
onToggleSubtitleVariant={(language, variant, checked) => { onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') { if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return; return;
@@ -2751,6 +2776,12 @@ export default function MediaInfoReviewPanel({
selectedTrackIds={selectedSubtitleTrackIds} selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection} selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder} selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'subtitle', trackId, checked);
}}
onToggleSubtitleVariant={(language, variant, checked) => { onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') { if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return; return;
@@ -4,7 +4,6 @@ import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable'; import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column'; import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
export default function MetadataSelectionDialog({ export default function MetadataSelectionDialog({
visible, visible,
@@ -105,6 +104,13 @@ export default function MetadataSelectionDialog({
} }
return null; return null;
}; };
const normalizeSeriesClassification = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'series' || raw === 'film' || raw === 'ambiguous') {
return raw;
}
return null;
};
const parseDiscNumber = (rawValue) => { const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim(); const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) { if (!/^\d+$/.test(text)) {
@@ -133,45 +139,78 @@ export default function MetadataSelectionDialog({
const [conflictError, setConflictError] = useState(''); const [conflictError, setConflictError] = useState('');
const [seasonFilter, setSeasonFilter] = useState(''); const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState(''); const [discValidationError, setDiscValidationError] = useState('');
const [manualWorkflowKind, setManualWorkflowKind] = useState(null);
const activeSearchRequestRef = useRef(0); const activeSearchRequestRef = useRef(0);
const autoSearchDoneRef = useRef(false); const autoSearchDoneRef = useRef(false);
const autoSearchWorkflowRef = useRef(null);
const wasVisibleRef = useRef(false);
const lastVisibleContextIdentityRef = useRef(null);
const contextWorkflowKind = normalizeWorkflowKind( const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind context?.selectedMetadata?.workflowKind
|| context?.workflowKind || context?.workflowKind
|| null || null
); );
const contextSelectedMetadataProvider = String(
context?.selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const contextMetadataProvider = (
contextWorkflowKind === 'series'
? 'tmdb'
: (contextWorkflowKind === 'film'
? 'omdb'
: (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase()))
) || 'omdb';
const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider);
const mediaProfile = String( const mediaProfile = String(
context?.mediaProfile context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile || context?.selectedMetadata?.mediaProfile
|| '' || ''
).trim().toLowerCase(); ).trim().toLowerCase();
const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb'; const metadataProvider = 'tmdb';
const providerOptions = mediaProfile === 'dvd' const providerLabel = 'TMDb';
? [ const seriesDecisionClassification = normalizeSeriesClassification(context?.seriesDecision?.classification);
{ label: 'Film (OMDb)', value: 'omdb' }, const hasSeriesSignals = useMemo(() => {
{ label: 'Serie (TMDb)', value: 'tmdb' } if (contextWorkflowKind === 'series') {
] return true;
: [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }]; }
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'; const selectedMetadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
const supportsExternalIdInput = metadataProvider === 'omdb'; ? context.selectedMetadata
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile); : {};
const metadataKind = String(selectedMetadata?.metadataKind || '').trim().toLowerCase();
if (metadataKind === 'series' || metadataKind === 'season') {
return true;
}
if (Number(selectedMetadata?.seasonNumber || 0) > 0) {
return true;
}
if (Number(context?.seriesLookupHint?.seasonNumber || 0) > 0) {
return true;
}
if (Boolean(context?.seriesAnalysis?.summary?.seriesLike)) {
return true;
}
const contextCandidates = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [];
return contextCandidates.some((row) => Number(row?.seasonNumber || 0) > 0);
}, [context, contextWorkflowKind]);
const recommendedWorkflowKind = normalizeWorkflowKind(
context?.seriesDecision?.recommendedWorkflowKind
|| contextWorkflowKind
|| null
) || (hasSeriesSignals ? 'series' : 'film');
const effectiveWorkflowKind = manualWorkflowKind || recommendedWorkflowKind;
const allowsWorkflowSwitch = (
(mediaProfile === 'dvd' || mediaProfile === 'bluray')
&& seriesDecisionClassification === 'ambiguous'
);
const supportsExternalIdInput = false;
const requiresDiscNumber = metadataProvider === 'tmdb'
&& effectiveWorkflowKind === 'series'
&& (mediaProfile === 'dvd' || mediaProfile === 'bluray');
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber); const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null; const hasValidDiscNumber = effectiveDiscNumber !== null;
const contextJobId = Number(context?.jobId || 0) || null;
const contextIdentity = contextJobId
? `job:${contextJobId}`
: `detected:${String(context?.detectedTitle || '').trim().toLowerCase()}`;
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
wasVisibleRef.current = false;
lastVisibleContextIdentityRef.current = null;
return;
}
const openingNow = !wasVisibleRef.current;
const contextChanged = lastVisibleContextIdentityRef.current !== contextIdentity;
if (!openingNow && !contextChanged) {
return; return;
} }
@@ -198,10 +237,13 @@ export default function MetadataSelectionDialog({
setConflictError(''); setConflictError('');
setSeasonFilter(''); setSeasonFilter('');
setDiscValidationError(''); setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider); setManualWorkflowKind(allowsWorkflowSwitch ? recommendedWorkflowKind : null);
autoSearchDoneRef.current = false; autoSearchDoneRef.current = false;
autoSearchWorkflowRef.current = null;
activeSearchRequestRef.current += 1; activeSearchRequestRef.current += 1;
}, [visible, context]); wasVisibleRef.current = true;
lastVisibleContextIdentityRef.current = contextIdentity;
}, [visible, contextIdentity, context, allowsWorkflowSwitch, recommendedWorkflowKind]);
useEffect(() => { useEffect(() => {
if (!discValidationError) { if (!discValidationError) {
@@ -221,7 +263,7 @@ export default function MetadataSelectionDialog({
const rows = useMemo(() => { const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates) const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates ? context.metadataCandidates
: (context?.omdbCandidates || []); : [];
const sourceRows = hasSearchOverride ? extraResults : base; const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : []; const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map(); const map = new Map();
@@ -246,7 +288,7 @@ export default function MetadataSelectionDialog({
}, [context, extraResults, hasSearchOverride]); }, [context, extraResults, hasSearchOverride]);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') { if (effectiveWorkflowKind !== 'series') {
return rows; return rows;
} }
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase(); const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
@@ -261,7 +303,7 @@ export default function MetadataSelectionDialog({
}, [metadataProvider, rows, seasonFilter]); }, [metadataProvider, rows, seasonFilter]);
const titleWithPosterBody = (row) => ( const titleWithPosterBody = (row) => (
<div className="omdb-row"> <div className="metadata-row">
{row.poster && row.poster !== 'N/A' ? ( {row.poster && row.poster !== 'N/A' ? (
<img src={row.poster} alt={row.title} className="poster-thumb-lg" /> <img src={row.poster} alt={row.title} className="poster-thumb-lg" />
) : ( ) : (
@@ -299,7 +341,8 @@ export default function MetadataSelectionDialog({
let effectiveResults = []; let effectiveResults = [];
for (const candidateQuery of queryVariants) { for (const candidateQuery of queryVariants) {
const results = await onSearch(candidateQuery, { const results = await onSearch(candidateQuery, {
metadataProvider metadataProvider,
workflowKind: effectiveWorkflowKind
}); });
if (activeSearchRequestRef.current !== requestId) { if (activeSearchRequestRef.current !== requestId) {
return; return;
@@ -323,20 +366,26 @@ export default function MetadataSelectionDialog({
}; };
useEffect(() => { useEffect(() => {
if (!visible || autoSearchDoneRef.current) { if (!visible) {
return; return;
} }
if (typeof onSearch !== 'function') { if (typeof onSearch !== 'function') {
autoSearchDoneRef.current = true; autoSearchDoneRef.current = true;
autoSearchWorkflowRef.current = effectiveWorkflowKind;
return; return;
} }
const trimmedQuery = String(query || '').trim(); const trimmedQuery = String(query || '').trim();
if (!trimmedQuery) { if (!trimmedQuery) {
return; return;
} }
const workflowChanged = autoSearchWorkflowRef.current !== effectiveWorkflowKind;
if (autoSearchDoneRef.current && !workflowChanged) {
return;
}
autoSearchDoneRef.current = true; autoSearchDoneRef.current = true;
autoSearchWorkflowRef.current = effectiveWorkflowKind;
void handleSearch(); void handleSearch();
}, [visible, query, metadataProvider, onSearch]); }, [visible, query, effectiveWorkflowKind, onSearch]);
const handleSubmitError = (error, pendingPayload = null) => { const handleSubmitError = (error, pendingPayload = null) => {
const details = Array.isArray(error?.details) ? error.details : []; const details = Array.isArray(error?.details) ? error.details : [];
@@ -393,12 +442,22 @@ export default function MetadataSelectionDialog({
year: selected.year, year: selected.year,
imdbId: selected.imdbId, imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null, poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
fromOmdb: metadataProvider === 'omdb',
metadataProvider, metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', workflowKind: effectiveWorkflowKind,
providerId: selected.providerId || null, providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null, tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null, metadataKind: selected.metadataKind || null,
voteAverage: Number.isFinite(Number(selected?.voteAverage))
? Number(Number(selected.voteAverage).toFixed(1))
: null,
imdbRating: String(
selected?.imdbRating
|| selected?.tmdbDetails?.imdbRating
|| ''
).trim() || null,
tmdbDetails: selected?.tmdbDetails && typeof selected.tmdbDetails === 'object'
? selected.tmdbDetails
: null,
seasonNumber: selected.seasonNumber || null, seasonNumber: selected.seasonNumber || null,
seasonName: selected.seasonName || null, seasonName: selected.seasonName || null,
episodeCount: selected.episodeCount || 0, episodeCount: selected.episodeCount || 0,
@@ -409,11 +468,10 @@ export default function MetadataSelectionDialog({
jobId: context.jobId, jobId: context.jobId,
title: manualTitle, title: manualTitle,
year: manualYear, year: manualYear,
imdbId: supportsExternalIdInput ? manualImdb : null, imdbId: null,
poster: null, poster: null,
fromOmdb: false,
metadataProvider, metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film', workflowKind: effectiveWorkflowKind,
seasonNumber: null, seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {}) ...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}; };
@@ -572,26 +630,6 @@ export default function MetadataSelectionDialog({
modal modal
> >
<div className="search-row"> <div className="search-row">
{providerOptions.length > 1 ? (
<Dropdown
value={metadataProvider}
options={providerOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb';
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
setHasSearchOverride(false);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
}}
disabled={searchBusy || busy}
style={{ minWidth: '12.5rem' }}
/>
) : null}
<InputText <InputText
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -605,8 +643,26 @@ export default function MetadataSelectionDialog({
/> />
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} /> <Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div> </div>
<div className={`metadata-filter-grid${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}> {allowsWorkflowSwitch ? (
{metadataProvider === 'tmdb' ? ( <div className="dialog-actions" style={{ justifyContent: 'flex-start', marginTop: '-0.25rem' }}>
<Button
label="Als Film suchen"
severity={effectiveWorkflowKind === 'film' ? 'primary' : 'secondary'}
outlined={effectiveWorkflowKind !== 'film'}
onClick={() => setManualWorkflowKind('film')}
disabled={searchBusy || busy}
/>
<Button
label="Als Serie suchen"
severity={effectiveWorkflowKind === 'series' ? 'primary' : 'secondary'}
outlined={effectiveWorkflowKind !== 'series'}
onClick={() => setManualWorkflowKind('series')}
disabled={searchBusy || busy}
/>
</div>
) : null}
<div className={`metadata-filter-grid${effectiveWorkflowKind !== 'series' ? ' metadata-filter-grid-single' : ''}`}>
{effectiveWorkflowKind === 'series' ? (
<InputText <InputText
value={seasonFilter} value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)} onChange={(event) => setSeasonFilter(event.target.value)}
@@ -650,8 +706,8 @@ export default function MetadataSelectionDialog({
<Column header="Titel" body={titleWithPosterBody} /> <Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} /> <Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column <Column
header={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'} header="Staffel"
body={(row) => (metadataProvider === 'tmdb' body={(row) => (effectiveWorkflowKind === 'series'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-') ? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: (row.imdbId || '-'))} : (row.imdbId || '-'))}
style={{ width: '10rem' }} style={{ width: '10rem' }}
+74 -32
View File
@@ -316,6 +316,14 @@ function normalizeSubtitleLanguageOrder(rawOrder = []) {
return output; return output;
} }
function normalizeSubtitleSelectionMode(value, fallback = 'variants') {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'track_ids' || mode === 'variants') {
return mode;
}
return fallback;
}
function resolveSubtitleTrackVariantFlags(track) { function resolveSubtitleTrackVariantFlags(track) {
const subtitleType = String(track?.subtitleType || '').trim().toLowerCase(); const subtitleType = String(track?.subtitleType || '').trim().toLowerCase();
const isForcedOnly = Boolean( const isForcedOnly = Boolean(
@@ -430,6 +438,15 @@ function buildDefaultTrackSelection(review) {
const manualSubtitleLanguageOrder = manualSelectionMatchesTitle const manualSubtitleLanguageOrder = manualSelectionMatchesTitle
? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || []) ? normalizeSubtitleLanguageOrder(manualSelection?.subtitleLanguageOrder || [])
: []; : [];
const manualHasVariants = Object.keys(manualSubtitleVariantSelection).length > 0;
const subtitleSelectionMode = manualSelectionMatchesTitle
? normalizeSubtitleSelectionMode(
manualSelection?.subtitleSelectionMode,
manualHasVariants
? 'variants'
: (manualSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
)
: 'variants';
selection[titleId] = { selection[titleId] = {
audioTrackIds: manualAudioTrackIds.length > 0 audioTrackIds: manualAudioTrackIds.length > 0
@@ -447,7 +464,8 @@ function buildDefaultTrackSelection(review) {
: defaultSubtitleVariantSelection.subtitleVariantSelection, : defaultSubtitleVariantSelection.subtitleVariantSelection,
subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0 subtitleLanguageOrder: manualSubtitleLanguageOrder.length > 0
? manualSubtitleLanguageOrder ? manualSubtitleLanguageOrder
: defaultSubtitleVariantSelection.subtitleLanguageOrder : defaultSubtitleVariantSelection.subtitleLanguageOrder,
subtitleSelectionMode
}; };
} }
@@ -460,7 +478,8 @@ function defaultTrackSelectionForTitle(review, titleId) {
audioTrackIds: [], audioTrackIds: [],
subtitleTrackIds: [], subtitleTrackIds: [],
subtitleVariantSelection: {}, subtitleVariantSelection: {},
subtitleLanguageOrder: [] subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
}; };
} }
@@ -569,15 +588,22 @@ function hasSeriesMetadataShape(source = null) {
const metadata = source && typeof source === 'object' ? source : {}; const metadata = source && typeof source === 'object' ? source : {};
const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null); const seasonNumber = normalizePositiveInt(metadata?.seasonNumber ?? metadata?.season ?? null);
const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : []; const episodes = Array.isArray(metadata?.episodes) ? metadata.episodes : [];
const metadataProvider = String(metadata?.metadataProvider || '').trim().toLowerCase();
const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase(); const workflowKind = String(metadata?.workflowKind || metadata?.kind || '').trim().toLowerCase();
const metadataKind = String(metadata?.metadataKind || '').trim().toLowerCase();
const episodeCount = Number(metadata?.episodeCount || 0) || 0;
if (['film', 'movie', 'feature'].includes(workflowKind) || ['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
return Boolean( return Boolean(
seasonNumber seasonNumber
|| episodeCount > 0
|| episodes.length > 0 || episodes.length > 0
|| metadataProvider === 'tmdb'
|| metadataProvider === 'themoviedb'
|| workflowKind === 'series' || workflowKind === 'series'
|| workflowKind === 'season' || workflowKind === 'season'
|| workflowKind === 'tv'
|| metadataKind === 'series'
|| metadataKind === 'season'
|| metadataKind === 'tv'
); );
} }
@@ -631,24 +657,14 @@ function resolveDiscWorkflowKind({
return normalized; return normalized;
} }
} }
const providerCandidates = [ if (
selected?.metadataProvider, hasSeriesMetadataShape(selected)
analyze?.metadataProvider, || hasSeriesMetadataShape(analyze)
pipelineCtx?.metadataProvider || hasSeriesMetadataShape(pipelineCtx)
]; ) {
for (const candidate of providerCandidates) { return 'series';
const provider = String(candidate || '').trim().toLowerCase();
if (!provider) {
continue;
}
if (provider === 'tmdb' || provider === 'themoviedb') {
return 'series';
}
if (provider === 'omdb') {
return 'film';
}
} }
return fallbackIsSeries ? 'series' : 'film'; return null;
} }
function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) { function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
@@ -656,7 +672,10 @@ function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') { if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
return null; return null;
} }
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film'; const kind = normalizeDiscWorkflowKind(workflowKind);
if (!kind) {
return null;
}
if (normalizedMediaProfile === 'bluray') { if (normalizedMediaProfile === 'bluray') {
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film'; return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
} }
@@ -1555,7 +1574,7 @@ export default function PipelineStatusCard({
onAnalyze, onAnalyze,
onReanalyze, onReanalyze,
onOpenMetadata, onOpenMetadata,
onReassignOmdb, onReassignMetadata,
onStart, onStart,
onRemoveFromQueue, onRemoveFromQueue,
onRestartEncode, onRestartEncode,
@@ -3496,11 +3515,24 @@ export default function PipelineStatusCard({
const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0; const hasExplicitVariantSelection = Object.keys(filteredVariantSelection).length > 0;
const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || []) const filteredSubtitleTrackIds = normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
.filter((id) => !blockedSubtitleTrackIds.has(String(id))); .filter((id) => !blockedSubtitleTrackIds.has(String(id)));
const subtitleSelectionMode = normalizeSubtitleSelectionMode(
effectiveSelection?.subtitleSelectionMode,
hasExplicitVariantSelection
? 'variants'
: (filteredSubtitleTrackIds.length > 0 ? 'track_ids' : 'variants')
);
selectedTrackSelection[titleId] = { selectedTrackSelection[titleId] = {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []), audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds, subtitleSelectionMode,
subtitleVariantSelection: filteredVariantSelection, subtitleTrackIds: subtitleSelectionMode === 'track_ids'
subtitleLanguageOrder: normalizedLanguageOrder ? filteredSubtitleTrackIds
: (hasExplicitVariantSelection ? [] : filteredSubtitleTrackIds),
...(subtitleSelectionMode === 'variants'
? {
subtitleVariantSelection: filteredVariantSelection,
subtitleLanguageOrder: normalizedLanguageOrder
}
: {})
}; };
} }
const episodeAssignments = {}; const episodeAssignments = {};
@@ -3721,7 +3753,7 @@ export default function PipelineStatusCard({
&& state !== 'IDLE' && state !== 'IDLE'
&& state !== 'DISC_DETECTED' && state !== 'DISC_DETECTED'
&& retryJobId && retryJobId
&& typeof onReassignOmdb === 'function' && typeof onReassignMetadata === 'function'
&& !isOrphanCancelled && !isOrphanCancelled
&& !blockMetadataAndReviewForRipRetry && !blockMetadataAndReviewForRipRetry
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? ( && !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
@@ -3730,7 +3762,7 @@ export default function PipelineStatusCard({
icon="pi pi-search" icon="pi pi-search"
severity="info" severity="info"
size="small" size="small"
onClick={() => onReassignOmdb?.(retryJobId)} onClick={() => onReassignMetadata?.(retryJobId)}
loading={busy} loading={busy}
/> />
) : null} ) : null}
@@ -4357,7 +4389,8 @@ export default function PipelineStatusCard({
audioTrackIds: [], audioTrackIds: [],
subtitleTrackIds: [], subtitleTrackIds: [],
subtitleVariantSelection: {}, subtitleVariantSelection: {},
subtitleLanguageOrder: [] subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
}; };
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds'; const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []); const existing = normalizeTrackIdList(current?.[key] || []);
@@ -4369,7 +4402,14 @@ export default function PipelineStatusCard({
...prev, ...prev,
[normalizedTitleId]: { [normalizedTitleId]: {
...current, ...current,
[key]: next [key]: next,
...(trackType === 'subtitle'
? {
subtitleSelectionMode: 'track_ids',
subtitleVariantSelection: {},
subtitleLanguageOrder: []
}
: {})
} }
}; };
}); });
@@ -4387,7 +4427,8 @@ export default function PipelineStatusCard({
audioTrackIds: [], audioTrackIds: [],
subtitleTrackIds: [], subtitleTrackIds: [],
subtitleVariantSelection: {}, subtitleVariantSelection: {},
subtitleLanguageOrder: [] subtitleLanguageOrder: [],
subtitleSelectionMode: 'variants'
}; };
const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {}); const currentMap = normalizeSubtitleVariantSelectionMap(current?.subtitleVariantSelection || {});
const nextMap = { ...currentMap }; const nextMap = { ...currentMap };
@@ -4415,6 +4456,7 @@ export default function PipelineStatusCard({
...prev, ...prev,
[normalizedTitleId]: { [normalizedTitleId]: {
...current, ...current,
subtitleSelectionMode: 'variants',
subtitleVariantSelection: nextMap, subtitleVariantSelection: nextMap,
subtitleLanguageOrder: nextOrder subtitleLanguageOrder: nextOrder
} }
+10 -6
View File
@@ -18,7 +18,7 @@ export function useWebSocket({ onMessage }) {
let reconnectTimer; let reconnectTimer;
let progressFlushTimer = null; let progressFlushTimer = null;
let progressFlushRaf = null; let progressFlushRaf = null;
let latestProgressMessage = null; const latestProgressMessagesByJob = new Map();
let isUnmounted = false; let isUnmounted = false;
const clearProgressFlush = () => { const clearProgressFlush = () => {
@@ -35,12 +35,14 @@ export function useWebSocket({ onMessage }) {
const flushProgressMessage = () => { const flushProgressMessage = () => {
progressFlushRaf = null; progressFlushRaf = null;
progressFlushTimer = null; progressFlushTimer = null;
const message = latestProgressMessage; if (latestProgressMessagesByJob.size === 0) {
latestProgressMessage = null;
if (!message) {
return; return;
} }
onMessageRef.current?.(message); const messages = Array.from(latestProgressMessagesByJob.values());
latestProgressMessagesByJob.clear();
for (const message of messages) {
onMessageRef.current?.(message);
}
}; };
const scheduleProgressFlush = () => { const scheduleProgressFlush = () => {
@@ -65,7 +67,9 @@ export function useWebSocket({ onMessage }) {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message?.type === 'PIPELINE_PROGRESS') { if (message?.type === 'PIPELINE_PROGRESS') {
latestProgressMessage = message; const progressJobId = message?.payload?.activeJobId;
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
latestProgressMessagesByJob.set(jobKey, message);
scheduleProgressFlush(); scheduleProgressFlush();
return; return;
} }
+144 -81
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { Card } from 'primereact/card'; import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
@@ -10,7 +9,6 @@ import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket'; import { useWebSocket } from '../hooks/useWebSocket';
import AudiobookUploadPanel from '../components/AudiobookUploadPanel'; import AudiobookUploadPanel from '../components/AudiobookUploadPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel'; import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import AudiobookOutputExplorer from '../components/AudiobookOutputExplorer';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation'; import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { resolveMediaType } from '../utils/jobTaxonomy'; import { resolveMediaType } from '../utils/jobTaxonomy';
import { confirmModal } from '../utils/confirmModal'; import { confirmModal } from '../utils/confirmModal';
@@ -25,6 +23,44 @@ function normalizeJobId(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function hasOwn(source, key) {
return source != null && Object.prototype.hasOwnProperty.call(source, key);
}
function normalizeLiveProgressForJob(job) {
const live = job?.liveProgress && typeof job.liveProgress === 'object' ? job.liveProgress : null;
if (!live) {
return null;
}
const liveContext = live?.context && typeof live.context === 'object' ? live.context : {};
return {
progress: live?.progress ?? null,
eta: live?.eta ?? null,
statusText: live?.statusText ?? null,
state: live?.state ?? null,
currentChapter: liveContext?.currentChapter && typeof liveContext.currentChapter === 'object'
? liveContext.currentChapter
: null,
completedChapterCount: Number.isFinite(Number(liveContext?.completedChapterCount))
? Number(liveContext.completedChapterCount)
: null
};
}
function extractUploadJobIdFromResponse(response) {
const payload = response && typeof response === 'object' ? response : {};
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {};
return (
normalizeJobId(result?.jobId)
|| normalizeJobId(payload?.jobId)
|| normalizeJobId(result?.id)
|| normalizeJobId(payload?.id)
|| normalizeJobId(result?.job?.id)
|| normalizeJobId(payload?.job?.id)
|| null
);
}
function parseEncodePlan(job) { function parseEncodePlan(job) {
if (job?.encodePlan && typeof job.encodePlan === 'object') { if (job?.encodePlan && typeof job.encodePlan === 'object') {
return job.encodePlan; return job.encodePlan;
@@ -71,6 +107,22 @@ function resolveAudiobookMetadata(job, encodePlan) {
}; };
} }
function buildAudiobookJobMetaLine(jobId, metadata = {}, fallbackYear = null) {
const author = String(metadata?.author || metadata?.artist || '').trim() || null;
const narrator = String(metadata?.narrator || '').trim() || null;
const chapterCount = Array.isArray(metadata?.chapters) ? metadata.chapters.length : 0;
const year = Number.isFinite(Number(metadata?.year))
? Math.trunc(Number(metadata.year))
: (Number.isFinite(Number(fallbackYear)) ? Math.trunc(Number(fallbackYear)) : null);
return (
`#${jobId}`
+ (author ? ` | ${author}` : '')
+ (narrator ? ` | ${narrator}` : '')
+ (chapterCount > 0 ? ` | ${chapterCount} Kapitel` : '')
+ (year ? ` | ${year}` : '')
);
}
function buildAudiobookPipeline(job, progress = null) { function buildAudiobookPipeline(job, progress = null) {
const encodePlan = parseEncodePlan(job); const encodePlan = parseEncodePlan(job);
const selectedMetadata = resolveAudiobookMetadata(job, encodePlan); const selectedMetadata = resolveAudiobookMetadata(job, encodePlan);
@@ -93,7 +145,13 @@ function buildAudiobookPipeline(job, progress = null) {
format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3', format: String(encodePlan?.format || job?.handbrakeInfo?.format || 'mp3').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object' formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions ? encodePlan.formatOptions
: {} : {},
preEncodeScriptIds: Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : [],
postEncodeScriptIds: Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : [],
preEncodeChainIds: Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [],
postEncodeChainIds: Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [],
preEncodeItems: Array.isArray(encodePlan?.preEncodeItems) ? encodePlan.preEncodeItems : [],
postEncodeItems: Array.isArray(encodePlan?.postEncodeItems) ? encodePlan.postEncodeItems : []
}, },
mediaInfoReview: reviewData, mediaInfoReview: reviewData,
outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null, outputPath: String(job?.output_path || encodePlan?.outputPath || '').trim() || null,
@@ -109,16 +167,15 @@ function buildAudiobookPipeline(job, progress = null) {
export default function AudiobooksPage({ export default function AudiobooksPage({
audiobookUpload, audiobookUpload,
onAudiobookUpload onAudiobookUpload,
onCancelAudiobookUpload = null
}) { }) {
const toastRef = useRef(null); const toastRef = useRef(null);
const navigate = useNavigate();
const [jobs, setJobs] = useState([]); const [jobs, setJobs] = useState([]);
const [loadingJobs, setLoadingJobs] = useState(false); const [loadingJobs, setLoadingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined); const [expandedJobId, setExpandedJobId] = useState(undefined);
const [jobProgress, setJobProgress] = useState({}); const [jobProgress, setJobProgress] = useState({});
const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set()); const [actionBusyJobIds, setActionBusyJobIds] = useState(() => new Set());
const explorerRefreshToken = 0;
const setActionBusy = (jobId, busy) => { const setActionBusy = (jobId, busy) => {
const normalizedJobId = normalizeJobId(jobId); const normalizedJobId = normalizeJobId(jobId);
@@ -146,21 +203,32 @@ export default function AudiobooksPage({
.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); .sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
setJobs(audiobookRows); setJobs(audiobookRows);
setJobProgress((prev) => { setJobProgress((prev) => {
const next = { ...prev }; const next = {};
for (const job of audiobookRows) { for (const job of audiobookRows) {
const jobId = normalizeJobId(job?.id); const jobId = normalizeJobId(job?.id);
if (!jobId) { if (!jobId) {
continue; continue;
} }
const status = String(job?.status || '').trim().toUpperCase(); const status = String(job?.status || '').trim().toUpperCase();
if (!['ANALYZING', 'ENCODING'].includes(status)) { const isLiveStatus = ['ANALYZING', 'ENCODING'].includes(status);
delete next[jobId]; if (!isLiveStatus) {
continue;
}
const serverProgress = normalizeLiveProgressForJob(job);
if (serverProgress) {
next[jobId] = serverProgress;
continue;
}
if (prev?.[jobId] && typeof prev[jobId] === 'object') {
next[jobId] = prev[jobId];
} }
} }
return next; return next;
}); });
return audiobookRows;
} catch (error) { } catch (error) {
console.error('AudiobooksPage load jobs error:', error); console.error('AudiobooksPage load jobs error:', error);
return [];
} finally { } finally {
setLoadingJobs(false); setLoadingJobs(false);
} }
@@ -183,17 +251,31 @@ export default function AudiobooksPage({
if (!jobId) { if (!jobId) {
return; return;
} }
setJobProgress((prev) => ({ setJobProgress((prev) => {
...prev, const previousEntry = prev?.[jobId] && typeof prev[jobId] === 'object' ? prev[jobId] : {};
[jobId]: { const contextPatch = payload?.contextPatch && typeof payload.contextPatch === 'object'
progress: payload?.progress ?? null, ? payload.contextPatch
eta: payload?.eta ?? null, : null;
statusText: payload?.statusText ?? null, const nextCurrentChapter = hasOwn(contextPatch, 'currentChapter')
state: payload?.state ?? null, ? (contextPatch?.currentChapter ?? null)
currentChapter: payload?.contextPatch?.currentChapter ?? null, : (previousEntry?.currentChapter ?? null);
completedChapterCount: payload?.contextPatch?.completedChapterCount ?? null const nextCompletedChapterCount = hasOwn(contextPatch, 'completedChapterCount')
} ? (Number.isFinite(Number(contextPatch?.completedChapterCount))
})); ? Number(contextPatch.completedChapterCount)
: null)
: (previousEntry?.completedChapterCount ?? null);
return {
...prev,
[jobId]: {
progress: payload?.progress ?? previousEntry?.progress ?? null,
eta: payload?.eta ?? previousEntry?.eta ?? null,
statusText: payload?.statusText ?? previousEntry?.statusText ?? null,
state: payload?.state ?? previousEntry?.state ?? null,
currentChapter: nextCurrentChapter,
completedChapterCount: nextCompletedChapterCount
}
};
});
} }
if ( if (
message.type === 'PIPELINE_UPDATE' message.type === 'PIPELINE_UPDATE'
@@ -262,7 +344,7 @@ export default function AudiobooksPage({
} }
setActionBusy(normalizedJobId, true); setActionBusy(normalizedJobId, true);
try { try {
await api.cancelJob(normalizedJobId); await api.cancelPipeline(normalizedJobId);
toastRef.current?.show({ toastRef.current?.show({
severity: 'info', severity: 'info',
summary: 'Abbruch angefordert', summary: 'Abbruch angefordert',
@@ -282,39 +364,6 @@ export default function AudiobooksPage({
} }
}; };
const handleRetry = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setActionBusy(normalizedJobId, true);
try {
const response = await api.retryJob(normalizedJobId);
const retryJobId = normalizeJobId(response?.retryJob?.id);
toastRef.current?.show({
severity: 'success',
summary: 'Retry erstellt',
detail: retryJobId
? `Neuer Job #${retryJobId} wurde angelegt.`
: `Retry fuer Job #${normalizedJobId} wurde angelegt.`,
life: 3200
});
await loadJobs();
if (retryJobId) {
setExpandedJobId(retryJobId);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Retry fehlgeschlagen',
detail: error?.message || 'Bitte Logs pruefen.',
life: 4200
});
} finally {
setActionBusy(normalizedJobId, false);
}
};
const handleDelete = async (jobId) => { const handleDelete = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId); const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) { if (!normalizedJobId) {
@@ -357,11 +406,14 @@ export default function AudiobooksPage({
} }
}; };
const handleUploaded = async (uploadedJobId) => { const handleUploaded = async (uploadedJobId, response = null) => {
const normalizedJobId = normalizeJobId(uploadedJobId); const normalizedJobId = normalizeJobId(uploadedJobId)
await loadJobs(); || extractUploadJobIdFromResponse(response);
if (normalizedJobId) { const rows = await loadJobs();
setExpandedJobId(normalizedJobId); const fallbackJobId = normalizeJobId(rows?.[0]?.id);
const targetJobId = normalizedJobId || fallbackJobId;
if (targetJobId) {
setExpandedJobId(targetJobId);
} }
}; };
@@ -393,6 +445,7 @@ export default function AudiobooksPage({
<AudiobookUploadPanel <AudiobookUploadPanel
audiobookUpload={audiobookUpload} audiobookUpload={audiobookUpload}
onAudiobookUpload={onAudiobookUpload} onAudiobookUpload={onAudiobookUpload}
onCancelUpload={onCancelAudiobookUpload}
onUploaded={handleUploaded} onUploaded={handleUploaded}
/> />
</Card> </Card>
@@ -417,7 +470,17 @@ export default function AudiobooksPage({
const progressValue = Number.isFinite(Number(pipelineForJob?.progress)) const progressValue = Number.isFinite(Number(pipelineForJob?.progress))
? Math.max(0, Math.min(100, Number(pipelineForJob.progress))) ? Math.max(0, Math.min(100, Number(pipelineForJob.progress)))
: 0; : 0;
const showProgress = ['ANALYZING', 'ENCODING'].includes(state) && progressValue > 0; const isRunning = ['ANALYZING', 'ENCODING'].includes(state);
const showProgress = isRunning || progressValue > 0;
const progressLabel = `${Math.trunc(progressValue)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const currentChapter = pipelineForJob?.context?.currentChapter && typeof pipelineForJob.context.currentChapter === 'object'
? pipelineForJob.context.currentChapter
: null;
const chapterLabel = currentChapter?.index && currentChapter?.total
? ` | Kapitel ${currentChapter.index}/${currentChapter.total}${currentChapter.title ? `: ${currentChapter.title}` : ''}`
: '';
const metaLine = buildAudiobookJobMetaLine(jobId, pipelineForJob?.context?.selectedMetadata, job?.year);
if (!isExpanded) { if (!isExpanded) {
return ( return (
@@ -427,25 +490,30 @@ export default function AudiobooksPage({
className="ripper-job-row" className="ripper-job-row"
onClick={() => setExpandedJobId(jobId)} onClick={() => setExpandedJobId(jobId)}
> >
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div> {(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-row-content"> <div className="ripper-job-row-content">
<div className="ripper-job-row-main"> <div className="ripper-job-row-main">
<strong className="ripper-job-title-line"> <strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} /> <i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span> <span>{title}</span>
</strong> </strong>
<small>#{jobId}</small> <small>{metaLine}</small>
</div> </div>
<div className="ripper-job-badges"> <div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} /> <Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div> </div>
{showProgress ? ( {showProgress ? (
<div className="ripper-job-row-progress"> <div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} /> <ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div> </div>
) : null} ) : null}
</div> </div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button> </button>
); );
} }
@@ -453,25 +521,22 @@ export default function AudiobooksPage({
return ( return (
<div key={jobId} className="ripper-job-expanded"> <div key={jobId} className="ripper-job-expanded">
<div className="ripper-job-expanded-head"> <div className="ripper-job-expanded-head">
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div> {(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Cover</div>
)}
<div className="ripper-job-expanded-title"> <div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line"> <strong className="ripper-job-title-line">
<i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} /> <i className="pi pi-headphones media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span> <span>{title}</span>
</strong> </strong>
<small className="ripper-job-subtitle">{metaLine}</small>
<div className="ripper-job-badges"> <div className="ripper-job-badges">
<Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} /> <Tag value={getStatusLabel(state)} severity={getStatusSeverity(normalizeStatus(state))} />
<Tag value="Audiobook" severity="info" />
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
<Button
label="Im Ripper"
icon="pi pi-arrow-right"
severity="secondary"
outlined
onClick={() => navigate('/ripper')}
/>
<Button <Button
label="Einklappen" label="Einklappen"
icon="pi pi-angle-up" icon="pi pi-angle-up"
@@ -482,11 +547,16 @@ export default function AudiobooksPage({
/> />
</div> </div>
</div> </div>
{showProgress ? (
<div className="ripper-job-row-progress">
<ProgressBar value={progressValue} showValue={false} />
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : `${progressLabel}${chapterLabel}`}</small>
</div>
) : null}
<AudiobookConfigPanel <AudiobookConfigPanel
pipeline={pipelineForJob} pipeline={pipelineForJob}
onStart={(config) => void handleAudiobookStart(jobId, config)} onStart={(config) => void handleAudiobookStart(jobId, config)}
onCancel={() => void handleCancel(jobId)} onCancel={() => void handleCancel(jobId)}
onRetry={() => void handleRetry(jobId)}
onDeleteJob={() => void handleDelete(jobId)} onDeleteJob={() => void handleDelete(jobId)}
busy={busy} busy={busy}
/> />
@@ -496,13 +566,6 @@ export default function AudiobooksPage({
</div> </div>
)} )}
</Card> </Card>
<Card
title="Audiobook Output Explorer"
subTitle="Read-only Explorer auf dem Audiobook-Ausgabeordner"
>
<AudiobookOutputExplorer refreshToken={explorerRefreshToken} />
</Card>
</div> </div>
); );
} }
+7 -7
View File
@@ -507,7 +507,7 @@ export default function ConverterPage() {
imdbId: row?.imdb_id || metadata?.imdbId || null, imdbId: row?.imdb_id || metadata?.imdbId || null,
poster: row?.poster_url || metadata?.poster || null poster: row?.poster_url || metadata?.poster || null
}, },
omdbCandidates: [] metadataCandidates: []
}); });
setMetadataDialogVisible(true); setMetadataDialogVisible(true);
}; };
@@ -537,9 +537,9 @@ export default function ConverterPage() {
} }
}; };
const handleOmdbSearch = async (query) => { const handleMetadataSearch = async (query) => {
try { try {
const response = await api.searchOmdb(query); const response = await api.searchTmdbMovie(query);
return Array.isArray(response?.results) ? response.results : []; return Array.isArray(response?.results) ? response.results : [];
} catch (_error) { } catch (_error) {
return []; return [];
@@ -978,7 +978,7 @@ export default function ConverterPage() {
onExpand={() => setExpandedJobId(Number(job.id))} onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)} onCollapse={() => setExpandedJobId(null)}
onOpenMetadata={handleOpenMetadataDialog} onOpenMetadata={handleOpenMetadataDialog}
onReassignOmdb={handleOpenMetadataDialog} onReassignMetadata={handleOpenMetadataDialog}
onStart={handleStartPipelineJob} onStart={handleStartPipelineJob}
onConfirmReview={handleConfirmReview} onConfirmReview={handleConfirmReview}
onSelectPlaylist={handleSelectPlaylist} onSelectPlaylist={handleSelectPlaylist}
@@ -1027,7 +1027,7 @@ export default function ConverterPage() {
setMetadataDialogContext(null); setMetadataDialogContext(null);
}} }}
onSubmit={handleMetadataSubmit} onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch} onSearch={handleMetadataSearch}
busy={metadataDialogBusy} busy={metadataDialogBusy}
/> />
@@ -1196,7 +1196,7 @@ function ConverterVideoJobCard({
onExpand, onExpand,
onCollapse, onCollapse,
onOpenMetadata, onOpenMetadata,
onReassignOmdb, onReassignMetadata,
onStart, onStart,
onConfirmReview, onConfirmReview,
onSelectPlaylist, onSelectPlaylist,
@@ -1282,7 +1282,7 @@ function ConverterVideoJobCard({
jobRow={job} jobRow={job}
pipeline={pipeline} pipeline={pipeline}
onOpenMetadata={onOpenMetadata} onOpenMetadata={onOpenMetadata}
onReassignOmdb={onReassignOmdb} onReassignMetadata={onReassignMetadata}
onStart={onStart} onStart={onStart}
onConfirmReview={onConfirmReview} onConfirmReview={onConfirmReview}
onSelectPlaylist={onSelectPlaylist} onSelectPlaylist={onSelectPlaylist}
+67 -10
View File
@@ -31,6 +31,7 @@ export default function DatabasePage() {
const [orphanRows, setOrphanRows] = useState([]); const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false); const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null); const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
const [orphanDeleteBusyPath, setOrphanDeleteBusyPath] = useState(null);
const toastRef = useRef(null); const toastRef = useRef(null);
const orphanLoadRequestRef = useRef(0); const orphanLoadRequestRef = useRef(0);
@@ -130,6 +131,50 @@ export default function DatabasePage() {
setOrphanImportBusyPath(null); setOrphanImportBusyPath(null);
} }
}; };
const handleDeleteOrphanRaw = async (row) => {
const target = String(row?.rawPath || row?.folderName || '-').trim() || '-';
const confirmed = await confirmModal({
header: 'RAW löschen',
message:
`RAW-Ordner "${target}" wirklich löschen?\n` +
'Der Ordner wird dauerhaft aus dem Dateisystem entfernt und verschwindet aus der /database-Liste.',
acceptLabel: 'RAW löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setOrphanDeleteBusyPath(row.rawPath);
try {
const response = await api.deleteOrphanRawFolder(row.rawPath);
const deletedRawPath = String(response?.rawPath || row?.rawPath || '').trim();
const deletedFiles = Number(response?.filesDeleted || 0);
const deletedDirs = Number(response?.dirsRemoved || 0);
if (deletedRawPath) {
setOrphanRows((previousRows) => (
(Array.isArray(previousRows) ? previousRows : []).filter((entry) => String(entry?.rawPath || '').trim() !== deletedRawPath)
));
}
toastRef.current?.show({
severity: 'success',
summary: 'RAW gelöscht',
detail: `Ordner entfernt | Dateien: ${deletedFiles}, Ordner: ${deletedDirs}`,
life: 3500
});
await loadOrphans({ silent: true });
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setOrphanDeleteBusyPath(null);
}
};
const orphanTitleBody = (row) => ( const orphanTitleBody = (row) => (
<div> <div>
<div><strong>{row.title || '-'}</strong></div> <div><strong>{row.title || '-'}</strong></div>
@@ -145,14 +190,26 @@ export default function DatabasePage() {
</div> </div>
); );
const orphanActionBody = (row) => ( const orphanActionBody = (row) => (
<Button <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
label="Job anlegen" <Button
icon="pi pi-plus" label="Job anlegen"
size="small" icon="pi pi-plus"
onClick={() => handleImportOrphanRaw(row)} size="small"
loading={orphanImportBusyPath === row.rawPath} onClick={() => handleImportOrphanRaw(row)}
disabled={Boolean(orphanImportBusyPath)} loading={orphanImportBusyPath === row.rawPath}
/> disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
<Button
label="RAW löschen"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
onClick={() => handleDeleteOrphanRaw(row)}
loading={orphanDeleteBusyPath === row.rawPath}
disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/>
</div>
); );
return ( return (
@@ -169,7 +226,7 @@ export default function DatabasePage() {
icon="pi pi-search" icon="pi pi-search"
onClick={loadOrphans} onClick={loadOrphans}
loading={orphanLoading} loading={orphanLoading}
disabled={Boolean(orphanImportBusyPath)} disabled={Boolean(orphanImportBusyPath) || Boolean(orphanDeleteBusyPath)}
/> />
<Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} /> <Tag value={`${orphanRows.length} gefunden`} severity={orphanRows.length > 0 ? 'warning' : 'success'} />
</div> </div>
@@ -190,7 +247,7 @@ export default function DatabasePage() {
<Column field="entryCount" header="Dateien" style={{ width: '8rem' }} /> <Column field="entryCount" header="Dateien" style={{ width: '8rem' }} />
<Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} /> <Column header="Pfad" body={orphanPathBody} style={{ minWidth: '22rem' }} />
<Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} /> <Column field="lastModifiedAt" header="Geändert" style={{ width: '16rem' }} />
<Column header="Aktion" body={orphanActionBody} style={{ width: '10rem' }} /> <Column header="Aktion" body={orphanActionBody} style={{ minWidth: '18rem' }} />
</DataTable> </DataTable>
</div> </div>
</Card> </Card>
+208 -184
View File
@@ -9,6 +9,7 @@ import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog'; import { Dialog } from 'primereact/dialog';
import { api } from '../api/client'; import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import JobDetailDialog from '../components/JobDetailDialog'; import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog'; import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog'; import CdMetadataDialog from '../components/CdMetadataDialog';
@@ -459,33 +460,63 @@ function sanitizeRating(value) {
return raw; return raw;
} }
function findOmdbRatingBySource(omdbInfo, sourceName) { function normalizeRatingValue(value) {
const ratings = Array.isArray(omdbInfo?.Ratings) ? omdbInfo.Ratings : []; if (value === null || value === undefined || value === '') {
const source = String(sourceName || '').trim().toLowerCase(); return null;
const entry = ratings.find((item) => String(item?.Source || '').trim().toLowerCase() === source); }
return sanitizeRating(entry?.Value); const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue.toFixed(1);
}
return sanitizeRating(value);
} }
function resolveRatings(row) { function resolveRatings(row) {
const omdbInfo = row?.omdbInfo && typeof row.omdbInfo === 'object' ? row.omdbInfo : null; const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
if (!omdbInfo) { const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const planMetadata = encodePlan?.metadata && typeof encodePlan.metadata === 'object'
? encodePlan.metadata
: {};
const selectedTmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: null;
const planTmdbDetails = planMetadata?.tmdbDetails && typeof planMetadata.tmdbDetails === 'object'
? planMetadata.tmdbDetails
: null;
const rowTmdbDetails = row?.tmdbDetails && typeof row.tmdbDetails === 'object'
? row.tmdbDetails
: null;
const tmdbDetails = selectedTmdbDetails || planTmdbDetails || rowTmdbDetails;
const tmdbRating = normalizeRatingValue(
tmdbDetails?.seasonVoteAverage
?? tmdbDetails?.voteAverage
?? tmdbDetails?.imdbRating
?? selectedMetadata?.seasonVoteAverage
?? selectedMetadata?.voteAverage
?? selectedMetadata?.imdbRating
?? planMetadata?.seasonVoteAverage
?? planMetadata?.voteAverage
?? planMetadata?.imdbRating
?? row?.voteAverage
?? row?.imdbRating
);
if (!tmdbRating) {
return []; return [];
} }
const imdb = sanitizeRating(omdbInfo?.imdbRating)
|| findOmdbRatingBySource(omdbInfo, 'Internet Movie Database');
const rotten = findOmdbRatingBySource(omdbInfo, 'Rotten Tomatoes');
const metascore = sanitizeRating(omdbInfo?.Metascore);
const ratings = []; const ratings = [];
if (imdb) { if (tmdbRating) {
ratings.push({ key: 'imdb', label: 'IMDb', value: imdb }); ratings.push({ key: 'tmdb', label: 'TMDb', value: tmdbRating });
}
if (rotten) {
ratings.push({ key: 'rt', label: 'RT', value: rotten });
}
if (metascore) {
ratings.push({ key: 'meta', label: 'Meta', value: metascore });
} }
return ratings; return ratings;
} }
@@ -553,7 +584,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null); const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null); const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false); const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false); const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null); const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false); const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
@@ -568,6 +599,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete' const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
const [conflictModalBusy, setConflictModalBusy] = useState(false); const [conflictModalBusy, setConflictModalBusy] = useState(false);
const toastRef = useRef(null); const toastRef = useRef(null);
const wsReloadTimerRef = useRef(null);
const progressStateByJobRef = useRef(new Map());
const queuedJobIdSet = useMemo(() => { const queuedJobIdSet = useMemo(() => {
const next = new Set(); const next = new Set();
@@ -638,6 +671,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
} }
}; };
const scheduleLiveReload = (delayMs = 120) => {
if (wsReloadTimerRef.current) {
return;
}
wsReloadTimerRef.current = setTimeout(() => {
wsReloadTimerRef.current = null;
void load();
}, delayMs);
};
useEffect(() => { useEffect(() => {
api.getPref('history_layout').then((res) => { api.getPref('history_layout').then((res) => {
if (res?.value === 'list' || res?.value === 'grid') { if (res?.value === 'list' || res?.value === 'grid') {
@@ -654,6 +697,55 @@ export default function HistoryPage({ refreshToken = 0 }) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [search, status, refreshToken]); }, [search, status, refreshToken]);
useEffect(() => () => {
if (wsReloadTimerRef.current) {
clearTimeout(wsReloadTimerRef.current);
wsReloadTimerRef.current = null;
}
}, []);
useWebSocket({
onMessage: (message) => {
const type = String(message?.type || '').trim().toUpperCase();
if (!type) {
return;
}
if (
type === 'PIPELINE_STATE_CHANGED'
|| type === 'PIPELINE_QUEUE_CHANGED'
|| type === 'DISC_DETECTED'
|| type === 'DISC_REMOVED'
) {
scheduleLiveReload(80);
return;
}
if (type !== 'PIPELINE_PROGRESS') {
return;
}
const payload = message?.payload && typeof message.payload === 'object'
? message.payload
: {};
const normalizedJobId = normalizeJobId(payload?.activeJobId);
if (!normalizedJobId) {
return;
}
const nextState = String(payload?.state || '').trim().toUpperCase();
if (!nextState) {
return;
}
const key = String(normalizedJobId);
const prevState = progressStateByJobRef.current.get(key) || '';
if (nextState === prevState) {
return;
}
progressStateByJobRef.current.set(key, nextState);
scheduleLiveReload(120);
}
});
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const openJobId = Number(params.get('open') || 0); const openJobId = Number(params.get('open') || 0);
@@ -869,11 +961,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
} }
} else if (action === 'restart_review') { } else if (action === 'restart_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`; const title = job?.title || job?.detected_title || `Job #${job?.id}`;
const isAudiobookJob = resolveMediaType(job) === 'audiobook';
if (!skipConfirm) { if (!skipConfirm) {
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'Review neu starten', header: isAudiobookJob ? 'Vorprüfung starten' : 'Review neu starten',
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`, message: isAudiobookJob
acceptLabel: 'Review starten', ? `Vorprüfung für "${title}" starten?\nDer Job wird neu angelegt und kann danach im Ripper vor dem Encode angepasst werden.`
: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Ripper neu getroffen werden.`,
acceptLabel: isAudiobookJob ? 'Vorprüfung starten' : 'Review starten',
rejectLabel: 'Abbrechen' rejectLabel: 'Abbrechen'
}); });
if (!confirmed) { if (!confirmed) {
@@ -887,14 +982,21 @@ export default function HistoryPage({ refreshToken = 0 }) {
const replacementJobId = normalizeJobId(result?.jobId); const replacementJobId = normalizeJobId(result?.jobId);
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'Review-Neustart', summary: isAudiobookJob ? 'Vorprüfung gestartet' : 'Review-Neustart',
detail: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.', detail: isAudiobookJob
? 'Job neu angelegt. Im Ripper können die Encode-Einstellungen angepasst werden.'
: 'Analyse gestartet. Job ist jetzt im Ripper verfügbar.',
life: 3500 life: 3500
}); });
await load(); await load();
await refreshDetailAfterReplacement(job.id, replacementJobId); await refreshDetailAfterReplacement(job.id, replacementJobId);
} catch (error) { } catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 }); toastRef.current?.show({
severity: 'error',
summary: isAudiobookJob ? 'Vorprüfung fehlgeschlagen' : 'Review-Neustart fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally { } finally {
setActionBusy(false); setActionBusy(false);
} }
@@ -903,7 +1005,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!skipConfirm) { if (!skipConfirm) {
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'CD-Vorprüfung starten', header: 'CD-Vorprüfung starten',
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`, message: `CD-Vorprüfung für "${title}" starten?\nTrackauswahl und Ausgabeeinstellungen werden im Ripper geöffnet.`,
acceptLabel: 'Vorprüfung starten', acceptLabel: 'Vorprüfung starten',
rejectLabel: 'Abbrechen' rejectLabel: 'Abbrechen'
}); });
@@ -917,7 +1019,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'CD-Vorprüfung gestartet', summary: 'CD-Vorprüfung gestartet',
detail: 'Job ist jetzt im Ripper verfügbar — bitte Metadaten und Einstellungen wählen.', detail: 'Job ist jetzt im Ripper verfügbar — bitte Tracks und Einstellungen prüfen.',
life: 4000 life: 4000
}); });
await load(); await load();
@@ -1129,8 +1231,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
} }
}; };
const handleDownloadOutputFolder = async (row, folderPath) => { const handleDownloadOutputFolder = async (row, folderPath, ownerJobId = null) => {
const jobId = Number(row?.id || selectedJob?.id || 0); const jobId = Number(ownerJobId || row?.id || selectedJob?.id || 0);
if (!jobId || !folderPath) return; if (!jobId || !folderPath) return;
setDownloadFolderBusyPath(folderPath); setDownloadFolderBusyPath(folderPath);
try { try {
@@ -1269,7 +1371,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
} }
}; };
const handleAssignOmdb = (row) => { const handleAssignMetadata = (row) => {
const jobId = Number(row?.id || 0); const jobId = Number(row?.id || 0);
if (!jobId) return; if (!jobId) return;
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {}; const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
@@ -1291,11 +1393,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null); : (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row); const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row); const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = workflowKind === 'series' const metadataProvider = 'tmdb';
? 'tmdb'
: (workflowKind === 'film'
? 'omdb'
: (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb'));
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null; const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null; const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = { const context = {
@@ -1326,47 +1424,63 @@ export default function HistoryPage({ refreshToken = 0 }) {
...(discNumber ? { discNumber } : {}) ...(discNumber ? { discNumber } : {})
}, },
metadataProvider, metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [], metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null, seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null, seriesLookupHint: analyzeContext?.seriesLookupHint || null,
omdbCandidates: [] seriesDecision: analyzeContext?.seriesDecision || null
}; };
setMetadataDialogContext(context); setMetadataDialogContext(context);
setMetadataDialogVisible(true); setMetadataDialogVisible(true);
}; };
const handleOmdbSearch = async (query, options = {}) => { const handleMetadataSearch = async (query, options = {}) => {
const provider = String( const workflow = String(
options?.metadataProvider options?.workflowKind
|| metadataDialogContext?.metadataProvider || metadataDialogContext?.selectedMetadata?.workflowKind
|| 'omdb' || ''
).trim().toLowerCase() || 'omdb'; ).trim().toLowerCase();
try { try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber ?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null; ?? null;
const response = provider === 'tmdb' const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint) ? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchOmdb(query); : await api.searchTmdbMovie(query);
return response.results || []; return response.results || [];
} catch (error) { } catch (error) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'error', severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen', summary: 'TMDb-Suche fehlgeschlagen',
detail: error.message detail: error.message
}); });
return []; return [];
} }
}; };
const handleOmdbSubmit = async (payload) => { const handleMetadataSubmit = async (payload) => {
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign'; const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
const isPipelineFlow = submitMode === 'pipeline'; const isPipelineFlow = submitMode === 'pipeline';
setOmdbAssignBusy(true); setMetadataAssignBusy(true);
try { try {
if (isPipelineFlow) { if (isPipelineFlow) {
await api.selectMetadata(payload); const selectResponse = await api.selectMetadata(payload);
const selectedJobUpdate = selectResponse?.job && typeof selectResponse.job === 'object'
? selectResponse.job
: null;
if (selectedJobUpdate?.id) {
const normalizedSelectedId = Number(selectedJobUpdate.id);
setJobs((prev) => (
Array.isArray(prev)
? prev.map((row) => (Number(row?.id || 0) === normalizedSelectedId ? { ...row, ...selectedJobUpdate } : row))
: prev
));
setSelectedJob((prev) => (
Number(prev?.id || 0) === normalizedSelectedId
? { ...(prev || {}), ...selectedJobUpdate }
: prev
));
}
setMetadataDialogVisible(false); setMetadataDialogVisible(false);
setMetadataDialogContext(null); setMetadataDialogContext(null);
@@ -1408,11 +1522,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
return; return;
} }
await api.assignJobOmdb(payload.jobId, payload); const assignResponse = await api.assignJobMetadata(payload.jobId, payload);
const assignedJobUpdate = assignResponse?.job && typeof assignResponse.job === 'object'
? assignResponse.job
: null;
if (assignedJobUpdate?.id) {
const normalizedAssignedId = Number(assignedJobUpdate.id);
setJobs((prev) => (
Array.isArray(prev)
? prev.map((row) => (Number(row?.id || 0) === normalizedAssignedId ? { ...row, ...assignedJobUpdate } : row))
: prev
));
setSelectedJob((prev) => (
Number(prev?.id || 0) === normalizedAssignedId
? { ...(prev || {}), ...assignedJobUpdate }
: prev
));
}
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'Metadaten zugewiesen', summary: 'Metadaten zugewiesen',
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.', detail: 'TMDb-Metadaten wurden aktualisiert.',
life: 3000 life: 3000
}); });
setMetadataDialogVisible(false); setMetadataDialogVisible(false);
@@ -1425,11 +1555,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
} }
toastRef.current?.show({ toastRef.current?.show({
severity: 'error', severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen', summary: 'TMDb-Zuweisung fehlgeschlagen',
detail: error.message detail: error.message
}); });
} finally { } finally {
setOmdbAssignBusy(false); setMetadataAssignBusy(false);
} }
}; };
@@ -1451,9 +1581,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
setCdMetadataDialogVisible(true); setCdMetadataDialogVisible(true);
}; };
const handleMusicBrainzSearch = async (query) => { const handleMusicBrainzSearch = async (query, options = {}) => {
try { try {
const response = await api.searchMusicBrainz(query); const response = await api.searchMusicBrainz(query, {
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
});
return response.results || []; return response.results || [];
} catch (error) { } catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message }); toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz-Suche fehlgeschlagen', detail: error.message });
@@ -1594,71 +1726,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
} }
}; };
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
setDeleteEntryPreviewLoading(true);
try {
const response = await api.getJobDeletePreview(jobId, {
includeRelated: deleteEntryIncludeRelated,
selectedJobIds
});
const preview = response?.preview || null;
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
: relatedJobs
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
setDeleteEntryPreview(preview);
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
const defaultSelectedRawPaths = rawCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
const defaultSelectedMoviePaths = movieCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschvorschau fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setDeleteEntryPreviewLoading(false);
}
};
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
if (!normalizedRelatedJobId || !dialogJobId) {
return;
}
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const knownJobIds = relatedJobs
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
const currentSelected = new Set(
deleteEntrySelectedJobIds
.map((item) => normalizeJobId(item))
.filter(Boolean)
);
if (checked) {
currentSelected.add(normalizedRelatedJobId);
} else {
currentSelected.delete(normalizedRelatedJobId);
}
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
setDeleteEntrySelectedJobIds(orderedSelected);
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
};
const confirmDeleteEntry = async (target) => { const confirmDeleteEntry = async (target) => {
const normalizedTarget = String(target || '').trim().toLowerCase(); const normalizedTarget = String(target || '').trim().toLowerCase();
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) { if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
@@ -1672,16 +1739,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) { if (!jobId) {
return; return;
} }
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Historie-Auswahl erforderlich',
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
life: 3500
});
return;
}
if ( if (
(normalizedTarget === 'raw' || normalizedTarget === 'both') (normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled && rawDeleteSelectionEnabled
@@ -1761,16 +1818,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) { if (!jobId) {
return; return;
} }
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Historie-Auswahl erforderlich',
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
life: 3500
});
return;
}
if ( if (
(normalizedTarget === 'raw' || normalizedTarget === 'both') (normalizedTarget === 'raw' || normalizedTarget === 'both')
&& rawDeleteSelectionEnabled && rawDeleteSelectionEnabled
@@ -2455,12 +2502,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
}; };
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : []; const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
: previewRelatedJobs
.filter((item) => Boolean(item?.selected))
.map((item) => normalizeJobId(item?.id))
.filter(Boolean);
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : []; const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : []; const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow); const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
@@ -2474,17 +2515,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)), () => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedRawPaths] [deleteEntrySelectedRawPaths]
); );
const selectedDeleteRelatedJobSet = useMemo(
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
[deleteEntrySelectedJobIds]
);
const selectedDeleteMoviePathSet = useMemo( const selectedDeleteMoviePathSet = useMemo(
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)), () => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedMoviePaths] [deleteEntrySelectedMoviePaths]
); );
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
&& previewRelatedJobs.length > 0
&& deleteEntrySelectedJobIds.length === 0;
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
&& rawDeleteSelectionEnabled && rawDeleteSelectionEnabled
&& deleteEntrySelectedRawPaths.length === 0; && deleteEntrySelectedRawPaths.length === 0;
@@ -2571,7 +2605,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview} onRestartCdReview={resolveMediaType(selectedJob) === 'converter' ? null : handleRestartCdReview}
onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode} onReencode={resolveMediaType(selectedJob) === 'converter' ? null : handleReencode}
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry} onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb} onAssignMetadata={handleAssignMetadata}
onAssignCdMetadata={handleAssignCdMetadata} onAssignCdMetadata={handleAssignCdMetadata}
onGenerateNfo={handleGenerateNfo} onGenerateNfo={handleGenerateNfo}
onAcknowledgeError={handleAcknowledgeError} onAcknowledgeError={handleAcknowledgeError}
@@ -2586,7 +2620,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
actionBusy={actionBusy} actionBusy={actionBusy}
cancelBusy={actionBusy} cancelBusy={actionBusy}
restoreMergeBusy={actionBusy} restoreMergeBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy} metadataAssignBusy={metadataAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy} cdMetadataAssignBusy={cdMetadataAssignBusy}
generateNfoBusy={generateNfoBusy} generateNfoBusy={generateNfoBusy}
acknowledgeErrorBusy={acknowledgeErrorBusy} acknowledgeErrorBusy={acknowledgeErrorBusy}
@@ -2612,7 +2646,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
modal modal
> >
<p> <p>
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`} {`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge im Lösch-Scope enthalten.`}
</p> </p>
<small className="history-dv-subtle"> <small className="history-dv-subtle">
{deleteEntryIsMergeJob {deleteEntryIsMergeJob
@@ -2636,29 +2670,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
<ul className="history-delete-preview-list"> <ul className="history-delete-preview-list">
{previewRelatedJobs.map((item) => ( {previewRelatedJobs.map((item) => (
<li key={`delete-related-${item.id}`}> <li key={`delete-related-${item.id}`}>
<label className="history-delete-preview-checkbox-row"> <span>
<input <strong>#{item.id}</strong>
type="checkbox" {' '}| {item.title || '-'}
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))} {' '}| {item.roleLabel || 'Job'}
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))} {' '}| {getStatusLabel(item.status)}
/> {' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
<span> </span>
<strong>#{item.id}</strong>
{' '}| {item.title || '-'}
{' '}| {item.roleLabel || 'Job'}
{' '}| {getStatusLabel(item.status)}
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
</span>
</label>
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small> <small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
)} )}
{relatedDeleteSelectionRequired ? (
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
) : null}
</div> </div>
{!deleteEntryIsMergeJob ? ( {!deleteEntryIsMergeJob ? (
@@ -2751,7 +2775,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined outlined
onClick={() => confirmDeleteEntry('movie')} onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'} loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired} disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/> />
) : ( ) : (
<> <>
@@ -2762,7 +2786,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined outlined
onClick={() => confirmDeleteEntry('raw')} onClick={() => confirmDeleteEntry('raw')}
loading={deleteEntryTargetBusy === 'raw'} loading={deleteEntryTargetBusy === 'raw'}
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired} disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
/> />
<Button <Button
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`} label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
@@ -2771,7 +2795,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined outlined
onClick={() => confirmDeleteEntry('movie')} onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'} loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired} disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/> />
<Button <Button
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`} label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
@@ -2779,7 +2803,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
severity="danger" severity="danger"
onClick={() => confirmDeleteEntry('both')} onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'} loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired} disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/> />
</> </>
)} )}
@@ -2791,7 +2815,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined outlined
onClick={() => confirmDeleteFilesOnly('both')} onClick={() => confirmDeleteFilesOnly('both')}
loading={deleteEntryTargetBusy === 'files-both'} loading={deleteEntryTargetBusy === 'files-both'}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired} disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
/> />
) : null} ) : null}
<Button <Button
@@ -2801,7 +2825,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined outlined
onClick={() => confirmDeleteEntry('none')} onClick={() => confirmDeleteEntry('none')}
loading={deleteEntryTargetBusy === 'none'} loading={deleteEntryTargetBusy === 'none'}
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired} disabled={deleteTargetActionsDisabled}
/> />
<Button <Button
label="Abbrechen" label="Abbrechen"
@@ -2820,9 +2844,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
setMetadataDialogVisible(false); setMetadataDialogVisible(false);
setMetadataDialogContext(null); setMetadataDialogContext(null);
}} }}
onSubmit={handleOmdbSubmit} onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch} onSearch={handleMetadataSearch}
busy={omdbAssignBusy} busy={metadataAssignBusy}
/> />
<CdMetadataDialog <CdMetadataDialog
File diff suppressed because it is too large Load Diff
+134 -11
View File
@@ -78,6 +78,53 @@ function normalizeUserPresetDefaultsMap(raw = {}) {
return normalized; return normalized;
} }
function normalizeRunningChainIds(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const active = Array.isArray(payload.active) ? payload.active : [];
const ids = [];
const seen = new Set();
for (const item of active) {
const type = String(item?.type || '').trim().toLowerCase();
if (type !== 'chain') {
continue;
}
const chainIdRaw = Number(item?.chainId || 0);
if (!Number.isFinite(chainIdRaw) || chainIdRaw <= 0) {
continue;
}
const chainId = Math.trunc(chainIdRaw);
if (seen.has(chainId)) {
continue;
}
seen.add(chainId);
ids.push(chainId);
}
return ids;
}
function isLikelyNetworkRequestError(error) {
if (!error) {
return false;
}
const status = Number(error?.status);
if (Number.isFinite(status) && status > 0) {
return false;
}
const message = String(error?.message || '').trim().toLowerCase();
if (!message) {
return true;
}
return (
message.includes('failed to fetch')
|| message.includes('networkerror')
|| message.includes('network error')
|| message.includes('load failed')
|| message.includes('timeout')
|| message.includes('aborted')
|| message.includes('socket hang up')
);
}
function isPresetCompatibleWithSlot(preset, slotMediaType) { function isPresetCompatibleWithSlot(preset, slotMediaType) {
const mediaType = String(preset?.mediaType || '').trim().toLowerCase(); const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
if (mediaType === 'all') { if (mediaType === 'all') {
@@ -272,7 +319,8 @@ export default function SettingsPage() {
const [chainSaving, setChainSaving] = useState(false); const [chainSaving, setChainSaving] = useState(false);
const [chainReordering, setChainReordering] = useState(false); const [chainReordering, setChainReordering] = useState(false);
const [chainListDragSourceId, setChainListDragSourceId] = useState(null); const [chainListDragSourceId, setChainListDragSourceId] = useState(null);
const [chainActionBusyId, setChainActionBusyId] = useState(null); const [chainActionBusyIds, setChainActionBusyIds] = useState(() => new Set());
const [runningChainIds, setRunningChainIds] = useState([]);
const [lastChainTestResult, setLastChainTestResult] = useState(null); const [lastChainTestResult, setLastChainTestResult] = useState(null);
const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] }); const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] });
const [chainEditorErrors, setChainEditorErrors] = useState({}); const [chainEditorErrors, setChainEditorErrors] = useState({});
@@ -328,6 +376,27 @@ export default function SettingsPage() {
} }
return next; return next;
}, [userPresets, userPresetDefaultsDraft]); }, [userPresets, userPresetDefaultsDraft]);
const runningChainIdSet = useMemo(
() => new Set(Array.isArray(runningChainIds) ? runningChainIds : []),
[runningChainIds]
);
const setChainActionBusy = (chainId, busyFlag) => {
const normalizedId = Number(chainId);
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
return;
}
const targetId = Math.trunc(normalizedId);
setChainActionBusyIds((prev) => {
const next = new Set(prev);
if (busyFlag) {
next.add(targetId);
} else {
next.delete(targetId);
}
return next;
});
};
const loadScripts = async ({ silent = false } = {}) => { const loadScripts = async ({ silent = false } = {}) => {
if (!silent) { if (!silent) {
@@ -404,6 +473,15 @@ export default function SettingsPage() {
} }
}; };
const refreshRunningChains = async () => {
try {
const response = await api.getRuntimeActivities();
setRunningChainIds(normalizeRunningChainIds(response));
} catch (_error) {
// ignore polling errors
}
};
const openNewUserPreset = () => { const openNewUserPreset = () => {
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' }); setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
setUserPresetErrors({}); setUserPresetErrors({});
@@ -546,7 +624,8 @@ export default function SettingsPage() {
const presetsPromise = api.getHandBrakePresets(); const presetsPromise = api.getHandBrakePresets();
const scriptsPromise = api.getScripts(); const scriptsPromise = api.getScripts();
const chainsPromise = api.getScriptChains(); const chainsPromise = api.getScriptChains();
const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]); const runtimePromise = api.getRuntimeActivities();
const [scriptsResponse, chainsResponse, runtimeResponse] = await Promise.allSettled([scriptsPromise, chainsPromise, runtimePromise]);
if (scriptsResponse.status === 'fulfilled') { if (scriptsResponse.status === 'fulfilled') {
setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []); setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []);
} else { } else {
@@ -559,6 +638,9 @@ export default function SettingsPage() {
if (chainsResponse.status === 'fulfilled') { if (chainsResponse.status === 'fulfilled') {
setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []); setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []);
} }
if (runtimeResponse.status === 'fulfilled') {
setRunningChainIds(normalizeRunningChainIds(runtimeResponse.value));
}
presetsPromise presetsPromise
.then((presetPayload) => { .then((presetPayload) => {
@@ -593,6 +675,25 @@ export default function SettingsPage() {
loadUserPresetDefaults(); loadUserPresetDefaults();
}, []); }, []);
useEffect(() => {
let cancelled = false;
const pollRuntime = async () => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setRunningChainIds(normalizeRunningChainIds(response));
}
} catch (_error) {
// ignore polling errors
}
};
const timer = setInterval(pollRuntime, 2500);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
const dirtyKeys = useMemo(() => { const dirtyKeys = useMemo(() => {
const keys = new Set(); const keys = new Set();
const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]); const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]);
@@ -974,7 +1075,8 @@ export default function SettingsPage() {
if (!Number.isFinite(chainId) || chainId <= 0) { if (!Number.isFinite(chainId) || chainId <= 0) {
return; return;
} }
setChainActionBusyId(chainId); const normalizedChainId = Math.trunc(chainId);
setChainActionBusy(normalizedChainId, true);
setLastChainTestResult(null); setLastChainTestResult(null);
try { try {
const response = await api.testScriptChain(chainId); const response = await api.testScriptChain(chainId);
@@ -994,9 +1096,27 @@ export default function SettingsPage() {
}); });
} }
} catch (error) { } catch (error) {
if (isLikelyNetworkRequestError(error)) {
try {
const runtimeResponse = await api.getRuntimeActivities();
const activeIds = normalizeRunningChainIds(runtimeResponse);
setRunningChainIds(activeIds);
if (activeIds.includes(normalizedChainId)) {
toastRef.current?.show({
severity: 'warn',
summary: 'Ketten-Test läuft weiter',
detail: `"${chain?.name || chainId}" läuft im Hintergrund weiter. Der Test-Request wurde vorzeitig beendet (z.B. Timeout/Verbindungsabbruch).`
});
return;
}
} catch (_runtimeError) {
// ignore follow-up runtime fetch errors and show default error toast below
}
}
toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message }); toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message });
} finally { } finally {
setChainActionBusyId(null); setChainActionBusy(normalizedChainId, false);
refreshRunningChains();
} }
}; };
@@ -1121,7 +1241,7 @@ export default function SettingsPage() {
}; };
const handleChainListDragStart = (event, chainId) => { const handleChainListDragStart = (event, chainId) => {
if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) { if (chainSaving || chainsLoading || chainReordering) {
event.preventDefault(); event.preventDefault();
return; return;
} }
@@ -1221,8 +1341,7 @@ export default function SettingsPage() {
|| Boolean(scriptActionBusyId); || Boolean(scriptActionBusyId);
const chainListDnDDisabled = chainSaving const chainListDnDDisabled = chainSaving
|| chainsLoading || chainsLoading
|| chainReordering || chainReordering;
|| Boolean(chainActionBusyId);
return ( return (
<div className="page-grid"> <div className="page-grid">
@@ -1555,6 +1674,10 @@ export default function SettingsPage() {
<div className="script-order-list"> <div className="script-order-list">
{chains.map((chain, index) => { {chains.map((chain, index) => {
const isDragging = Number(chainListDragSourceId) === Number(chain.id); const isDragging = Number(chainListDragSourceId) === Number(chain.id);
const chainId = Number(chain?.id);
const normalizedChainId = Number.isFinite(chainId) && chainId > 0 ? Math.trunc(chainId) : null;
const isChainRuntimeRunning = normalizedChainId !== null && runningChainIdSet.has(normalizedChainId);
const isChainActionBusy = normalizedChainId !== null && chainActionBusyIds.has(normalizedChainId);
return ( return (
<div key={chain.id} className="script-order-wrapper"> <div key={chain.id} className="script-order-wrapper">
<div <div
@@ -1596,15 +1719,15 @@ export default function SettingsPage() {
severity="secondary" severity="secondary"
outlined outlined
onClick={() => openChainEditor(chain)} onClick={() => openChainEditor(chain)}
disabled={chainReordering || Boolean(chainActionBusyId)} disabled={chainReordering}
/> />
<Button <Button
icon="pi pi-play" icon="pi pi-play"
label="Test" label="Test"
severity="info" severity="info"
onClick={() => handleTestChain(chain)} onClick={() => handleTestChain(chain)}
loading={chainActionBusyId === chain.id} loading={isChainActionBusy}
disabled={chainReordering || (Boolean(chainActionBusyId) && chainActionBusyId !== chain.id)} disabled={chainReordering || isChainActionBusy || isChainRuntimeRunning}
/> />
<Button <Button
icon="pi pi-trash" icon="pi pi-trash"
@@ -1612,7 +1735,7 @@ export default function SettingsPage() {
severity="danger" severity="danger"
outlined outlined
onClick={() => handleDeleteChain(chain)} onClick={() => handleDeleteChain(chain)}
disabled={chainReordering || Boolean(chainActionBusyId)} disabled={chainReordering}
/> />
</div> </div>
</div> </div>
+410
View File
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Toast } from 'primereact/toast';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { ProgressSpinner } from 'primereact/progressspinner';
import { api } from '../api/client';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import { isSeriesVideoJob } from '../utils/jobTaxonomy';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function resolveMediaType(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
const candidates = [
row?.mediaType,
row?.media_type,
row?.mediaProfile,
row?.media_profile,
encodePlan?.mediaProfile,
row?.makemkvInfo?.analyzeContext?.mediaProfile,
row?.makemkvInfo?.mediaProfile,
row?.mediainfoInfo?.mediaProfile
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim().toLowerCase();
if (!raw) {
continue;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (raw === 'converter') {
return 'converter';
}
}
return 'other';
}
function buildMetadataContextFromJob(row) {
const jobId = normalizeJobId(row?.id);
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const workflowKindRaw = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
? 'film'
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const rowMediaType = resolveMediaType(row);
const isSeriesDvd = (rowMediaType === 'dvd' || rowMediaType === 'bluray') && isSeriesVideoJob(row);
const metadataProvider = 'tmdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
return {
jobId,
status: row?.status || null,
lastState: row?.last_state || null,
submitMode: 'assign',
mediaProfile: rowMediaType,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
year: row?.year || selectedMetadata?.year || null,
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
poster: row?.poster_url || selectedMetadata?.poster || null,
metadataProvider,
workflowKind,
tmdbId: selectedMetadata?.tmdbId || null,
providerId: selectedMetadata?.providerId || null,
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
seasonNumber,
seasonName: selectedMetadata?.seasonName || null,
episodeCount: selectedMetadata?.episodeCount || 0,
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
...(discNumber ? { discNumber } : {})
},
metadataProvider,
workflowKind: workflowKind || analyzeContext?.workflowKind || null,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
seriesDecision: analyzeContext?.seriesDecision || null
};
}
export default function TmdbMigrationPage() {
const toastRef = useRef(null);
const countdownIntervalRef = useRef(null);
const nextJobTimeoutRef = useRef(null);
const jobsRef = useRef([]);
const runningRef = useRef(false);
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataAssignBusy, setMetadataAssignBusy] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
useEffect(() => {
jobsRef.current = Array.isArray(jobs) ? jobs : [];
}, [jobs]);
useEffect(() => {
runningRef.current = running;
}, [running]);
const clearTimers = useCallback(() => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
if (nextJobTimeoutRef.current) {
clearTimeout(nextJobTimeoutRef.current);
nextJobTimeoutRef.current = null;
}
}, []);
useEffect(() => () => {
clearTimers();
}, [clearTimers]);
const loadPendingJobs = useCallback(async () => {
setLoading(true);
try {
const response = await api.getTmdbMigrationPendingJobs({ limit: 1000 });
const rows = Array.isArray(response?.jobs) ? response.jobs : [];
setJobs(rows);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Laden fehlgeschlagen',
detail: error?.message || 'TMDb-Migrationsliste konnte nicht geladen werden.'
});
setJobs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadPendingJobs();
}, [loadPendingJobs]);
const openJobInDialog = useCallback((job) => {
if (!job) {
return;
}
const context = buildMetadataContextFromJob(job);
if (!context?.jobId) {
return;
}
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
}, []);
const scheduleNextJob = useCallback((nextJob, seconds = 10) => {
clearTimers();
const normalizedSeconds = Math.max(0, Math.trunc(Number(seconds || 0)));
if (!nextJob) {
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
return;
}
setCooldownRemaining(normalizedSeconds);
if (normalizedSeconds > 0) {
countdownIntervalRef.current = setInterval(() => {
setCooldownRemaining((prev) => {
const next = Number(prev || 0) - 1;
return next > 0 ? next : 0;
});
}, 1000);
}
nextJobTimeoutRef.current = setTimeout(() => {
clearTimers();
setCooldownRemaining(0);
if (!runningRef.current) {
return;
}
openJobInDialog(nextJob);
}, normalizedSeconds * 1000);
}, [clearTimers, openJobInDialog]);
const startSequence = () => {
const pending = jobsRef.current;
if (!Array.isArray(pending) || pending.length === 0) {
return;
}
clearTimers();
setRunning(true);
setMetadataDialogVisible(true);
setCooldownRemaining(0);
openJobInDialog(pending[0]);
};
const stopSequence = useCallback(() => {
clearTimers();
setRunning(false);
setCooldownRemaining(0);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}, [clearTimers]);
const handleMetadataSearch = async (query, options = {}) => {
const workflow = String(
options?.workflowKind
|| metadataDialogContext?.selectedMetadata?.workflowKind
|| ''
).trim().toLowerCase();
try {
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = workflow === 'series'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchTmdbMovie(query);
return response?.results || [];
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'TMDb-Suche fehlgeschlagen',
detail: error?.message || 'Suche fehlgeschlagen.'
});
return [];
}
};
const handleMetadataSubmit = async (payload) => {
const normalizedJobId = normalizeJobId(payload?.jobId);
if (!normalizedJobId) {
return;
}
setMetadataAssignBusy(true);
clearTimers();
setCooldownRemaining(0);
try {
await api.assignJobMetadata(normalizedJobId, payload || {});
const remaining = jobsRef.current.filter((row) => normalizeJobId(row?.id) !== normalizedJobId);
jobsRef.current = remaining;
setJobs(remaining);
toastRef.current?.show({
severity: 'success',
summary: 'Job migriert',
detail: `Job #${normalizedJobId} wurde auf TMDb aktualisiert.`,
life: 2600
});
if (remaining.length === 0) {
setRunning(false);
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
toastRef.current?.show({
severity: 'success',
summary: 'Migration abgeschlossen',
detail: 'Alle ausstehenden Jobs wurden abgearbeitet.',
life: 3200
});
return;
}
if (!runningRef.current) {
return;
}
scheduleNextJob(remaining[0], 10);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Migration fehlgeschlagen',
detail: error?.message || 'Metadaten konnten nicht übernommen werden.'
});
throw error;
} finally {
setMetadataAssignBusy(false);
}
};
const mediumTagBody = (row) => {
const mediaType = resolveMediaType(row);
const label = mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType === 'converter' ? 'Converter' : 'Other';
return <Tag value={label} severity={mediaType === 'converter' ? 'info' : 'warning'} />;
};
const titleBody = (row) => String(row?.title || row?.detected_title || `Job #${row?.id || '-'}`).trim() || '-';
const countdownProgressValue = useMemo(() => {
if (cooldownRemaining <= 0) {
return 0;
}
const elapsed = 10 - Math.min(10, cooldownRemaining);
return Math.max(0, Math.min(100, Math.round((elapsed / 10) * 100)));
}, [cooldownRemaining]);
return (
<div className="page-grid tmdb-migration-page">
<Toast ref={toastRef} position="top-right" />
<Card title="TMDb Migration (Direktlink)" subTitle="Diese Seite ist nicht in der Navigation verlinkt.">
<div className="tmdb-migration-toolbar">
<div className="tmdb-migration-toolbar-left">
<Tag value={`${jobs.length} offen`} severity={jobs.length > 0 ? 'warning' : 'success'} />
{running ? <Tag value="Sequenz aktiv" severity="info" /> : null}
</div>
<div className="tmdb-migration-toolbar-actions">
<Button
label="Liste neu laden"
icon={loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
outlined
onClick={() => {
if (!metadataAssignBusy) {
void loadPendingJobs();
}
}}
disabled={metadataAssignBusy}
/>
{!running ? (
<Button
label="Start"
icon="pi pi-play"
onClick={startSequence}
disabled={jobs.length === 0 || loading || metadataAssignBusy}
/>
) : (
<Button
label="Stop"
icon="pi pi-stop"
severity="secondary"
onClick={stopSequence}
disabled={metadataAssignBusy}
/>
)}
</div>
</div>
{running && cooldownRemaining > 0 ? (
<div className="tmdb-migration-countdown">
<ProgressSpinner style={{ width: '2rem', height: '2rem' }} strokeWidth="5" />
<div className="tmdb-migration-countdown-copy">
<strong>Nächste Suche startet in {cooldownRemaining}s</strong>
<small>API-Wartezeit aktiv (mind. 10 Sekunden zwischen Jobs).</small>
</div>
<div className="tmdb-migration-countdown-progress" aria-label="Cooldown Fortschritt">
<div className="tmdb-migration-countdown-bar" style={{ width: `${countdownProgressValue}%` }} />
</div>
</div>
) : null}
<DataTable
value={jobs}
loading={loading}
dataKey="id"
emptyMessage="Keine offenen Jobs für TMDb-Migration."
className="tmdb-migration-table"
rows={50}
paginator={jobs.length > 50}
>
<Column field="id" header="ID" style={{ width: '6rem' }} body={(row) => `#${row?.id || '-'}`} />
<Column header="Titel" body={titleBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} body={(row) => row?.year || '-'} />
<Column header="Medium" body={mediumTagBody} style={{ width: '10rem' }} />
<Column
header="Status"
style={{ width: '14rem' }}
body={(row) => String(row?.status || row?.last_state || '-').trim() || '-'}
/>
</DataTable>
</Card>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={stopSequence}
onSubmit={handleMetadataSubmit}
onSearch={handleMetadataSearch}
busy={metadataAssignBusy || (running && cooldownRemaining > 0)}
/>
</div>
);
}
+199 -58
View File
@@ -983,6 +983,62 @@ body {
gap: 0.15rem; gap: 0.15rem;
} }
.tmdb-migration-page .p-card .p-card-content {
display: grid;
gap: 0.9rem;
}
.tmdb-migration-toolbar {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.tmdb-migration-toolbar-left,
.tmdb-migration-toolbar-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.tmdb-migration-countdown {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.65rem 0.75rem;
align-items: center;
border: 1px solid var(--surface-border);
border-radius: 0.55rem;
background: var(--surface-section);
padding: 0.65rem 0.8rem;
}
.tmdb-migration-countdown-copy {
display: grid;
gap: 0.12rem;
}
.tmdb-migration-countdown-copy small {
color: var(--text-color-secondary);
}
.tmdb-migration-countdown-progress {
grid-column: 1 / -1;
width: 100%;
height: 0.4rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.tmdb-migration-countdown-bar {
height: 100%;
background: var(--primary-color);
transition: width 180ms linear;
}
.queue-job-expand-btn { .queue-job-expand-btn {
width: 1.6rem; width: 1.6rem;
height: 1.6rem; height: 1.6rem;
@@ -1300,7 +1356,7 @@ body {
.aax-file-item { .aax-file-item {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.65rem; gap: 0.65rem;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border-top: 1px solid var(--rip-border); border-top: 1px solid var(--rip-border);
@@ -1328,6 +1384,60 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.aax-file-status {
display: grid;
gap: 0.28rem;
margin-top: 0.3rem;
}
.aax-file-status .p-progressbar {
height: 0.35rem;
}
.audiobook-upload-header {
width: 100%;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.9rem;
flex-wrap: wrap;
}
.audiobook-upload-header-actions {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.audiobook-upload-header-actions .p-button.p-button-icon-only {
width: 2.35rem !important;
height: 2.35rem !important;
}
.audiobook-upload-header-status {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: fit-content;
}
.audiobook-upload-header-status-top {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
flex-wrap: wrap;
}
.audiobook-upload-header-status-top small {
color: var(--rip-muted);
}
.audiobook-upload-inline-text {
color: var(--rip-muted);
overflow-wrap: anywhere;
}
.audiobook-upload-status { .audiobook-upload-status {
margin-top: 0.8rem; margin-top: 0.8rem;
padding: 0.7rem 0.8rem; padding: 0.7rem 0.8rem;
@@ -1379,6 +1489,17 @@ body {
margin-top: 0.15rem; margin-top: 0.15rem;
} }
@media (max-width: 860px) {
.audiobook-upload-header-status {
min-width: 100%;
justify-content: flex-start;
}
.audiobook-upload-header-status-top {
justify-content: flex-start;
}
}
.ripper-job-badges { .ripper-job-badges {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3552,14 +3673,14 @@ body {
background: #f6ebd6; background: #f6ebd6;
} }
.omdb-row { .metadata-row {
display: grid; display: grid;
grid-template-columns: 64px 1fr; grid-template-columns: 64px 1fr;
gap: 0.65rem; gap: 0.65rem;
align-items: center; align-items: center;
} }
.omdb-row > div { .metadata-row > div {
min-width: 0; min-width: 0;
} }
@@ -3793,7 +3914,8 @@ body {
border-radius: 0.45rem; border-radius: 0.45rem;
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
background: var(--rip-panel-soft); background: var(--rip-panel-soft);
display: grid; display: flex;
flex-direction: column;
gap: 0.45rem; gap: 0.45rem;
} }
@@ -3801,6 +3923,44 @@ body {
margin: 0; margin: 0;
} }
.post-script-box > small:last-child {
display: block;
min-height: 2.4em;
line-height: 1.2;
}
.post-script-box .actions-row,
.post-script-box .encode-item-add-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
width: 100%;
margin-top: auto;
}
.post-script-box .actions-row .p-button,
.post-script-box .encode-item-add-row .p-button {
width: 100%;
min-height: 2.35rem;
}
.post-script-box .actions-row > .p-button:only-child,
.post-script-box .encode-item-add-row > .p-button:only-child {
grid-column: 1 / -1;
}
@media (max-width: 1675px) {
.post-script-box .actions-row,
.post-script-box .encode-item-add-row {
grid-template-columns: 1fr;
}
.post-script-box .actions-row > .p-button:only-child,
.post-script-box .encode-item-add-row > .p-button:only-child {
grid-column: auto;
}
}
.post-script-row { .post-script-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -5119,6 +5279,17 @@ body {
gap: 1rem; gap: 1rem;
} }
.encode-automation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
align-items: stretch;
}
.encode-automation-grid > :only-child {
grid-column: 1 / -1;
}
.audiobook-config-head { .audiobook-config-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -5152,13 +5323,6 @@ body {
display: block; display: block;
} }
.audiobook-config-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: flex-start;
}
.audiobook-config-grid { .audiobook-config-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -5189,9 +5353,7 @@ body {
.audiobook-chapter-list { .audiobook-chapter-list {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
max-height: 18rem; grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: auto;
padding-right: 0.25rem;
} }
.audiobook-chapter-row { .audiobook-chapter-row {
@@ -5224,47 +5386,13 @@ body {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.audiobook-chapter-status-list { .audiobook-chapter-status .cd-track-table {
display: grid; min-width: 38rem;
gap: 0.25rem;
max-height: 16rem;
overflow: auto;
padding-right: 0.25rem;
} }
.audiobook-chapter-status-row { .audiobook-chapter-status .cd-track-table td.title,
display: flex; .audiobook-chapter-status .cd-track-table th.title {
align-items: center; min-width: 16rem;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: 6px;
font-size: 0.85rem;
}
.chapter-status-done {
background: var(--green-50, #f0fdf4);
color: var(--green-700, #15803d);
}
.chapter-status-active {
background: var(--blue-50, #eff6ff);
color: var(--blue-700, #1d4ed8);
font-weight: 600;
}
.chapter-status-pending {
color: var(--rip-muted, #666);
}
.chapter-status-nr {
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.chapter-status-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.audiobook-description-dialog p { .audiobook-description-dialog p {
@@ -5281,7 +5409,13 @@ body {
word-break: break-word; word-break: break-word;
} }
@media (max-width: 980px) { @media (max-width: 1600px) {
.audiobook-chapter-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1350px) {
.audiobook-config-summary { .audiobook-config-summary {
width: 100%; width: 100%;
} }
@@ -5294,6 +5428,17 @@ body {
.audiobook-config-grid { .audiobook-config-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.audiobook-chapter-list {
grid-template-columns: 1fr;
}
}
@media (max-width: 1400px) {
.encode-automation-grid {
grid-template-columns: 1fr;
}
} }
/* ── ReencodeConflictModal ──────────────────────────────────────────────── */ /* ── ReencodeConflictModal ──────────────────────────────────────────────── */
@@ -5723,10 +5868,6 @@ body {
gap: 0.55rem; gap: 0.55rem;
} }
.audiobook-output-explorer .audiobook-output-row {
grid-template-columns: minmax(180px, 2fr) minmax(96px, 0.8fr) minmax(160px, 1.2fr);
}
/* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */ /* ── Icon-Button (wie Klangkiste) ────────────────────────────────────────── */
.icon-button { .icon-button {
+4 -14
View File
@@ -254,16 +254,14 @@ export function isSeriesVideoJob(job) {
return true; return true;
} }
const metadataProvider = String(
analyzeContext?.metadataProvider
|| selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const metadataKind = String( const metadataKind = String(
selectedMetadata?.metadataKind selectedMetadata?.metadataKind
|| analyzeContext?.metadataKind || analyzeContext?.metadataKind
|| '' || ''
).trim().toLowerCase(); ).trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(metadataKind)) {
return false;
}
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null; const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const seasonNumberText = String(seasonNumberRaw ?? '').trim(); const seasonNumberText = String(seasonNumberRaw ?? '').trim();
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.')); const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
@@ -279,15 +277,7 @@ export function isSeriesVideoJob(job) {
return true; return true;
} }
const tmdbIdRaw = selectedMetadata?.tmdbId return false;
?? analyzeContext?.tmdbId
?? selectedMetadata?.providerId
?? analyzeContext?.providerId
?? null;
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
} }
export function isSeriesDvdJob(job) { export function isSeriesDvdJob(job) {
+5 -5
View File
@@ -13,11 +13,11 @@ const STATUS_LABELS = {
FINISHED: 'Fertig', FINISHED: 'Fertig',
CANCELLED: 'Abgebrochen', CANCELLED: 'Abgebrochen',
ERROR: 'Fehler', ERROR: 'Fehler',
CD_ANALYZING: 'CD-Analyse', CD_ANALYZING: 'Analyse',
CD_METADATA_SELECTION: 'CD-Metadatenauswahl', CD_METADATA_SELECTION: 'Metadatenauswahl',
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)', CD_READY_TO_RIP: 'Bereit zum Encodieren',
CD_RIPPING: 'CD-Rippen', CD_RIPPING: 'Rippen',
CD_ENCODING: 'CD-Encodieren' CD_ENCODING: 'Encodieren'
}; };
const PROCESS_STATUS_LABELS = { const PROCESS_STATUS_LABELS = {
+5 -2
View File
@@ -222,7 +222,7 @@ backup_and_remove_backend_src() {
backup_persistent_backend_data() { backup_persistent_backend_data() {
local src_dir="$INSTALL_DIR/backend/data" local src_dir="$INSTALL_DIR/backend/data"
local backup_root="/tmp/ripster-data-backup" local backup_root="$INSTALL_DIR/backend/data/temp/ripster-data-backup"
local backup_dir="${backup_root}/data_bak_$(date +%Y%m%d%H%M%S)" local backup_dir="${backup_root}/data_bak_$(date +%Y%m%d%H%M%S)"
local copied_any=false local copied_any=false
local candidate local candidate
@@ -391,7 +391,7 @@ SupplementaryGroups=video render cdrom disk
# darf no_new_privileges nicht aktiv sein. # darf no_new_privileges nicht aktiv sein.
NoNewPrivileges=false NoNewPrivileges=false
ProtectSystem=full ProtectSystem=full
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR} ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
PrivateTmp=true PrivateTmp=true
EOF EOF
) )
@@ -974,6 +974,7 @@ mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd" mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/downloads" mkdir -p "$INSTALL_DIR/backend/data/downloads"
mkdir -p "$INSTALL_DIR/backend/data/logs" mkdir -p "$INSTALL_DIR/backend/data/logs"
mkdir -p "$INSTALL_DIR/backend/data/temp"
# Gesicherte Daten zurückspielen # Gesicherte Daten zurückspielen
if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
@@ -987,6 +988,7 @@ mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies" mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/series" mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd" mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/temp"
# --- npm-Abhängigkeiten installieren ----------------------------------------- # --- npm-Abhängigkeiten installieren -----------------------------------------
header "npm-Abhängigkeiten installieren" header "npm-Abhängigkeiten installieren"
@@ -1043,6 +1045,7 @@ DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
DEFAULT_TEMP_DIR=${INSTALL_DIR}/backend/data/temp
EOF EOF
ok "Backend .env erstellt" ok "Backend .env erstellt"
fi fi
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.16.1-6", "version": "0.16.1-7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.16.1-6", "version": "0.16.1-7",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.16.1-6", "version": "0.16.1-7",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",