0.13.0 DVD Series
This commit is contained in:
@@ -8,5 +8,6 @@ LOG_LEVEL=debug
|
||||
# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.)
|
||||
DEFAULT_RAW_DIR=
|
||||
DEFAULT_MOVIE_DIR=
|
||||
DEFAULT_SERIES_DIR=
|
||||
DEFAULT_CD_DIR=
|
||||
DEFAULT_DOWNLOAD_DIR=
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'),
|
||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
||||
|
||||
+152
-9
@@ -838,6 +838,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
required: 0,
|
||||
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
||||
validation_json: '{}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_episode',
|
||||
required: 1,
|
||||
description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_multi_episode',
|
||||
required: 1,
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -917,6 +929,16 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
|
||||
);
|
||||
|
||||
// depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige
|
||||
// Settings per INSERT OR IGNORE angelegt werden.
|
||||
{
|
||||
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
|
||||
if (!cols.some((c) => c.name === 'depends_on')) {
|
||||
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
|
||||
logger.info('migrate:settings-schema-add-depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
// Add movie_dir_cd if not already present
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -986,6 +1008,87 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`);
|
||||
|
||||
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_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`);
|
||||
|
||||
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_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`);
|
||||
|
||||
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_plugin_enabled', 'Features', 'DVD Serien-Plugin aktiviert', 'boolean', 1, 'Aktiviert die additive Serienerkennung für DVDs. Wenn deaktiviert, bleibt der bestehende DVD-Film-Flow unverändert.', 'false', '[]', '{}', 145)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_plugin_enabled', 'false')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||
VALUES ('dvd_series_prescan_enabled', 'Features', 'DVD HandBrake-Prescan aktiviert', 'boolean', 1, 'Führt nach der DVD-Erkennung einen zusätzlichen HandBrake-Prescan für die Serienheuristik aus.', 'true', '[]', '{}', 146, 'dvd_series_plugin_enabled')`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_prescan_enabled', 'true')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);
|
||||
|
||||
await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
|
||||
await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
|
||||
|
||||
// Converter-Pfade und Eigentümer
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -1077,15 +1180,6 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);
|
||||
|
||||
// depends_on-Spalte zu settings_schema hinzufügen (falls noch nicht vorhanden)
|
||||
{
|
||||
const cols = await db.all(`PRAGMA table_info(settings_schema)`);
|
||||
if (!cols.some((c) => c.name === 'depends_on')) {
|
||||
await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
|
||||
logger.info('migrate:settings-schema-add-depends_on');
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -1093,6 +1187,55 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
provider_series_name TEXT,
|
||||
season_number INTEGER,
|
||||
season_type TEXT NOT NULL DEFAULT 'dvd',
|
||||
disc_label TEXT,
|
||||
disc_serial TEXT,
|
||||
disc_number INTEGER,
|
||||
language TEXT,
|
||||
disc_signature TEXT NOT NULL,
|
||||
fingerprint_json TEXT,
|
||||
episode_map_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`);
|
||||
await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`);
|
||||
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS dvd_series_title_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER NOT NULL,
|
||||
disc_profile_id INTEGER,
|
||||
title_index INTEGER NOT NULL,
|
||||
title_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
selected INTEGER NOT NULL DEFAULT 1,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
season_number INTEGER,
|
||||
episode_start INTEGER,
|
||||
episode_end INTEGER,
|
||||
episode_ids_json TEXT,
|
||||
mapping_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`);
|
||||
await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`);
|
||||
await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`);
|
||||
}
|
||||
|
||||
async function backfillJobOutputFolders(db) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
const { SourcePlugin } = require('./PluginBase');
|
||||
const dvdSeriesScanService = require('../services/dvdSeriesScanService');
|
||||
const tmdbService = require('../services/tmdbService');
|
||||
|
||||
/**
|
||||
* Companion-Plugin für Serien-DVDs.
|
||||
*
|
||||
* Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem
|
||||
* späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine
|
||||
* serientypische Titelstruktur vermuten lässt.
|
||||
*/
|
||||
class DVDSeriesPlugin extends SourcePlugin {
|
||||
get id() {
|
||||
return 'dvd_series';
|
||||
}
|
||||
|
||||
get name() {
|
||||
return 'DVD Series';
|
||||
}
|
||||
|
||||
detect() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async analyze(_devicePath, job, ctx) {
|
||||
ctx.markExecution('analyze', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
|
||||
const scanText = String(ctx.extra?.scanText || '').trim();
|
||||
if (!scanText) {
|
||||
return {
|
||||
parsedScan: null,
|
||||
seriesAnalysis: null,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {});
|
||||
const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
|
||||
{
|
||||
value: result?.parsed?.discTitle || '',
|
||||
source: 'handbrake_disc_title'
|
||||
},
|
||||
{
|
||||
value: ctx.extra?.detectedTitle || '',
|
||||
source: 'detected_title'
|
||||
}
|
||||
]);
|
||||
return {
|
||||
parsedScan: result.parsed,
|
||||
seriesAnalysis: result.analysis,
|
||||
seriesLookupHint,
|
||||
providerConfigured: await tmdbService.isConfigured()
|
||||
};
|
||||
}
|
||||
|
||||
async review(_job, ctx) {
|
||||
ctx.markExecution('review', {
|
||||
pluginId: this.id,
|
||||
pluginName: this.name,
|
||||
pluginFile: 'DVDSeriesPlugin.js'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async rip() {
|
||||
throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async encode() {
|
||||
throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
return tmdbService.searchSeries(query, options);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
return tmdbService.searchSeriesWithSeason(query, seasonNumber, options);
|
||||
}
|
||||
|
||||
getSettingsSchema() {
|
||||
return [
|
||||
{
|
||||
key: 'dvd_series_plugin_enabled',
|
||||
category: 'Features',
|
||||
label: 'DVD Serien-Plugin aktiviert',
|
||||
type: 'boolean',
|
||||
required: 1,
|
||||
description: 'Aktiviert die additive Serienerkennung für DVDs.',
|
||||
default_value: 'false',
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 145
|
||||
},
|
||||
{
|
||||
key: 'tmdb_api_read_access_token',
|
||||
category: 'Metadaten',
|
||||
label: 'TMDb Read Access Token',
|
||||
type: 'string',
|
||||
required: 0,
|
||||
description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).',
|
||||
default_value: null,
|
||||
options_json: '[]',
|
||||
validation_json: '{}',
|
||||
order_index: 401
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DVDSeriesPlugin };
|
||||
@@ -161,7 +161,9 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
collectLines: scanLines,
|
||||
collectStderrLines: false,
|
||||
// HandBrake scan details (duration/chapters/audio/subtitles) are often
|
||||
// emitted on stderr. Collect both streams for reliable disc heuristics.
|
||||
collectStderrLines: true,
|
||||
silent: reviewSilent
|
||||
});
|
||||
} catch (cmdError) {
|
||||
@@ -179,6 +181,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
cmd: scanConfig.cmd,
|
||||
args: scanConfig.args,
|
||||
onStdoutLine: (line) => scanLines.push(line),
|
||||
onStderrLine: (line) => scanLines.push(line),
|
||||
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
||||
});
|
||||
}
|
||||
@@ -219,6 +222,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
deviceInfo,
|
||||
selectedTitleId = null,
|
||||
backupOutputBase = null,
|
||||
disableMinLengthFilter = false,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
@@ -237,7 +241,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
selectedTitleId,
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
backupOutputBase
|
||||
backupOutputBase,
|
||||
disableMinLengthFilter
|
||||
});
|
||||
|
||||
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||
@@ -249,7 +254,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
args: ripConfig.args,
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
selectedTitleId
|
||||
selectedTitleId,
|
||||
disableMinLengthFilter
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, ripMode === 'backup'
|
||||
@@ -443,7 +449,7 @@ function _assertNotCancelled(isCancelled) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
|
||||
async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
|
||||
const lines = [];
|
||||
const handle = spawnTrackedProcess({
|
||||
cmd,
|
||||
@@ -454,7 +460,12 @@ async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
|
||||
onStdoutLine(line);
|
||||
}
|
||||
},
|
||||
onStderrLine: () => {},
|
||||
onStderrLine: (line) => {
|
||||
lines.push(line);
|
||||
if (typeof onStderrLine === 'function') {
|
||||
onStderrLine(line);
|
||||
}
|
||||
},
|
||||
context: context || {}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,13 +18,15 @@ router.get(
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean);
|
||||
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
||||
const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase());
|
||||
logger.info('get:jobs', {
|
||||
reqId: req.reqId,
|
||||
status: req.query.status,
|
||||
statuses: statuses.length > 0 ? statuses : null,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
lite
|
||||
lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
const jobs = await historyService.getJobs({
|
||||
@@ -32,7 +34,8 @@ router.get(
|
||||
statuses,
|
||||
search: req.query.search,
|
||||
limit,
|
||||
includeFsChecks: !lite
|
||||
includeFsChecks: !lite,
|
||||
includeChildren
|
||||
});
|
||||
|
||||
res.json({ jobs });
|
||||
@@ -108,6 +111,16 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/error/ack',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
logger.info('post:job:error:ack', { reqId: req.reqId, id });
|
||||
const job = await historyService.acknowledgeJobError(id);
|
||||
res.json({ job });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/delete-files',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -155,6 +155,17 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/tmdb/series/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
const query = req.query.q || '';
|
||||
const seasonNumber = req.query.season || null;
|
||||
logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber });
|
||||
const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber);
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/cd/musicbrainz/search',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -341,7 +352,26 @@ router.get(
|
||||
router.post(
|
||||
'/select-metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId } = req.body;
|
||||
const {
|
||||
jobId,
|
||||
title,
|
||||
year,
|
||||
imdbId,
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber
|
||||
} = req.body;
|
||||
|
||||
if (!jobId) {
|
||||
const error = new Error('jobId fehlt.');
|
||||
@@ -358,7 +388,13 @@ router.post(
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
seasonNumber,
|
||||
discNumber
|
||||
});
|
||||
|
||||
const job = await pipelineService.selectMetadata({
|
||||
@@ -369,7 +405,17 @@ router.post(
|
||||
poster,
|
||||
fromOmdb,
|
||||
selectedPlaylist,
|
||||
selectedHandBrakeTitleId
|
||||
selectedHandBrakeTitleId,
|
||||
selectedHandBrakeTitleIds,
|
||||
metadataProvider,
|
||||
providerId,
|
||||
tmdbId,
|
||||
metadataKind,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
episodeCount,
|
||||
episodes,
|
||||
discNumber
|
||||
});
|
||||
|
||||
res.json({ job });
|
||||
@@ -391,7 +437,9 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
|
||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||
const episodeAssignments = req.body?.episodeAssignments ?? null;
|
||||
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
|
||||
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
||||
@@ -403,7 +451,9 @@ router.post(
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0,
|
||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||
episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'),
|
||||
skipPipelineStateUpdate,
|
||||
selectedUserPresetId,
|
||||
selectedHandBrakePreset,
|
||||
@@ -422,7 +472,9 @@ router.post(
|
||||
});
|
||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||
selectedEncodeTitleId,
|
||||
selectedEncodeTitleIds,
|
||||
selectedTrackSelection,
|
||||
episodeAssignments,
|
||||
selectedPostEncodeScriptIds,
|
||||
selectedPreEncodeScriptIds,
|
||||
selectedPostEncodeChainIds,
|
||||
@@ -525,8 +577,19 @@ router.post(
|
||||
const jobId = Number(req.params.jobId);
|
||||
const keepBoth = Boolean(req.body?.keepBoth);
|
||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders });
|
||||
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
||||
logger.info('post:restart-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
keepBoth,
|
||||
restartMode,
|
||||
deleteFolderCount: deleteFolders.length
|
||||
});
|
||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
|
||||
keepBoth,
|
||||
deleteFolders,
|
||||
restartMode
|
||||
});
|
||||
res.json({ result });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ function isSensitiveSettingKey(key) {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user)/i.test(normalized);
|
||||
return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized);
|
||||
}
|
||||
|
||||
router.get(
|
||||
@@ -471,6 +471,50 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/drives/force-unlock',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { devicePath, all } = req.body || {};
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase());
|
||||
if (!expertMode) {
|
||||
const error = new Error('Expertenmodus erforderlich.');
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const devices = await diskDetectionService.getBlockDeviceInfo();
|
||||
const drives = devices
|
||||
.filter((d) => d.type === 'rom')
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
return d.path || (name ? `/dev/${name}` : null);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const activeLockPaths = (diskDetectionService.getActiveLocks?.() || [])
|
||||
.map((entry) => String(entry?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const targetPaths = all
|
||||
? Array.from(new Set([...drives, ...activeLockPaths]))
|
||||
: [String(devicePath || '').trim()].filter(Boolean);
|
||||
if (targetPaths.length === 0) {
|
||||
const error = new Error('Kein Laufwerk angegeben.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const target of targetPaths) {
|
||||
const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
})
|
||||
);
|
||||
|
||||
// User preferences (UI state, persisted per-installation)
|
||||
router.get(
|
||||
'/prefs/:key',
|
||||
|
||||
@@ -374,13 +374,67 @@ class DiskDetectionService extends EventEmitter {
|
||||
return String(devicePath || '').trim();
|
||||
}
|
||||
|
||||
lockDevice(devicePath, owner = null) {
|
||||
_resolveDeviceRealPath(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized || !normalized.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (fs.realpathSync && typeof fs.realpathSync.native === 'function') {
|
||||
return fs.realpathSync.native(normalized);
|
||||
}
|
||||
return fs.realpathSync(normalized);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_deviceBaseName(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
return String(parts[parts.length - 1] || '').trim();
|
||||
}
|
||||
|
||||
_isSameDevicePath(leftPath, rightPath) {
|
||||
const left = this.normalizeDevicePath(leftPath);
|
||||
const right = this.normalizeDevicePath(rightPath);
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
const leftReal = this._resolveDeviceRealPath(left);
|
||||
const rightReal = this._resolveDeviceRealPath(right);
|
||||
if (leftReal && rightReal && leftReal === rightReal) {
|
||||
return true;
|
||||
}
|
||||
const leftBase = this._deviceBaseName(left);
|
||||
const rightBase = this._deviceBaseName(right);
|
||||
if (leftBase && rightBase && leftBase === rightBase && /^sr\d+$/i.test(leftBase)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_resolveLockKey(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return this._resolveDeviceRealPath(normalized) || normalized;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized) || {
|
||||
lockDevice(devicePath, owner = null) {
|
||||
const lockKey = this._resolveLockKey(devicePath);
|
||||
if (!lockKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(lockKey) || {
|
||||
count: 0,
|
||||
owners: []
|
||||
};
|
||||
@@ -389,16 +443,16 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (owner) {
|
||||
entry.owners.push(owner);
|
||||
}
|
||||
this.deviceLocks.set(normalized, entry);
|
||||
this.deviceLocks.set(lockKey, entry);
|
||||
|
||||
logger.info('lock:add', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
count: entry.count,
|
||||
owner
|
||||
});
|
||||
|
||||
return {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
owner
|
||||
};
|
||||
}
|
||||
@@ -409,24 +463,33 @@ class DiskDetectionService extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized);
|
||||
const directKey = this._resolveLockKey(normalized);
|
||||
const candidateKeys = Array.from(this.deviceLocks.keys());
|
||||
const lockKey = (directKey && this.deviceLocks.has(directKey))
|
||||
? directKey
|
||||
: (candidateKeys.find((key) => this._isSameDevicePath(key, normalized)) || null);
|
||||
if (!lockKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(lockKey);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.count = Math.max(0, entry.count - 1);
|
||||
if (entry.count === 0) {
|
||||
this.deviceLocks.delete(normalized);
|
||||
this.deviceLocks.delete(lockKey);
|
||||
logger.info('lock:remove', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
owner
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.deviceLocks.set(normalized, entry);
|
||||
this.deviceLocks.set(lockKey, entry);
|
||||
logger.info('lock:decrement', {
|
||||
devicePath: normalized,
|
||||
devicePath: lockKey,
|
||||
count: entry.count,
|
||||
owner
|
||||
});
|
||||
@@ -438,20 +501,26 @@ class DiskDetectionService extends EventEmitter {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized);
|
||||
if (!entry) {
|
||||
const candidateKeys = Array.from(this.deviceLocks.keys())
|
||||
.filter((key) => this._isSameDevicePath(key, normalized));
|
||||
if (candidateKeys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const previousCount = Math.max(0, Number(entry.count) || 0);
|
||||
this.deviceLocks.delete(normalized);
|
||||
logger.warn('lock:force-remove', {
|
||||
devicePath: normalized,
|
||||
previousCount,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||
});
|
||||
return previousCount;
|
||||
let removed = 0;
|
||||
for (const lockKey of candidateKeys) {
|
||||
const entry = this.deviceLocks.get(lockKey);
|
||||
const previousCount = Math.max(0, Number(entry?.count) || 0);
|
||||
removed += previousCount;
|
||||
this.deviceLocks.delete(lockKey);
|
||||
logger.warn('lock:force-remove', {
|
||||
devicePath: lockKey,
|
||||
previousCount,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
isDeviceLocked(devicePath) {
|
||||
@@ -459,7 +528,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return this.deviceLocks.has(normalized);
|
||||
const directKey = this._resolveLockKey(normalized);
|
||||
if (directKey && this.deviceLocks.has(directKey)) {
|
||||
return true;
|
||||
}
|
||||
return Array.from(this.deviceLocks.keys()).some((key) => this._isSameDevicePath(key, normalized));
|
||||
}
|
||||
|
||||
getActiveLocks() {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
'use strict';
|
||||
|
||||
const TITLE_KIND = Object.freeze({
|
||||
EPISODE_CANDIDATE: 'episode_candidate',
|
||||
PLAY_ALL: 'play_all',
|
||||
EXTRA: 'extra',
|
||||
DUPLICATE: 'duplicate',
|
||||
SHORT: 'short',
|
||||
UNKNOWN: 'unknown'
|
||||
});
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
minEpisodeMinutes: 18,
|
||||
maxEpisodeMinutes: 75,
|
||||
minChapterCount: 3,
|
||||
shortTitleMinutes: 5
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(rawValue) {
|
||||
const text = String(rawValue || '').trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]);
|
||||
}
|
||||
|
||||
function roundToStep(value, step) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || step <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(num / step) * step;
|
||||
}
|
||||
|
||||
function median(values = []) {
|
||||
const nums = (Array.isArray(values) ? values : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.sort((left, right) => left - right);
|
||||
if (nums.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const middle = Math.floor(nums.length / 2);
|
||||
if (nums.length % 2 === 1) {
|
||||
return nums[middle];
|
||||
}
|
||||
return (nums[middle - 1] + nums[middle]) / 2;
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(rawCode, fallbackLabel = null) {
|
||||
const raw = String(rawCode || '').trim().toLowerCase();
|
||||
if (raw && raw.length === 3) {
|
||||
return raw;
|
||||
}
|
||||
const label = String(fallbackLabel || '').trim().toLowerCase();
|
||||
if (label.startsWith('de')) return 'deu';
|
||||
if (label.startsWith('en')) return 'eng';
|
||||
if (label.startsWith('fr')) return 'fra';
|
||||
if (label.startsWith('es')) return 'spa';
|
||||
if (label.startsWith('nl')) return 'nld';
|
||||
return raw || 'und';
|
||||
}
|
||||
|
||||
function normalizeSeriesLookupTitle(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/[_./]+/g, ' ')
|
||||
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
|
||||
.replace(/\b\d{1,2}x\b/gi, ' ')
|
||||
.replace(/\b(?:complete|collection|boxset)\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function deriveSeriesLookupHint(inputs = []) {
|
||||
const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs])
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return {
|
||||
value: String(entry.value || '').trim(),
|
||||
source: String(entry.source || '').trim() || 'unknown'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: String(entry || '').trim(),
|
||||
source: 'unknown'
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.value);
|
||||
|
||||
for (const entry of normalizedInputs) {
|
||||
const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!compactValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonMatch = compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|
||||
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
|
||||
if (!seasonMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonNumber = Number(seasonMatch[1] || 0);
|
||||
if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discMatch = compactValue.match(/(?:^|\s)(?:disc|dvd|cd)\s*(\d{1,2})(?=\s|$)/i);
|
||||
const discNumber = discMatch
|
||||
? Number(discMatch[1] || 0) || null
|
||||
: null;
|
||||
|
||||
const baseTitle = normalizeSeriesLookupTitle(compactValue);
|
||||
if (!baseTitle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
query: baseTitle,
|
||||
seriesTitle: baseTitle,
|
||||
seasonNumber,
|
||||
discNumber,
|
||||
source: entry.source,
|
||||
sourceLabel: entry.value,
|
||||
confidence: discNumber ? 'high' : 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTitleRecord(title = {}) {
|
||||
const durationSeconds = Number(title.durationSeconds || 0);
|
||||
const chapterCount = Number(title.chapterCount || 0);
|
||||
const roundedDurationBucket = roundToStep(durationSeconds, 30);
|
||||
const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
index: Number(title.index || 0),
|
||||
durationSeconds,
|
||||
durationLabel: String(title.durationLabel || '').trim() || null,
|
||||
chapterCount,
|
||||
chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [],
|
||||
aspectRatio: String(title.aspectRatio || '').trim() || null,
|
||||
audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [],
|
||||
subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [],
|
||||
flags: Array.isArray(title.flags) ? title.flags : [],
|
||||
roundedDurationBucket,
|
||||
signature: JSON.stringify({
|
||||
duration: roundedDurationBucket,
|
||||
chapterCount,
|
||||
audioLanguages,
|
||||
subtitleLanguages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function buildBestEpisodeCluster(titles = [], options = {}) {
|
||||
const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60));
|
||||
const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60));
|
||||
const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount));
|
||||
const candidates = titles.filter((title) =>
|
||||
title.durationSeconds >= minEpisodeSeconds
|
||||
&& title.durationSeconds <= maxEpisodeSeconds
|
||||
&& title.chapterCount >= minChapterCount
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
titles: [],
|
||||
medianDurationSeconds: 0,
|
||||
medianChapterCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
let bestCluster = [];
|
||||
for (const anchor of candidates) {
|
||||
const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
|
||||
const cluster = candidates.filter((candidate) => {
|
||||
const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds);
|
||||
const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount);
|
||||
return durationGap <= durationTolerance && chapterGap <= 2;
|
||||
});
|
||||
|
||||
if (cluster.length > bestCluster.length) {
|
||||
bestCluster = cluster;
|
||||
continue;
|
||||
}
|
||||
if (cluster.length === bestCluster.length) {
|
||||
const clusterMedian = median(cluster.map((item) => item.durationSeconds));
|
||||
const bestMedian = median(bestCluster.map((item) => item.durationSeconds));
|
||||
if (clusterMedian > bestMedian) {
|
||||
bestCluster = cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
titles: bestCluster,
|
||||
medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)),
|
||||
medianChapterCount: median(bestCluster.map((item) => item.chapterCount))
|
||||
};
|
||||
}
|
||||
|
||||
function classifyTitles(titles = [], cluster = {}, options = {}) {
|
||||
const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60));
|
||||
const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index)));
|
||||
const duplicateFirstBySignature = new Map();
|
||||
|
||||
for (const title of titles) {
|
||||
if (!duplicateFirstBySignature.has(title.signature)) {
|
||||
duplicateFirstBySignature.set(title.signature, title.index);
|
||||
}
|
||||
}
|
||||
|
||||
const playAllCandidate = (() => {
|
||||
if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) {
|
||||
return null;
|
||||
}
|
||||
const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1);
|
||||
return titles
|
||||
.filter((title) =>
|
||||
!clusterTitleIds.has(Number(title.index))
|
||||
&& title.durationSeconds >= minPlayAllSeconds * 0.75
|
||||
&& title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6))
|
||||
)
|
||||
.sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null;
|
||||
})();
|
||||
|
||||
return titles.map((title) => {
|
||||
const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index;
|
||||
const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds;
|
||||
const isEpisodeCandidate = clusterTitleIds.has(Number(title.index));
|
||||
const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index);
|
||||
const kind = isPlayAll
|
||||
? TITLE_KIND.PLAY_ALL
|
||||
: isEpisodeCandidate
|
||||
? TITLE_KIND.EPISODE_CANDIDATE
|
||||
: !isFirstWithSignature
|
||||
? TITLE_KIND.DUPLICATE
|
||||
: isShort
|
||||
? TITLE_KIND.SHORT
|
||||
: (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN);
|
||||
|
||||
let confidence = 0.2;
|
||||
if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8;
|
||||
if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95;
|
||||
if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85;
|
||||
if (kind === TITLE_KIND.SHORT) confidence = 0.9;
|
||||
if (kind === TITLE_KIND.EXTRA) confidence = 0.55;
|
||||
|
||||
return {
|
||||
...title,
|
||||
kind,
|
||||
confidence,
|
||||
duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE
|
||||
? duplicateFirstBySignature.get(title.signature)
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiscSignature(parsedScan = {}) {
|
||||
return JSON.stringify({
|
||||
discTitle: String(parsedScan.discTitle || '').trim() || null,
|
||||
discSerial: String(parsedScan.discSerial || '').trim() || null,
|
||||
titleCount: Number(parsedScan.titleCount || 0),
|
||||
durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : [])
|
||||
.map((title) => ({
|
||||
index: Number(title.index || 0),
|
||||
durationBucket: roundToStep(title.durationSeconds || 0, 30),
|
||||
chapterCount: Number(title.chapterCount || 0)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) {
|
||||
const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length;
|
||||
const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length;
|
||||
const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length;
|
||||
const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length;
|
||||
|
||||
const reasons = [];
|
||||
let confidence = 'low';
|
||||
let seriesLike = false;
|
||||
|
||||
if (episodeCount >= 3) {
|
||||
seriesLike = true;
|
||||
confidence = playAllCount > 0 ? 'high' : 'medium';
|
||||
reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`);
|
||||
} else if (episodeCount === 2) {
|
||||
seriesLike = true;
|
||||
confidence = 'medium';
|
||||
reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.');
|
||||
}
|
||||
|
||||
if (playAllCount > 0) {
|
||||
reasons.push('Ein langer Play-All-Titel wurde erkannt.');
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`);
|
||||
}
|
||||
if (cluster.medianDurationSeconds > 0) {
|
||||
reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`);
|
||||
}
|
||||
if (extrasCount > 0) {
|
||||
reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`);
|
||||
}
|
||||
|
||||
return {
|
||||
seriesLike,
|
||||
confidence,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function parseHandBrakeScanText(rawOutput) {
|
||||
const lines = String(rawOutput || '').split(/\r?\n/);
|
||||
const parsed = {
|
||||
source: 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: null,
|
||||
discSerial: null,
|
||||
titleCount: 0,
|
||||
rawLineCount: lines.length,
|
||||
titles: []
|
||||
};
|
||||
|
||||
let currentTitle = null;
|
||||
const ensureCurrentTitle = (index) => {
|
||||
const normalizedIndex = Number(index);
|
||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentTitle && Number(currentTitle.index) === normalizedIndex) {
|
||||
return currentTitle;
|
||||
}
|
||||
const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex);
|
||||
if (existing) {
|
||||
currentTitle = existing;
|
||||
return currentTitle;
|
||||
}
|
||||
currentTitle = {
|
||||
index: normalizedIndex,
|
||||
durationLabel: null,
|
||||
durationSeconds: 0,
|
||||
chapterCount: 0,
|
||||
chapterDurationsMs: [],
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
aspectRatio: null,
|
||||
flags: []
|
||||
};
|
||||
parsed.titles.push(currentTitle);
|
||||
return currentTitle;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discTitle = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i);
|
||||
if (match) {
|
||||
parsed.discSerial = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i);
|
||||
if (match) {
|
||||
parsed.titleCount = Number(match[1] || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
|
||||
if (match) {
|
||||
ensureCurrentTitle(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.durationLabel = match[1];
|
||||
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
|
||||
if (match) {
|
||||
const title = ensureCurrentTitle(match[1]);
|
||||
if (title) {
|
||||
title.chapterCount = Number(match[2] || 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.chapterDurationsMs.push(Number(match[1] || 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i);
|
||||
if (match && currentTitle) {
|
||||
const track = {
|
||||
languageLabel: String(match[1] || '').trim() || null,
|
||||
languageCode: normalizeLanguageCode(match[2], match[1])
|
||||
};
|
||||
if (/\[VOBSUB\]/i.test(line)) {
|
||||
currentTitle.subtitleTracks.push(track);
|
||||
} else {
|
||||
currentTitle.audioTracks.push(track);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.aspectRatio = String(match[1] || '').trim() || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
match = line.match(/scan:\s+ignoring title \(too short\)/i);
|
||||
if (match && currentTitle) {
|
||||
currentTitle.flags.push('ignored_too_short');
|
||||
}
|
||||
}
|
||||
|
||||
parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0));
|
||||
if (!parsed.titleCount) {
|
||||
parsed.titleCount = parsed.titles.length;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function analyzeParsedScan(parsedScan = {}, options = {}) {
|
||||
const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord);
|
||||
const cluster = buildBestEpisodeCluster(titles, options);
|
||||
const classifiedTitles = classifyTitles(titles, cluster, options);
|
||||
const summary = summarizeSeriesLikelihood(classifiedTitles, cluster);
|
||||
|
||||
return {
|
||||
source: parsedScan.source || 'handbrake_scan_text',
|
||||
generatedAt: nowIso(),
|
||||
discTitle: parsedScan.discTitle || null,
|
||||
discSerial: parsedScan.discSerial || null,
|
||||
titleCount: Number(parsedScan.titleCount || titles.length || 0),
|
||||
discSignature: buildDiscSignature({
|
||||
discTitle: parsedScan.discTitle,
|
||||
discSerial: parsedScan.discSerial,
|
||||
titleCount: parsedScan.titleCount,
|
||||
titles
|
||||
}),
|
||||
summary: {
|
||||
...summary,
|
||||
titleCount: classifiedTitles.length,
|
||||
episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length,
|
||||
playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length,
|
||||
duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length,
|
||||
extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length,
|
||||
typicalEpisodeMinutes: cluster.medianDurationSeconds
|
||||
? Number((cluster.medianDurationSeconds / 60).toFixed(2))
|
||||
: 0
|
||||
},
|
||||
titles: classifiedTitles
|
||||
};
|
||||
}
|
||||
|
||||
function analyzeHandBrakeScan(rawOutput, options = {}) {
|
||||
const parsed = parseHandBrakeScanText(rawOutput);
|
||||
const analysis = analyzeParsedScan(parsed, options);
|
||||
return {
|
||||
parsed,
|
||||
analysis
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TITLE_KIND,
|
||||
parseHandBrakeScanText,
|
||||
analyzeParsedScan,
|
||||
analyzeHandBrakeScan,
|
||||
deriveSeriesLookupHint
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+3497
-262
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ const { setLogRootDir } = require('./logPathService');
|
||||
const {
|
||||
defaultRawDir: DEFAULT_RAW_DIR,
|
||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||
defaultSeriesDir: DEFAULT_SERIES_DIR,
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||
@@ -38,6 +39,7 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
|
||||
const SENSITIVE_SETTING_KEYS = new Set([
|
||||
'makemkv_registration_key',
|
||||
'omdb_api_key',
|
||||
'tmdb_api_read_access_token',
|
||||
'pushover_token',
|
||||
'pushover_user'
|
||||
]);
|
||||
@@ -62,18 +64,30 @@ const PROFILED_SETTINGS = {
|
||||
cd: 'raw_dir_cd_owner',
|
||||
audiobook: 'raw_dir_audiobook_owner'
|
||||
},
|
||||
series_raw_dir: {
|
||||
dvd: 'raw_dir_dvd_series'
|
||||
},
|
||||
series_raw_dir_owner: {
|
||||
dvd: 'raw_dir_dvd_series_owner'
|
||||
},
|
||||
movie_dir: {
|
||||
bluray: 'movie_dir_bluray',
|
||||
dvd: 'movie_dir_dvd',
|
||||
cd: 'movie_dir_cd',
|
||||
audiobook: 'movie_dir_audiobook'
|
||||
},
|
||||
series_dir: {
|
||||
dvd: 'series_dir_dvd'
|
||||
},
|
||||
movie_dir_owner: {
|
||||
bluray: 'movie_dir_bluray_owner',
|
||||
dvd: 'movie_dir_dvd_owner',
|
||||
cd: 'movie_dir_cd_owner',
|
||||
audiobook: 'movie_dir_audiobook_owner'
|
||||
},
|
||||
series_dir_owner: {
|
||||
dvd: 'series_dir_dvd_owner'
|
||||
},
|
||||
mediainfo_extra_args: {
|
||||
bluray: 'mediainfo_extra_args_bluray',
|
||||
dvd: 'mediainfo_extra_args_dvd'
|
||||
@@ -111,8 +125,12 @@ const PROFILED_SETTINGS = {
|
||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||
'raw_dir',
|
||||
'raw_dir_owner',
|
||||
'series_raw_dir',
|
||||
'series_raw_dir_owner',
|
||||
'movie_dir',
|
||||
'movie_dir_owner'
|
||||
'movie_dir_owner',
|
||||
'series_dir',
|
||||
'series_dir_owner'
|
||||
]);
|
||||
|
||||
function applyRuntimeLogDirSetting(rawValue) {
|
||||
@@ -813,6 +831,8 @@ class SettingsService {
|
||||
} else {
|
||||
resolvedValue = DEFAULT_RAW_DIR;
|
||||
}
|
||||
} else if (legacyKey === 'series_dir') {
|
||||
resolvedValue = DEFAULT_SERIES_DIR;
|
||||
} else if (legacyKey === 'movie_dir') {
|
||||
if (normalizedRequestedProfile === 'cd') {
|
||||
resolvedValue = DEFAULT_CD_DIR;
|
||||
@@ -858,7 +878,7 @@ class SettingsService {
|
||||
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||
return {
|
||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||
dvd: { raw: dvd.raw_dir, seriesRaw: dvd.series_raw_dir || dvd.raw_dir, movies: dvd.movie_dir, series: dvd.series_dir },
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
downloads: { path: bluray.download_dir },
|
||||
@@ -870,6 +890,7 @@ class SettingsService {
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
series: DEFAULT_SERIES_DIR,
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||
@@ -1116,6 +1137,7 @@ class SettingsService {
|
||||
: 'mkv';
|
||||
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
||||
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
|
||||
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
||||
let extra = [];
|
||||
let baseArgs = [];
|
||||
@@ -1141,6 +1163,9 @@ class SettingsService {
|
||||
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||
const hasExplicitTitle = rawSelectedTitleId !== null;
|
||||
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||
const minLengthSeconds = Number.isFinite(minLength) && minLength > 0
|
||||
? Math.round(minLength * 60)
|
||||
: 0;
|
||||
if (hasExplicitTitle) {
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
@@ -1150,9 +1175,12 @@ class SettingsService {
|
||||
rawJobDir
|
||||
];
|
||||
} else {
|
||||
const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0)
|
||||
? [`--minlength=${minLengthSeconds}`]
|
||||
: [];
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--minlength=' + Math.round(minLength * 60),
|
||||
...minLengthArgs,
|
||||
'mkv',
|
||||
sourceArg,
|
||||
targetTitle,
|
||||
@@ -1166,6 +1194,7 @@ class SettingsService {
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
deviceInfo,
|
||||
disableMinLengthFilter: ripMode === 'mkv' ? disableMinLengthFilter : false,
|
||||
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
|
||||
? Math.trunc(rawSelectedTitleId)
|
||||
: null
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
'use strict';
|
||||
|
||||
const settingsService = require('./settingsService');
|
||||
const logger = require('./logger').child('TMDB');
|
||||
|
||||
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
const TMDB_TIMEOUT_MS = 10000;
|
||||
|
||||
class TmdbService {
|
||||
normalizeNameList(values = [], options = {}) {
|
||||
const maxItems = Math.max(1, Number(options.maxItems || 10));
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const name = String(item?.name || item || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(name);
|
||||
if (output.length >= maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
formatRuntimeLabel(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${min} min` : `${min}-${max} min`;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return `${Math.trunc(numeric)} min`;
|
||||
}
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
const settings = await settingsService.getSettingsMap();
|
||||
return {
|
||||
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
|
||||
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
|
||||
};
|
||||
}
|
||||
|
||||
async isConfigured() {
|
||||
const config = await this.getConfig();
|
||||
return Boolean(config.readAccessToken);
|
||||
}
|
||||
|
||||
async resolveLanguage(explicitLanguage = null) {
|
||||
const config = await this.getConfig();
|
||||
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
|
||||
}
|
||||
|
||||
async request(pathName, options = {}) {
|
||||
const config = await this.getConfig();
|
||||
if (!config.readAccessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
|
||||
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${config.readAccessToken}`,
|
||||
'User-Agent': 'Ripster/1.0'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`TMDb request failed (${response.status})`);
|
||||
error.statusCode = response.status;
|
||||
error.url = url.toString();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
buildImageUrl(imagePath, size = 'w342') {
|
||||
const normalizedPath = String(imagePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
async searchSeries(query, options = {}) {
|
||||
const normalizedQuery = String(query || '').trim();
|
||||
if (!normalizedQuery || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
|
||||
const 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) => {
|
||||
logger.warn('search:failed', {
|
||||
query: normalizedQuery,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
const rows = Array.isArray(data?.results) ? data.results : [];
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: Number(row?.id || 0) || null,
|
||||
title: String(row?.name || row?.original_name || '').trim() || null,
|
||||
originalTitle: String(row?.original_name || '').trim() || null,
|
||||
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
|
||||
overview: String(row?.overview || '').trim() || null,
|
||||
posterPath: String(row?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(row?.poster_path, 'w342'),
|
||||
backdropPath: String(row?.backdrop_path || '').trim() || null,
|
||||
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
|
||||
originalLanguage: String(row?.original_language || '').trim() || null,
|
||||
popularity: Number(row?.popularity || 0) || 0
|
||||
}))
|
||||
.filter((row) => row.id && row.title);
|
||||
}
|
||||
|
||||
async getSeriesDetails(seriesId, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
const appendToResponse = Array.isArray(options.appendToResponse)
|
||||
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
return 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),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getEpisodeGroups(seriesId) {
|
||||
const id = Number(seriesId);
|
||||
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
|
||||
return [];
|
||||
}
|
||||
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
|
||||
logger.warn('series:episode-groups:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
return Array.isArray(response?.results) ? response.results : [];
|
||||
}
|
||||
|
||||
async getEpisodeGroupDetails(groupId, options = {}) {
|
||||
const normalizedId = String(groupId || '').trim();
|
||||
if (!normalizedId || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/episode_group/${normalizedId}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('episode-group:details:failed', {
|
||||
groupId: normalizedId,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
|
||||
const id = Number(seriesId);
|
||||
const season = Number(seasonNumber);
|
||||
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
|
||||
return null;
|
||||
}
|
||||
const language = await this.resolveLanguage(options.language);
|
||||
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
|
||||
query: {
|
||||
language
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('season:credits:failed', {
|
||||
seriesId: Math.trunc(id),
|
||||
seasonNumber: Math.trunc(season),
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
buildSeasonSummary(seasonDetails = null) {
|
||||
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
|
||||
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
|
||||
return {
|
||||
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
|
||||
name: String(details.name || '').trim() || null,
|
||||
overview: String(details.overview || '').trim() || null,
|
||||
posterPath: String(details.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(details.poster_path, 'w342'),
|
||||
episodeCount: episodes.length,
|
||||
episodes: episodes.map((episode) => ({
|
||||
id: Number(episode?.id || 0) || null,
|
||||
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
|
||||
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
|
||||
name: String(episode?.name || '').trim() || null,
|
||||
overview: String(episode?.overview || '').trim() || null,
|
||||
runtime: Number(episode?.runtime || 0) || null,
|
||||
airDate: String(episode?.air_date || '').trim() || null,
|
||||
stillPath: String(episode?.still_path || '').trim() || null,
|
||||
still: this.buildImageUrl(episode?.still_path, 'w300')
|
||||
})).filter((episode) => episode.id && episode.number)
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesDetailsSummary(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
|
||||
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
|
||||
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
|
||||
const genres = Array.isArray(details?.genres) ? details.genres : [];
|
||||
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
|
||||
const directorNames = this.normalizeNameList(
|
||||
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
|
||||
{ maxItems: 5 }
|
||||
);
|
||||
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
|
||||
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 || '').trim() || null;
|
||||
|
||||
return {
|
||||
director: directorNames.length > 0
|
||||
? directorNames.join(', ')
|
||||
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
|
||||
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
|
||||
runtime: runtimeLabel,
|
||||
runtimeLabel,
|
||||
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,
|
||||
firstAirDate: String(details?.first_air_date || '').trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
buildSeriesMetadataCandidate(series = {}, options = {}) {
|
||||
const season = series?.season && typeof series.season === 'object'
|
||||
? series.season
|
||||
: null;
|
||||
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
|
||||
const providerId = seasonNumber
|
||||
? `tmdb:${series.id}:season:${seasonNumber}`
|
||||
: `tmdb:${series.id}`;
|
||||
|
||||
return {
|
||||
provider: 'tmdb',
|
||||
providerId,
|
||||
metadataKind: seasonNumber ? 'season' : 'series',
|
||||
tmdbId: Number(series?.id || 0) || null,
|
||||
title: String(series?.title || '').trim() || null,
|
||||
originalTitle: String(series?.originalTitle || '').trim() || null,
|
||||
year: Number(series?.year || 0) || null,
|
||||
overview: String(series?.overview || '').trim() || null,
|
||||
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
|
||||
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
|
||||
seasonNumber,
|
||||
seasonName: String(season?.name || '').trim() || null,
|
||||
seasonOverview: String(season?.overview || '').trim() || null,
|
||||
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
|
||||
episodeCount: Number(season?.episodeCount || 0) || 0,
|
||||
episodes: Array.isArray(season?.episodes) ? season.episodes : []
|
||||
};
|
||||
}
|
||||
|
||||
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
|
||||
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
|
||||
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
|
||||
return seasons
|
||||
.map((season) => ({
|
||||
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
|
||||
name: String(season?.name || '').trim() || null,
|
||||
overview: String(season?.overview || '').trim() || null,
|
||||
posterPath: String(season?.poster_path || '').trim() || null,
|
||||
poster: this.buildImageUrl(season?.poster_path, 'w342'),
|
||||
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
|
||||
episodes: []
|
||||
}))
|
||||
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeasons(query, options = {}) {
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
|
||||
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const details = await this.getSeriesDetails(candidate.id, options);
|
||||
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
|
||||
if (seasons.length === 0) {
|
||||
return [this.buildSeriesMetadataCandidate(candidate)];
|
||||
}
|
||||
return seasons.map((season) => this.buildSeriesMetadataCandidate({
|
||||
...candidate,
|
||||
season
|
||||
}, {
|
||||
seasonNumber: season.seasonNumber
|
||||
}));
|
||||
}));
|
||||
|
||||
return expanded.flat().filter((item) => item?.tmdbId && item?.title);
|
||||
}
|
||||
|
||||
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
|
||||
const normalizedSeason = Number(seasonNumber);
|
||||
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = await this.searchSeries(query, options);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
|
||||
const selectedCandidates = candidates.slice(0, limit);
|
||||
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
|
||||
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
|
||||
if (!seasonDetails) {
|
||||
return {
|
||||
...candidate,
|
||||
season: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
...candidate,
|
||||
season: this.buildSeasonSummary(seasonDetails)
|
||||
};
|
||||
}));
|
||||
|
||||
return withSeasons
|
||||
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
|
||||
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
|
||||
seasonNumber: normalizedSeason
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TmdbService();
|
||||
@@ -45,13 +45,44 @@ function sanitizeFileNameWithExtension(input) {
|
||||
|
||||
function renderTemplate(template, values) {
|
||||
const rawSource = String(template || '${title} (${year})');
|
||||
const parseToken = (rawToken) => {
|
||||
const token = String(rawToken || '').trim();
|
||||
if (!token) {
|
||||
return { format: '', key: '' };
|
||||
}
|
||||
const separatorIndex = token.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return { format: '', key: token };
|
||||
}
|
||||
return {
|
||||
format: token.slice(0, separatorIndex).trim(),
|
||||
key: token.slice(separatorIndex + 1).trim()
|
||||
};
|
||||
};
|
||||
const applyFormat = (value, format) => {
|
||||
const normalizedFormat = String(format || '').trim().toLowerCase();
|
||||
if (!normalizedFormat) {
|
||||
return String(value);
|
||||
}
|
||||
// {0:key} => number with leading zero (width 2), e.g. 2 -> 02
|
||||
if (normalizedFormat === '0') {
|
||||
const raw = String(value);
|
||||
const match = raw.match(/^(-?)(\d+)$/);
|
||||
if (match) {
|
||||
const sign = match[1] || '';
|
||||
const digits = String(match[2] || '');
|
||||
return `${sign}${digits.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
const resolveToken = (rawKey) => {
|
||||
const key = String(rawKey || '').trim();
|
||||
const { format, key } = parseToken(rawKey);
|
||||
const val = values[key];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
return String(val);
|
||||
return applyFormat(val, format);
|
||||
};
|
||||
|
||||
const source = (
|
||||
|
||||
@@ -208,6 +208,51 @@ CREATE TABLE converter_scan_entries (
|
||||
CREATE INDEX idx_converter_scan_entries_rel_path ON converter_scan_entries(rel_path);
|
||||
CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id);
|
||||
|
||||
CREATE TABLE dvd_series_disc_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
provider_series_name TEXT,
|
||||
season_number INTEGER,
|
||||
season_type TEXT NOT NULL DEFAULT 'dvd',
|
||||
disc_label TEXT,
|
||||
disc_serial TEXT,
|
||||
disc_number INTEGER,
|
||||
language TEXT,
|
||||
disc_signature TEXT NOT NULL,
|
||||
fingerprint_json TEXT,
|
||||
episode_map_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature);
|
||||
CREATE INDEX idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number);
|
||||
|
||||
CREATE TABLE dvd_series_title_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
parent_job_id INTEGER NOT NULL,
|
||||
disc_profile_id INTEGER,
|
||||
title_index INTEGER NOT NULL,
|
||||
title_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
selected INTEGER NOT NULL DEFAULT 1,
|
||||
provider TEXT NOT NULL DEFAULT 'tmdb',
|
||||
provider_series_id TEXT,
|
||||
season_number INTEGER,
|
||||
episode_start INTEGER,
|
||||
episode_end INTEGER,
|
||||
episode_ids_json TEXT,
|
||||
mapping_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index);
|
||||
CREATE INDEX idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number);
|
||||
|
||||
-- =============================================================================
|
||||
-- Default Settings Seed
|
||||
-- =============================================================================
|
||||
@@ -221,6 +266,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('raw_dir_dvd_owner', 'Pfade', 'Eigentümer Raw-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1025);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_bluray_owner', 'Pfade', 'Eigentümer Film-Ordner (Blu-ray)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1115);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray_owner', NULL);
|
||||
@@ -229,6 +278,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('movie_dir_dvd_owner', 'Pfade', 'Eigentümer Film-Ordner (DVD)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1125);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd_owner', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL);
|
||||
|
||||
-- Laufwerk
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10);
|
||||
@@ -259,6 +312,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('raw_dir_dvd', 'Pfade', 'Raw-Ordner (DVD)', 'path', 0, 'RAW-Zielpfad für DVD. Leer = Standardpfad (data/output/raw).', NULL, '[]', '{}', 102);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('movie_dir_bluray', 'Pfade', 'Film-Ordner (Blu-ray)', 'path', 0, 'Encode-Zielpfad für Blu-ray. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 111);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_bluray', NULL);
|
||||
@@ -267,6 +324,10 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('movie_dir_dvd', 'Pfade', 'Film-Ordner (DVD)', 'path', 0, 'Encode-Zielpfad für DVD. Leer = Standardpfad (data/output/movies).', NULL, '[]', '{}', 112);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('log_dir', 'Pfade', 'Log Ordner', 'path', 1, 'Basisordner für Logs. Job-Logs liegen direkt hier, Backend-Logs in /backend.', 'data/logs', '[]', '{"minLength":1}', 120);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('log_dir', 'data/logs');
|
||||
@@ -284,6 +345,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('hardware_monitoring_interval_ms', 'Monitoring', 'Hardware Monitoring Intervall (ms)', 'number', 1, 'Polling-Intervall für CPU/RAM/GPU/Storage-Metriken.', '5000', '[]', '{"min":1000,"max":60000}', 140);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('hardware_monitoring_interval_ms', '5000');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_plugin_enabled', 'Features', 'DVD Serien-Plugin aktiviert', 'boolean', 1, 'Aktiviert die additive Serienerkennung für DVDs. Wenn deaktiviert, bleibt der bestehende DVD-Film-Flow unverändert.', 'false', '[]', '{}', 145);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_plugin_enabled', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||
VALUES ('dvd_series_prescan_enabled', 'Features', 'DVD HandBrake-Prescan aktiviert', 'boolean', 1, 'Führt nach der DVD-Erkennung einen zusätzlichen HandBrake-Prescan für die Serienheuristik aus.', 'true', '[]', '{}', 146, 'dvd_series_plugin_enabled');
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_prescan_enabled', 'true');
|
||||
|
||||
-- Tools
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200);
|
||||
@@ -402,6 +471,14 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'Template für Ordner und Dateiname. Platzhalter: ${title}, ${year}, ${imdbId}. Unterordner über "/" möglich – alles nach dem letzten "/" ist der Dateiname. Die Endung wird über das gewählte Ausgabeformat gesetzt.', '${title} (${year})/${title} (${year})', '[]', '{"minLength":1}', 535);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}');
|
||||
|
||||
-- Tools – CD
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('cdparanoia_command', 'Tools', 'cdparanoia Kommando', 'string', 1, 'Pfad oder Befehl für cdparanoia. Wird als Fallback genutzt wenn kein individuelles Kommando gesetzt ist.', 'cdparanoia', '[]', '{"minLength":1}', 230);
|
||||
@@ -479,6 +556,26 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
||||
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_api_key', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL);
|
||||
|
||||
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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE');
|
||||
|
||||
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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US');
|
||||
|
||||
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);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -363,6 +363,12 @@ export const api = {
|
||||
getDetectedDrives() {
|
||||
return request('/settings/drives');
|
||||
},
|
||||
forceUnlockDrive(payload = {}) {
|
||||
return request('/settings/drives/force-unlock', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
},
|
||||
async getHandBrakePresets(options = {}) {
|
||||
const result = await requestCachedGet('/settings/handbrake-presets', {
|
||||
ttlMs: 10 * 60 * 1000,
|
||||
@@ -547,6 +553,14 @@ export const api = {
|
||||
searchOmdb(q) {
|
||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
searchTmdbSeries(q, seasonNumber = null) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', String(q || ''));
|
||||
if (seasonNumber !== undefined && seasonNumber !== null && String(seasonNumber).trim() !== '') {
|
||||
params.set('season', String(seasonNumber).trim());
|
||||
}
|
||||
return request(`/pipeline/tmdb/series/search?${params.toString()}`);
|
||||
},
|
||||
searchMusicBrainz(q) {
|
||||
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
||||
},
|
||||
@@ -675,6 +689,7 @@ export const api = {
|
||||
const body = {};
|
||||
if (options.keepBoth) body.keepBoth = true;
|
||||
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
||||
if (options.restartMode) body.restartMode = options.restartMode;
|
||||
const result = await request(`/pipeline/restart-encode/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
||||
@@ -743,6 +758,9 @@ export const api = {
|
||||
if (params.lite) {
|
||||
query.set('lite', '1');
|
||||
}
|
||||
if (params.includeChildren) {
|
||||
query.set('includeChildren', '1');
|
||||
}
|
||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||
return request(`/history${suffix}`);
|
||||
},
|
||||
@@ -773,6 +791,13 @@ export const api = {
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async acknowledgeJobError(jobId) {
|
||||
const result = await request(`/history/${jobId}/error/ack`, {
|
||||
method: 'POST'
|
||||
});
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async deleteJobFiles(jobId, target = 'both') {
|
||||
const result = await request(`/history/${jobId}/delete-files`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -293,9 +293,10 @@ function ExternalStoragePathsEditor({ value, onChange, settingKey }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DetectedDrivesInfo() {
|
||||
function DetectedDrivesInfo({ expertModeEnabled = false, onNotify = null }) {
|
||||
const [drives, setDrives] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
@@ -307,21 +308,70 @@ function DetectedDrivesInfo() {
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const notify = (severity, summary, detail) => {
|
||||
if (typeof onNotify === 'function') {
|
||||
onNotify({ severity, summary, detail });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockOne = async (devicePath) => {
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await api.forceUnlockDrive({ devicePath });
|
||||
notify('success', 'Laufwerk freigegeben', `${devicePath} wurde entsperrt.`);
|
||||
load();
|
||||
} catch (error) {
|
||||
notify('error', 'Freigabe fehlgeschlagen', error?.message || 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockAll = async () => {
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await api.forceUnlockDrive({ all: true });
|
||||
notify('success', 'Laufwerke freigegeben', 'Alle erkannten Laufwerke wurden entsperrt.');
|
||||
load();
|
||||
} catch (error) {
|
||||
notify('error', 'Freigabe fehlgeschlagen', error?.message || 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="detected-drives-info">
|
||||
<div className="detected-drives-header">
|
||||
<span className="detected-drives-label">Erkannte optische Laufwerke</span>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={load}
|
||||
type="button"
|
||||
aria-label="Laufwerke neu einlesen"
|
||||
title="Laufwerke neu einlesen"
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
||||
{expertModeEnabled ? (
|
||||
<Button
|
||||
icon="pi pi-unlock"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
loading={unlocking}
|
||||
disabled={loading}
|
||||
onClick={handleUnlockAll}
|
||||
type="button"
|
||||
aria-label="Alle Laufwerke freigeben"
|
||||
title="Alle Laufwerke freigeben"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={unlocking}
|
||||
onClick={load}
|
||||
type="button"
|
||||
aria-label="Laufwerke neu einlesen"
|
||||
title="Laufwerke neu einlesen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{drives === null || loading
|
||||
? <span className="detected-drives-empty">Wird geladen…</span>
|
||||
@@ -334,6 +384,7 @@ function DetectedDrivesInfo() {
|
||||
<th>Gerät</th>
|
||||
<th>Modell</th>
|
||||
<th>MakeMKV Index</th>
|
||||
{expertModeEnabled ? <th>Freigabe</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -342,6 +393,19 @@ function DetectedDrivesInfo() {
|
||||
<td><code>{d.path}</code></td>
|
||||
<td>{d.model || '—'}</td>
|
||||
<td><Tag value={d.discArg ?? '—'} severity="info" /></td>
|
||||
{expertModeEnabled ? (
|
||||
<td>
|
||||
<Button
|
||||
label="Freigeben"
|
||||
icon="pi pi-unlock"
|
||||
size="small"
|
||||
text
|
||||
onClick={() => handleUnlockOne(d.path)}
|
||||
loading={unlocking}
|
||||
disabled={loading}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -438,7 +502,15 @@ function buildToolSections(settings) {
|
||||
|
||||
// Path keys per medium — _owner keys are rendered inline
|
||||
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||
const DVD_PATH_KEYS = [
|
||||
'raw_dir_dvd',
|
||||
'raw_dir_dvd_series',
|
||||
'movie_dir_dvd',
|
||||
'series_dir_dvd',
|
||||
'output_template_dvd',
|
||||
'output_template_dvd_series_episode',
|
||||
'output_template_dvd_series_multi_episode'
|
||||
];
|
||||
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
||||
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
|
||||
const DOWNLOAD_PATH_KEYS = ['download_dir'];
|
||||
@@ -732,6 +804,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
|
||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
||||
const defaultSeries = effectivePaths?.defaults?.series || 'data/output/series';
|
||||
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
||||
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
||||
@@ -745,7 +818,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
||||
const dvdSeriesRaw = ep.dvd?.seriesRaw || dvdRaw;
|
||||
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||
const dvdSeries = ep.dvd?.series || defaultSeries;
|
||||
const cdRaw = ep.cd?.raw || defaultCd;
|
||||
const cdMovies = ep.cd?.movies || cdRaw;
|
||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||
@@ -796,6 +871,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
{isDefault(dvdMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>DVD Serie</strong></td>
|
||||
<td>
|
||||
<code>{dvdSeriesRaw}</code>
|
||||
{isDefault(dvdSeriesRaw, dvdRaw) && <span className="path-default-badge">wie DVD RAW</span>}
|
||||
</td>
|
||||
<td>
|
||||
<code>{dvdSeries}</code>
|
||||
{isDefault(dvdSeries, defaultSeries) && <span className="path-default-badge">Standard</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>CD / Audio</strong></td>
|
||||
<td>
|
||||
@@ -915,7 +1001,8 @@ export default function DynamicSettingsForm({
|
||||
errors,
|
||||
dirtyKeys,
|
||||
onChange,
|
||||
effectivePaths
|
||||
effectivePaths,
|
||||
onNotify
|
||||
}) {
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||
@@ -1009,7 +1096,7 @@ export default function DynamicSettingsForm({
|
||||
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
|
||||
return (
|
||||
<div className="settings-sections">
|
||||
{isDriveCategory && <DetectedDrivesInfo />}
|
||||
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={`${category?.category || 'category'}-${section.id}`}
|
||||
|
||||
@@ -6,6 +6,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
|
||||
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
|
||||
|
||||
const CD_FORMAT_LABELS = {
|
||||
flac: 'FLAC',
|
||||
@@ -190,6 +191,98 @@ function buildExecutedHandBrakeCommand(handbrakeInfo) {
|
||||
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
|
||||
}
|
||||
|
||||
function normalizeSeriesEpisodeStatus(status) {
|
||||
const normalized = String(status || '').trim().toUpperCase();
|
||||
if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return 'QUEUED';
|
||||
}
|
||||
|
||||
function seriesEpisodeStatusMeta(status) {
|
||||
const normalized = normalizeSeriesEpisodeStatus(status);
|
||||
if (normalized === 'FINISHED') {
|
||||
return { label: 'Fertig', severity: 'success' };
|
||||
}
|
||||
if (normalized === 'RUNNING') {
|
||||
return { label: 'Läuft', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'ERROR') {
|
||||
return { label: 'Fehler', severity: 'danger' };
|
||||
}
|
||||
if (normalized === 'CANCELLED') {
|
||||
return { label: 'Abgebrochen', severity: 'warning' };
|
||||
}
|
||||
return { label: 'Wartend', severity: 'secondary' };
|
||||
}
|
||||
|
||||
function formatDateTimeOrDash(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) {
|
||||
return '-';
|
||||
}
|
||||
const parsed = new Date(text);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return text;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function mergeSeriesBatchEpisodes(job) {
|
||||
const planEpisodes = Array.isArray(job?.encodePlan?.seriesBatchEpisodes)
|
||||
? job.encodePlan.seriesBatchEpisodes
|
||||
: [];
|
||||
const hbEpisodes = Array.isArray(job?.handbrakeInfo?.seriesBatch?.episodes)
|
||||
? job.handbrakeInfo.seriesBatch.episodes
|
||||
: [];
|
||||
const map = new Map();
|
||||
|
||||
const append = (list, sourcePriority) => {
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const episodeIndex = normalizePositiveInteger(item?.episodeIndex);
|
||||
const titleId = normalizePositiveInteger(item?.titleId);
|
||||
const key = `${episodeIndex || 0}:${titleId || 0}`;
|
||||
const existing = map.get(key);
|
||||
if (!existing) {
|
||||
map.set(key, {
|
||||
...item,
|
||||
episodeIndex,
|
||||
titleId,
|
||||
sourcePriority
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (sourcePriority >= Number(existing.sourcePriority || 0)) {
|
||||
map.set(key, {
|
||||
...existing,
|
||||
...item,
|
||||
episodeIndex: episodeIndex || existing.episodeIndex || null,
|
||||
titleId: titleId || existing.titleId || null,
|
||||
sourcePriority
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
append(planEpisodes, 1);
|
||||
append(hbEpisodes, 2);
|
||||
|
||||
return Array.from(map.values())
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1),
|
||||
titleId: normalizePositiveInteger(entry?.titleId) || null,
|
||||
progress: Number.isFinite(Number(entry?.progress))
|
||||
? Math.max(0, Math.min(100, Number(entry.progress)))
|
||||
: 0,
|
||||
status: normalizeSeriesEpisodeStatus(entry?.status)
|
||||
}))
|
||||
.sort((left, right) => left.episodeIndex - right.episodeIndex);
|
||||
}
|
||||
|
||||
function buildConfiguredScriptAndChainSelection(job) {
|
||||
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
|
||||
@@ -505,6 +598,147 @@ function omdbRottenTomatoesScore(omdbInfo) {
|
||||
return omdbField(entry?.Value);
|
||||
}
|
||||
|
||||
function normalizeMetadataProvider(value) {
|
||||
return String(value || '').trim().toLowerCase() || 'omdb';
|
||||
}
|
||||
|
||||
function resolveJobMetadataContext(job) {
|
||||
const mkInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object'
|
||||
? job.makemkvInfo
|
||||
: {};
|
||||
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
|
||||
? mkInfo.analyzeContext
|
||||
: {};
|
||||
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: {};
|
||||
const selectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
|
||||
? mkInfo.selectedMetadata
|
||||
: {};
|
||||
const encodeMetadata = job?.encodePlan?.metadata && typeof job.encodePlan.metadata === 'object'
|
||||
? job.encodePlan.metadata
|
||||
: {};
|
||||
const mergedSelectedMetadata = {
|
||||
...analyzeSelectedMetadata,
|
||||
...selectedMetadata,
|
||||
...encodeMetadata
|
||||
};
|
||||
const metadataProvider = normalizeMetadataProvider(
|
||||
mergedSelectedMetadata?.metadataProvider
|
||||
|| analyzeContext?.metadataProvider
|
||||
|| 'omdb'
|
||||
);
|
||||
return {
|
||||
analyzeContext,
|
||||
selectedMetadata: mergedSelectedMetadata,
|
||||
metadataProvider
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueNameList(values, maxItems = 10) {
|
||||
const source = Array.isArray(values) ? values : [];
|
||||
const limit = Math.max(1, Number(maxItems || 10));
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of source) {
|
||||
const name = String(item?.name || item || '').trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(name);
|
||||
if (output.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatRuntimeLabel(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((entry) => Number(entry))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0)
|
||||
.map((entry) => Math.trunc(entry));
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
return min === max ? `${min} min` : `${min}-${max} min`;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return `${Math.trunc(numeric)} min`;
|
||||
}
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
|
||||
const metadataContext = resolveJobMetadataContext(job);
|
||||
const provider = metadataContext.metadataProvider;
|
||||
const selectedMetadata = metadataContext.selectedMetadata;
|
||||
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
|
||||
? selectedMetadata.tmdbDetails
|
||||
: {};
|
||||
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
|
||||
|
||||
if (isTmdbProvider) {
|
||||
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
|
||||
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
|
||||
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
|
||||
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
|
||||
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
|
||||
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
|
||||
? ratingNumber.toFixed(1)
|
||||
: 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 {
|
||||
provider: 'omdb',
|
||||
title: 'OMDb Details',
|
||||
matchLabel: 'OMDb Match',
|
||||
hasMatch: Boolean(job?.selected_from_omdb),
|
||||
imdbId: String(job?.imdb_id || '').trim() || null,
|
||||
director: omdbField(omdbInfo?.Director),
|
||||
actors: omdbField(omdbInfo?.Actors),
|
||||
runtime: omdbField(omdbInfo?.Runtime),
|
||||
genre: omdbField(omdbInfo?.Genre),
|
||||
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
|
||||
imdbRating: omdbField(omdbInfo?.imdbRating)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSeriesDiscNumber(job) {
|
||||
const metadataContext = resolveJobMetadataContext(job);
|
||||
return normalizePositiveInteger(
|
||||
metadataContext?.selectedMetadata?.discNumber
|
||||
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
|
||||
?? job?.encodePlan?.discNumber
|
||||
?? null
|
||||
);
|
||||
}
|
||||
|
||||
function BoolState({ value }) {
|
||||
const isTrue = Boolean(value);
|
||||
return isTrue ? (
|
||||
@@ -518,6 +752,33 @@ function BoolState({ value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TriState({ existing = 0, expected = 0, labels = {} }) {
|
||||
const safeExpected = Number.isFinite(Number(expected)) ? Number(expected) : 0;
|
||||
const safeExisting = Number.isFinite(Number(existing)) ? Number(existing) : 0;
|
||||
if (safeExpected <= 0) {
|
||||
return <BoolState value={safeExisting > 0} />;
|
||||
}
|
||||
if (safeExisting <= 0) {
|
||||
return (
|
||||
<span className="job-step-inline-no" title={labels.none || `Nein (${safeExisting}/${safeExpected})`}>
|
||||
<i className="pi pi-times-circle" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (safeExisting < safeExpected) {
|
||||
return (
|
||||
<span className="job-step-inline-warn" title={labels.partial || `Teilweise (${safeExisting}/${safeExpected})`}>
|
||||
<i className="pi pi-exclamation-circle" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="job-step-inline-ok" title={labels.full || `Ja (${safeExisting}/${safeExpected})`}>
|
||||
<i className="pi pi-check-circle" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectionStateMeta({ selected, outcome }) {
|
||||
if (!selected) {
|
||||
return {
|
||||
@@ -616,6 +877,7 @@ export default function JobDetailDialog({
|
||||
logLoadingMode = null,
|
||||
onAssignOmdb,
|
||||
onAssignCdMetadata,
|
||||
onAcknowledgeError,
|
||||
onResumeReady,
|
||||
onRestartEncode,
|
||||
onRestartReview,
|
||||
@@ -631,6 +893,7 @@ export default function JobDetailDialog({
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
cdMetadataAssignBusy = false,
|
||||
acknowledgeErrorBusy = false,
|
||||
actionBusy = false,
|
||||
cancelBusy = false,
|
||||
reencodeBusy = false,
|
||||
@@ -639,16 +902,39 @@ export default function JobDetailDialog({
|
||||
downloadFolderBusyPath = null
|
||||
}) {
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
// RAW-Import aus Datenbank, der noch nie encodiert wurde → nur "Neu einlesen" erlaubt
|
||||
const isUnencodedOrphanImport = Boolean(
|
||||
job?.makemkvInfo?.source === 'orphan_raw_import' && !job?.handbrakeInfo
|
||||
);
|
||||
const statusUpper = String(job?.status || '').trim().toUpperCase();
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
|
||||
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
|
||||
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
|
||||
const showFinalLog = !running;
|
||||
const mediaType = resolveMediaType(job);
|
||||
const isDvdSeries = mediaType === 'dvd' && isSeriesDvdJob(job);
|
||||
const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : [];
|
||||
const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object'
|
||||
? job.encodePlan.episodeAssignments
|
||||
: {};
|
||||
const seriesBatchSummary = seriesBatchEpisodes.reduce((acc, episode) => {
|
||||
const status = normalizeSeriesEpisodeStatus(episode?.status);
|
||||
if (status === 'FINISHED') {
|
||||
acc.finished += 1;
|
||||
} else if (status === 'ERROR') {
|
||||
acc.error += 1;
|
||||
} else if (status === 'CANCELLED') {
|
||||
acc.cancelled += 1;
|
||||
} else if (status === 'RUNNING') {
|
||||
acc.running += 1;
|
||||
} else {
|
||||
acc.queued += 1;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
total: seriesBatchEpisodes.length,
|
||||
finished: 0,
|
||||
error: 0,
|
||||
cancelled: 0,
|
||||
running: 0,
|
||||
queued: 0
|
||||
});
|
||||
const isCd = mediaType === 'cd';
|
||||
const isAudiobook = mediaType === 'audiobook';
|
||||
const isConverter = mediaType === 'converter';
|
||||
@@ -811,7 +1097,7 @@ export default function JobDetailDialog({
|
||||
const mediaTypeLabel = mediaType === 'bluray'
|
||||
? 'Blu-ray'
|
||||
: mediaType === 'dvd'
|
||||
? 'DVD'
|
||||
? (isDvdSeries ? 'DVD Serie' : 'DVD')
|
||||
: isCd
|
||||
? 'Audio CD'
|
||||
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
|
||||
@@ -823,6 +1109,27 @@ export default function JobDetailDialog({
|
||||
const mediaTypeAlt = mediaTypeLabel;
|
||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
const metadataContext = resolveJobMetadataContext(job);
|
||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||
const childJobs = Array.isArray(job?.children) ? job.children : [];
|
||||
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
||||
const seriesChildSummary = job?.seriesChildSummary || null;
|
||||
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
|
||||
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null;
|
||||
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
|
||||
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
|
||||
const metadataJsonValue = metadataDetails.provider === 'tmdb'
|
||||
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
|
||||
: job?.omdbInfo;
|
||||
const metadataAssignProvider = isDvdSeries
|
||||
? 'tmdb'
|
||||
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
|
||||
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
|
||||
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
|
||||
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
|
||||
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
|
||||
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
||||
|| configuredSelection.postScriptIds.length > 0
|
||||
@@ -832,8 +1139,13 @@ export default function JobDetailDialog({
|
||||
? job.encodePlan.userPreset
|
||||
: null;
|
||||
const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? null;
|
||||
const encodePlanTitles = Array.isArray(job?.encodePlan?.titles) ? job.encodePlan.titles : [];
|
||||
const selectedEncodeTitles = encodePlanTitles.filter(
|
||||
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
|
||||
);
|
||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
|
||||
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
||||
@@ -979,12 +1291,24 @@ export default function JobDetailDialog({
|
||||
) : (
|
||||
<>
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
|
||||
<h4>{isAudiobook ? 'Audiobook-Infos' : (isDvdSeries ? 'Serieninfos' : 'Film-Infos')}</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Titel:</strong>
|
||||
<span>{job.title || job.detected_title || '-'}</span>
|
||||
</div>
|
||||
{isDvdSeries ? (
|
||||
<div className="job-meta-item">
|
||||
<strong>Staffel:</strong>
|
||||
<span>{metadataContext?.selectedMetadata?.seasonNumber ?? '-'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{isDvdSeries ? (
|
||||
<div className="job-meta-item">
|
||||
<strong>{isSeriesContainer ? 'Container' : 'Disk'}:</strong>
|
||||
<span>{isSeriesContainer ? 'Ja' : (seriesDiscNumber ? `Disk ${seriesDiscNumber}` : '-')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="job-meta-item">
|
||||
<strong>Jahr:</strong>
|
||||
<span>{job.year || '-'}</span>
|
||||
@@ -1024,11 +1348,17 @@ export default function JobDetailDialog({
|
||||
<>
|
||||
<div className="job-meta-item">
|
||||
<strong>IMDb:</strong>
|
||||
<span>{job.imdb_id || '-'}</span>
|
||||
<span>{displayedImdbId || '-'}</span>
|
||||
</div>
|
||||
{isDvdSeries ? (
|
||||
<div className="job-meta-item">
|
||||
<strong>TMDb:</strong>
|
||||
<span>{metadataContext?.selectedMetadata?.tmdbId || '-'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="job-meta-item">
|
||||
<strong>OMDb Match:</strong>
|
||||
<BoolState value={job.selected_from_omdb} />
|
||||
<strong>{metadataDetails.matchLabel}:</strong>
|
||||
<BoolState value={metadataDetails.hasMatch} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1044,32 +1374,41 @@ export default function JobDetailDialog({
|
||||
|
||||
{!isAudiobook ? (
|
||||
<section className="job-meta-block job-meta-block-film">
|
||||
<h4>OMDb Details</h4>
|
||||
<h4>{metadataDetails.title}</h4>
|
||||
<div className="job-meta-list">
|
||||
<div className="job-meta-item">
|
||||
<strong>Regisseur:</strong>
|
||||
<span>{omdbField(omdbInfo?.Director)}</span>
|
||||
<span>{omdbField(metadataDetails.director)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Schauspieler:</strong>
|
||||
<span>{omdbField(omdbInfo?.Actors)}</span>
|
||||
<span>{omdbField(metadataDetails.actors)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Laufzeit:</strong>
|
||||
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
||||
<span>{omdbField(metadataDetails.runtime)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Genre:</strong>
|
||||
<span>{omdbField(omdbInfo?.Genre)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>Rotten Tomatoes:</strong>
|
||||
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
||||
</div>
|
||||
<div className="job-meta-item">
|
||||
<strong>imdbRating:</strong>
|
||||
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
||||
<span>{omdbField(metadataDetails.genre)}</span>
|
||||
</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>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -1101,9 +1440,17 @@ export default function JobDetailDialog({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
|
||||
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> {isSeriesContainer && seriesBackupSummary ? (
|
||||
<TriState existing={seriesBackupSummary.existing} expected={seriesBackupSummary.expected} />
|
||||
) : (
|
||||
<BoolState value={job?.backupSuccess} />
|
||||
)}</span>
|
||||
<span className="job-infos-sep">|</span>
|
||||
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></span>
|
||||
<span><strong>Encode:</strong> {isSeriesContainer && seriesEncodeSummary ? (
|
||||
<TriState existing={seriesEncodeSummary.existing} expected={seriesEncodeSummary.expected} />
|
||||
) : (
|
||||
<BoolState value={job?.encodeSuccess} />
|
||||
)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1115,11 +1462,29 @@ export default function JobDetailDialog({
|
||||
{/* Zeile 3+4: Pfade */}
|
||||
<PathField
|
||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
|
||||
value={isSeriesContainer ? null : (isCd ? (job.raw_path || job.output_path) : resolvedRawPath)}
|
||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
{isSeriesContainer && childJobs.length > 0 ? (
|
||||
childJobs.map((child) => {
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
|
||||
const childRawPath = child?.raw_path || child?.output_path || null;
|
||||
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
return (
|
||||
<PathField
|
||||
key={`child-raw-${child.id}`}
|
||||
label={childLabel}
|
||||
value={childRawPath}
|
||||
onDownload={canDownloadChildRaw ? () => onDownloadArchive?.(child, 'raw') : null}
|
||||
downloadDisabled={!canDownloadChildRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : null}
|
||||
{outputFolders.length > 0 ? (
|
||||
outputFolders.map((folder, idx) => {
|
||||
const folderPath = String(folder?.output_path || '').trim();
|
||||
@@ -1149,7 +1514,23 @@ export default function JobDetailDialog({
|
||||
/>
|
||||
)}
|
||||
{job.error_message ? (
|
||||
<div><strong>Fehler:</strong> {job.error_message}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span><strong>Fehler:</strong> {job.error_message}</span>
|
||||
{typeof onAcknowledgeError === 'function' ? (
|
||||
<Button
|
||||
icon="pi pi-check-circle"
|
||||
rounded
|
||||
text
|
||||
size="small"
|
||||
severity="success"
|
||||
title="Fehler quittieren"
|
||||
aria-label="Fehler quittieren"
|
||||
onClick={() => onAcknowledgeError(job)}
|
||||
loading={acknowledgeErrorBusy}
|
||||
disabled={acknowledgeErrorBusy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1219,6 +1600,119 @@ export default function JobDetailDialog({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isDvdSeries && seriesBatchEpisodes.length > 0 ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Serien-Episoden</h4>
|
||||
<div className="series-batch-progress-wrap">
|
||||
<div className="series-batch-head">
|
||||
<div>
|
||||
<strong>Gesamt:</strong>{' '}
|
||||
{`${seriesBatchSummary.finished}/${seriesBatchSummary.total} fertig`}
|
||||
{seriesBatchSummary.running > 0 ? ` | laufend: ${seriesBatchSummary.running}` : ''}
|
||||
{seriesBatchSummary.queued > 0 ? ` | wartend: ${seriesBatchSummary.queued}` : ''}
|
||||
{seriesBatchSummary.error > 0 ? ` | fehler: ${seriesBatchSummary.error}` : ''}
|
||||
{seriesBatchSummary.cancelled > 0 ? ` | abgebrochen: ${seriesBatchSummary.cancelled}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="series-batch-episodes">
|
||||
{seriesBatchEpisodes.map((episode) => {
|
||||
const assignment = seriesEpisodeAssignments[String(episode?.titleId)]
|
||||
|| seriesEpisodeAssignments[Number(episode?.titleId)]
|
||||
|| null;
|
||||
const statusMeta = seriesEpisodeStatusMeta(episode?.status);
|
||||
const episodeLabel = String(
|
||||
episode?.label
|
||||
|| assignment?.episodeTitle
|
||||
|| `Episode ${episode?.episodeIndex || '?'}`
|
||||
).trim();
|
||||
const episodeCode = (() => {
|
||||
const seasonNo = normalizePositiveInteger(assignment?.seasonNumber ?? episode?.seasonNumber);
|
||||
const episodeNoRaw = Number(assignment?.episodeNumber ?? episode?.episodeNumber);
|
||||
const episodeNo = Number.isFinite(episodeNoRaw) && episodeNoRaw > 0
|
||||
? episodeNoRaw
|
||||
: null;
|
||||
if (!seasonNo && !episodeNo) {
|
||||
return null;
|
||||
}
|
||||
const seasonToken = seasonNo ? String(seasonNo).padStart(2, '0') : '--';
|
||||
const episodeToken = episodeNo
|
||||
? (
|
||||
Number.isInteger(episodeNo)
|
||||
? String(Math.trunc(episodeNo)).padStart(2, '0')
|
||||
: String(episodeNo)
|
||||
)
|
||||
: '--';
|
||||
return `S${seasonToken}E${episodeToken}`;
|
||||
})();
|
||||
const trackSelection = episode?.trackSelection && typeof episode.trackSelection === 'object'
|
||||
? episode.trackSelection
|
||||
: null;
|
||||
const audioTrackIds = Array.isArray(trackSelection?.audioTrackIds) ? trackSelection.audioTrackIds : [];
|
||||
const subtitleTrackIds = Array.isArray(trackSelection?.subtitleTrackIds) ? trackSelection.subtitleTrackIds : [];
|
||||
const subtitleForcedIndexes = Array.isArray(trackSelection?.subtitleForcedTrackIndexes)
|
||||
? trackSelection.subtitleForcedTrackIndexes
|
||||
: [];
|
||||
const episodeCommand = buildExecutedHandBrakeCommand(episode?.handbrakeInfo);
|
||||
const boundedProgress = Number.isFinite(Number(episode?.progress))
|
||||
? Math.max(0, Math.min(100, Number(episode.progress)))
|
||||
: 0;
|
||||
return (
|
||||
<details key={`series-episode-${episode.episodeIndex}-${episode.titleId || 'na'}`} className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">
|
||||
{`#${episode?.episodeIndex || '?'} | ${episodeLabel}${episodeCode ? ` | ${episodeCode}` : ''}${seriesDiscNumber ? ` | Disk ${seriesDiscNumber}` : ''}`}
|
||||
</span>
|
||||
<Tag value={statusMeta.label} severity={statusMeta.severity} />
|
||||
</summary>
|
||||
<div className="track-group">
|
||||
<div className="track-item">
|
||||
<span><strong>Titel-ID:</strong> {episode?.titleId || '-'}</span>
|
||||
</div>
|
||||
<div className="track-item">
|
||||
<span><strong>Fortschritt:</strong> {`${boundedProgress.toFixed(2)}%`}</span>
|
||||
</div>
|
||||
<div className="track-item">
|
||||
<span><strong>Gestartet:</strong> {formatDateTimeOrDash(episode?.startedAt)}</span>
|
||||
</div>
|
||||
<div className="track-item">
|
||||
<span><strong>Beendet:</strong> {formatDateTimeOrDash(episode?.finishedAt)}</span>
|
||||
</div>
|
||||
<div className="track-item">
|
||||
<span><strong>Audio-Spuren:</strong> {audioTrackIds.length > 0 ? audioTrackIds.join(', ') : '-'}</span>
|
||||
</div>
|
||||
<div className="track-item">
|
||||
<span><strong>Subtitle-Spuren:</strong> {subtitleTrackIds.length > 0 ? subtitleTrackIds.join(', ') : '-'}</span>
|
||||
</div>
|
||||
<div className="track-item">
|
||||
<span><strong>Subtitle Forced-Index:</strong> {subtitleForcedIndexes.length > 0 ? subtitleForcedIndexes.join(', ') : '-'}</span>
|
||||
</div>
|
||||
{episode?.outputPath ? (
|
||||
<div className="track-item">
|
||||
<span><strong>Output:</strong> {episode.outputPath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{episode?.error ? (
|
||||
<div className="track-item">
|
||||
<span><strong>Fehler:</strong> {episode.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{episodeCommand ? (
|
||||
<div className="track-item">
|
||||
<span><strong>Command:</strong> {episodeCommand}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="track-item">
|
||||
<small className="track-action-note">Hinweis: Alle Episoden-Logs liegen im Hauptjob-Log.</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Encode-Konfiguration</h4>
|
||||
@@ -1257,12 +1751,74 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
) : null}
|
||||
{/* Spurauswahl – read-only */}
|
||||
{job.encodePlan && Array.isArray(job.encodePlan.titles) && job.encodePlan.titles.length > 0 ? (
|
||||
<div className="spurauswahl-block">
|
||||
<div className="spurauswahl-label">Spurauswahl</div>
|
||||
{job.encodePlan.titles
|
||||
.filter((t) => t.selectedForEncode || t.encodeInput || String(t.id) === String(reviewSelectedEncodeTitleId))
|
||||
.map((title) => {
|
||||
{selectedEncodeTitles.length > 0 ? (
|
||||
isDvdSeries ? (
|
||||
<details className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
|
||||
</summary>
|
||||
<div className="spurauswahl-block">
|
||||
<div className="spurauswahl-label">Spurauswahl</div>
|
||||
{selectedEncodeTitles.map((title) => {
|
||||
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
return (
|
||||
<div key={title.id} className="track-title-block">
|
||||
<div className="track-title-row">
|
||||
<span>#{title.id} | {title.fileName} | {title.durationMinutes != null ? `${Number(title.durationMinutes).toFixed(2)} min` : '-'}</span>
|
||||
</div>
|
||||
<div className="track-groups-row">
|
||||
{audioTracks.length > 0 ? (
|
||||
<div className="track-group">
|
||||
<div className="track-group-label">Tonspuren (Titel #{title.id})</div>
|
||||
{audioTracks.map((track) => {
|
||||
const lang = trackLang(track.language || track.languageLabel);
|
||||
const codec = trackCodec('audio', track.format, track.description || track.title);
|
||||
const chLayout = trackChLayout(track.channels);
|
||||
let displayText = `#${track.id} | ${lang} | ${codec}`;
|
||||
if (chLayout) displayText += ` | ${chLayout}`;
|
||||
const actionInfo = track.selectedForEncode
|
||||
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
|
||||
: 'Nicht übernommen';
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<span>{displayText}</span>
|
||||
<small className="track-action-note">Encode: {actionInfo}</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{subtitleTracks.length > 0 ? (
|
||||
<div className="track-group">
|
||||
<div className="track-group-label">Subtitles (Titel #{title.id})</div>
|
||||
{subtitleTracks.map((track) => {
|
||||
const lang = trackLang(track.language || track.languageLabel);
|
||||
const codec = trackCodec('subtitle', track.format);
|
||||
const displayText = `#${track.id} | ${lang} | ${codec}`;
|
||||
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
|
||||
const actionInfo = track.selectedForEncode
|
||||
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
|
||||
: 'Nicht übernommen';
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<span>{displayText}</span>
|
||||
<small className="track-action-note">Encode: {actionInfo}</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="spurauswahl-block">
|
||||
<div className="spurauswahl-label">Spurauswahl</div>
|
||||
{selectedEncodeTitles.map((title) => {
|
||||
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
return (
|
||||
@@ -1315,9 +1871,9 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
@@ -1421,15 +1977,55 @@ export default function JobDetailDialog({
|
||||
|
||||
<section className="job-meta-block job-meta-block-full">
|
||||
<h4>Logs</h4>
|
||||
<div className="job-json-grid">
|
||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
||||
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
|
||||
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
|
||||
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
|
||||
</div>
|
||||
{isSeriesContainer && childJobs.length > 0 ? (
|
||||
<>
|
||||
<div className="job-json-grid">
|
||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
|
||||
</div>
|
||||
{childJobs.map((child) => {
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
|
||||
return (
|
||||
<details key={`child-log-${child.id}`} className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{childDiscLabel}</span>
|
||||
</summary>
|
||||
<div className="job-json-grid">
|
||||
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={child.makemkvInfo} />
|
||||
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={child.mediainfoInfo} /> : null}
|
||||
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={child.encodePlan} />
|
||||
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={child.handbrakeInfo} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : isDvdSeries ? (
|
||||
<details className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
|
||||
</summary>
|
||||
<div className="job-json-grid">
|
||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
|
||||
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
|
||||
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
|
||||
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="job-json-grid">
|
||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
|
||||
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
|
||||
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
|
||||
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
|
||||
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1461,7 +2057,24 @@ export default function JobDetailDialog({
|
||||
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
||||
</div>
|
||||
{logLoaded ? (
|
||||
<pre className="log-box">{job.log || ''}</pre>
|
||||
isSeriesContainer && childJobs.length > 0 ? (
|
||||
<div className="log-box">
|
||||
{childJobs.map((child) => {
|
||||
const childDiscNumber = resolveSeriesDiscNumber(child);
|
||||
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
|
||||
return (
|
||||
<details key={`child-log-text-${child.id}`} className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{childDiscLabel}</span>
|
||||
</summary>
|
||||
<pre>{child.log || ''}</pre>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="log-box">{job.log || ''}</pre>
|
||||
)
|
||||
) : (
|
||||
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||
)}
|
||||
@@ -1595,7 +2208,7 @@ export default function JobDetailDialog({
|
||||
size="small"
|
||||
onClick={() => onRestartReview?.(job)}
|
||||
loading={actionBusy}
|
||||
disabled={!canRestartReview || isUnencodedOrphanImport}
|
||||
disabled={!canRestartReview}
|
||||
/>
|
||||
<span className="action-desc">{isAudiobook
|
||||
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
|
||||
@@ -1610,9 +2223,9 @@ export default function JobDetailDialog({
|
||||
icon="pi pi-sync"
|
||||
severity="info"
|
||||
size="small"
|
||||
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
|
||||
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
|
||||
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
|
||||
onClick={() => onReencode?.(job)}
|
||||
loading={reencodeBusy}
|
||||
disabled={!canReencode}
|
||||
/>
|
||||
<span className="action-desc">{isAudiobook
|
||||
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
|
||||
@@ -1629,7 +2242,7 @@ export default function JobDetailDialog({
|
||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="OMDB neu zuweisen"
|
||||
label={metadataAssignButtonLabel}
|
||||
icon="pi pi-search"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@@ -1638,7 +2251,7 @@ export default function JobDetailDialog({
|
||||
loading={omdbAssignBusy}
|
||||
disabled={running}
|
||||
/>
|
||||
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
|
||||
<span className="action-desc">{metadataAssignActionDescription}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -411,6 +411,46 @@ function shellQuote(value) {
|
||||
return `'${raw.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function transliterateForFilename(input) {
|
||||
return String(input || '')
|
||||
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
|
||||
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
|
||||
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/å/g, 'a').replace(/Å/g, 'A')
|
||||
.replace(/æ/g, 'ae').replace(/Æ/g, 'Ae')
|
||||
.replace(/ø/g, 'o').replace(/Ø/g, 'O')
|
||||
.replace(/ð/g, 'd').replace(/Ð/g, 'D')
|
||||
.replace(/þ/g, 'th').replace(/Þ/g, 'Th')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}+/gu, '')
|
||||
.replace(/[^\x00-\x7F]/g, '');
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value) {
|
||||
return transliterateForFilename(String(value || ''))
|
||||
.replace(/[^a-zA-Z0-9 ().[\]_-]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeOutputPathForCommand(pathValue) {
|
||||
const raw = String(pathValue || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = raw.replace(/\\/g, '/');
|
||||
const hasLeadingSlash = normalizedPath.startsWith('/');
|
||||
const segments = normalizedPath
|
||||
.split('/')
|
||||
.map((segment) => sanitizePathSegment(segment))
|
||||
.filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return hasLeadingSlash ? '/' : null;
|
||||
}
|
||||
return `${hasLeadingSlash ? '/' : ''}${segments.join('/')}`;
|
||||
}
|
||||
|
||||
function buildHandBrakeCommandPreview({
|
||||
review,
|
||||
title,
|
||||
@@ -433,7 +473,12 @@ function buildHandBrakeCommandPreview({
|
||||
const extraArgs = presetOverride !== null
|
||||
? String(presetOverride.extraArgs || '').trim()
|
||||
: String(review?.selectors?.extraArgs || '').trim();
|
||||
const rawMappedTitleId = Number(review?.handBrakeTitleId);
|
||||
const rawMappedTitleId = Number(
|
||||
title?.handBrakeTitleId
|
||||
?? title?.titleIndex
|
||||
?? title?.id
|
||||
?? review?.handBrakeTitleId
|
||||
);
|
||||
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
|
||||
? Math.trunc(rawMappedTitleId)
|
||||
: null;
|
||||
@@ -463,11 +508,12 @@ function buildHandBrakeCommandPreview({
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
|
||||
const sanitizedOutputPath = sanitizeOutputPathForCommand(commandOutputPath);
|
||||
const baseArgs = [
|
||||
'-i',
|
||||
inputPath || '<encode-input>',
|
||||
'-o',
|
||||
String(commandOutputPath || '').trim() || '<encode-output>'
|
||||
String(sanitizedOutputPath || '').trim() || '<encode-output>'
|
||||
];
|
||||
|
||||
if (mappedTitleId !== null) {
|
||||
@@ -1195,13 +1241,22 @@ export default function MediaInfoReviewPanel({
|
||||
review,
|
||||
presetDisplayValue = '',
|
||||
commandOutputPath = null,
|
||||
commandOutputPathByTitle = {},
|
||||
selectedEncodeTitleId = null,
|
||||
selectedEncodeTitleIds = [],
|
||||
allowTitleSelection = false,
|
||||
compactTitleSelection = false,
|
||||
onSelectEncodeTitle = null,
|
||||
onToggleEncodeTitle = null,
|
||||
allowTrackSelection = false,
|
||||
trackSelectionByTitle = {},
|
||||
onTrackSelectionChange = null,
|
||||
onSubtitleVariantSelectionChange = null,
|
||||
selectedMetadataEpisodes = [],
|
||||
selectedEpisodeFillStart = null,
|
||||
onEpisodeFillStartChange = null,
|
||||
episodeAssignmentsByTitle = {},
|
||||
onEpisodeAssignmentChange = null,
|
||||
availableScripts = [],
|
||||
availableChains = [],
|
||||
preEncodeItems = [],
|
||||
@@ -1225,10 +1280,68 @@ export default function MediaInfoReviewPanel({
|
||||
}
|
||||
|
||||
const titles = review.titles || [];
|
||||
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
|
||||
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
|
||||
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
||||
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
||||
const candidateTitles = compactTitleSelection && Array.isArray(review?.handBrakeTitleCandidates)
|
||||
? review.handBrakeTitleCandidates
|
||||
.map((candidate) => {
|
||||
const id = normalizeTitleId(candidate?.handBrakeTitleId);
|
||||
if (id === null) return null;
|
||||
const durationSeconds = Number(candidate?.durationSeconds || 0);
|
||||
const durationMinutes = Number.isFinite(durationSeconds) && durationSeconds > 0
|
||||
? Number((durationSeconds / 60).toFixed(2))
|
||||
: Number(candidate?.durationMinutes || 0);
|
||||
return {
|
||||
id,
|
||||
fileName: `Titel #${id}`,
|
||||
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
|
||||
durationMinutes: Number.isFinite(durationMinutes) ? durationMinutes : 0,
|
||||
sizeBytes: Number(candidate?.sizeBytes || 0),
|
||||
audioTrackCount: Number(candidate?.audioTrackCount || 0),
|
||||
subtitleTrackCount: Number(candidate?.subtitleTrackCount || 0),
|
||||
audioTracks: [],
|
||||
subtitleTracks: [],
|
||||
selectedForEncode: false
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles;
|
||||
const selectedTitleIds = normalizeTrackIdList([
|
||||
...normalizeTrackIdList(selectedEncodeTitleIds),
|
||||
...normalizeTrackIdList(review?.selectedTitleIds || []),
|
||||
...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id)
|
||||
]);
|
||||
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId)
|
||||
|| normalizeTitleId(review.encodeInputTitleId)
|
||||
|| selectedTitleIds[0]
|
||||
|| null;
|
||||
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
||||
const encodeInputTitle = displayTitles.find((item) => item.id === currentSelectedId) || null;
|
||||
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
|
||||
.map((episode) => {
|
||||
const episodeId = Number(episode?.episodeId ?? episode?.id ?? 0);
|
||||
const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? 0);
|
||||
const seasonNumber = Number(episode?.seasonNumber ?? episode?.season ?? 0);
|
||||
const episodeTitle = String(episode?.episodeTitle || episode?.name || episode?.title || '').trim();
|
||||
return {
|
||||
episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null,
|
||||
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
|
||||
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
|
||||
episodeTitle: episodeTitle || null
|
||||
};
|
||||
})
|
||||
.filter((episode) => episode.episodeId || episode.episodeNumber !== null);
|
||||
const episodeFillOptions = normalizedEpisodeRows.map((episode) => {
|
||||
const seasonLabel = episode.seasonNumber !== null ? `S${String(episode.seasonNumber).padStart(2, '0')}` : 'S??';
|
||||
const episodeLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : 'E??';
|
||||
const titleLabel = episode.episodeTitle || '(ohne Titel)';
|
||||
const value = String(episode.episodeId || episode.episodeNumber || '');
|
||||
return {
|
||||
label: `${seasonLabel}${episodeLabel} - ${titleLabel}`,
|
||||
value
|
||||
};
|
||||
}).filter((option) => option.value);
|
||||
const processedFiles = Number(review.processedFiles || displayTitles.length || 0);
|
||||
const totalFiles = Number(review.totalFiles || displayTitles.length || 0);
|
||||
const playlistRecommendation = review.playlistRecommendation || null;
|
||||
const rawPreset = String(review.selectors?.preset || '').trim();
|
||||
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
|
||||
@@ -1280,9 +1393,170 @@ export default function MediaInfoReviewPanel({
|
||||
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
|
||||
const handlePostDrop = makeHandleDrop(postEncodeItems, onReorderPostEncodeItem);
|
||||
|
||||
const resolveTitleSelectionState = (title) => {
|
||||
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
const selectableSubtitleTrackIds = subtitleTracks
|
||||
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
|
||||
const sortedSelectableSubtitleTracks = subtitleTracks
|
||||
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||
.slice()
|
||||
.sort((a, b) => (normalizeTrackId(a?.id) || Number.MAX_SAFE_INTEGER) - (normalizeTrackId(b?.id) || Number.MAX_SAFE_INTEGER));
|
||||
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedByRule))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const defaultSubtitleTrackIds = subtitleTracks
|
||||
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
.filter((id) => id !== null);
|
||||
const defaultSubtitleVariantSelection = {};
|
||||
const defaultSubtitleLanguageOrder = [];
|
||||
const subtitleLanguageOrderSeen = new Set();
|
||||
for (const track of sortedSelectableSubtitleTracks) {
|
||||
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
|
||||
if (!subtitleLanguageOrderSeen.has(language)) {
|
||||
subtitleLanguageOrderSeen.add(language);
|
||||
defaultSubtitleLanguageOrder.push(language);
|
||||
}
|
||||
if (!Boolean(track?.selectedByRule)) {
|
||||
continue;
|
||||
}
|
||||
if (!defaultSubtitleVariantSelection[language]) {
|
||||
defaultSubtitleVariantSelection[language] = { full: false, forced: false };
|
||||
}
|
||||
const flags = resolveSubtitleTrackVariantFlags(track);
|
||||
if (flags.isForcedOnly) {
|
||||
defaultSubtitleVariantSelection[language].forced = true;
|
||||
} else {
|
||||
defaultSubtitleVariantSelection[language].full = true;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAudioTrackIds = normalizeTrackIdList(
|
||||
Array.isArray(titleSelectionEntry?.audioTrackIds)
|
||||
? titleSelectionEntry.audioTrackIds
|
||||
: defaultAudioTrackIds
|
||||
);
|
||||
const selectedSubtitleTrackIds = normalizeTrackIdList(
|
||||
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
|
||||
? titleSelectionEntry.subtitleTrackIds
|
||||
: defaultSubtitleTrackIds
|
||||
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
|
||||
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
|
||||
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
|
||||
);
|
||||
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
|
||||
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
|
||||
? titleSelectionEntry.subtitleLanguageOrder
|
||||
: defaultSubtitleLanguageOrder
|
||||
);
|
||||
return {
|
||||
titleSelectionEntry,
|
||||
subtitleTracks,
|
||||
selectedAudioTrackIds,
|
||||
selectedSubtitleTrackIds,
|
||||
selectedSubtitleVariantSelection,
|
||||
selectedSubtitleLanguageOrder
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTrackSignatureText = (value) => String(value || '').trim().toLowerCase();
|
||||
const buildAudioSignature = (track) => [
|
||||
normalizeTrackSignatureText(track?.language || track?.languageLabel || 'und'),
|
||||
normalizeTrackSignatureText(track?.format || track?.codecName || ''),
|
||||
normalizeTrackSignatureText(track?.channels || ''),
|
||||
normalizeTrackSignatureText(track?.description || track?.title || '')
|
||||
].join('|');
|
||||
const buildSubtitleSignature = (track) => {
|
||||
const flags = resolveSubtitleTrackVariantFlags(track);
|
||||
return [
|
||||
normalizeTrackSignatureText(track?.language || track?.languageLabel || 'und'),
|
||||
normalizeTrackSignatureText(track?.format || track?.codecName || ''),
|
||||
flags.isForcedOnly ? 'forced' : 'full',
|
||||
flags.fullHasForced ? 'with_forced_variant' : 'plain'
|
||||
].join('|');
|
||||
};
|
||||
|
||||
const selectedTitlesForGlobal = selectedTitleIds.length > 0
|
||||
? titles.filter((title) => selectedTitleIdSet.has(String(normalizeTitleId(title?.id))))
|
||||
: titles;
|
||||
const masterGlobalTitle = selectedTitlesForGlobal[0] || titles[0] || null;
|
||||
const globalMasterSelectionState = masterGlobalTitle ? resolveTitleSelectionState(masterGlobalTitle) : null;
|
||||
const globalAudioTrackMaps = new Map();
|
||||
const globalSubtitleLanguagesByTitle = new Map();
|
||||
const globalTrackCompatibility = (() => {
|
||||
if (!masterGlobalTitle || selectedTitlesForGlobal.length < 2) {
|
||||
return { audio: false, subtitle: false };
|
||||
}
|
||||
const masterAudioTracks = Array.isArray(masterGlobalTitle.audioTracks) ? masterGlobalTitle.audioTracks : [];
|
||||
const masterSubtitleTracks = (Array.isArray(masterGlobalTitle.subtitleTracks) ? masterGlobalTitle.subtitleTracks : [])
|
||||
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track));
|
||||
const masterAudioSignatures = masterAudioTracks.map((track) => buildAudioSignature(track)).sort();
|
||||
const masterSubtitleSignatures = masterSubtitleTracks.map((track) => buildSubtitleSignature(track)).sort();
|
||||
const masterSubtitleLanguages = Array.from(new Set(masterSubtitleTracks.map((track) =>
|
||||
normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')
|
||||
))).sort();
|
||||
|
||||
let audioCompatible = true;
|
||||
let subtitleCompatible = true;
|
||||
for (const title of selectedTitlesForGlobal) {
|
||||
const titleId = normalizeTitleId(title?.id);
|
||||
if (!titleId) {
|
||||
continue;
|
||||
}
|
||||
const audioMap = new Map();
|
||||
const titleAudioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
||||
const titleAudioSignatures = titleAudioTracks.map((track) => {
|
||||
const signature = buildAudioSignature(track);
|
||||
const trackId = normalizeTrackId(track?.id);
|
||||
if (trackId !== null && !audioMap.has(signature)) {
|
||||
audioMap.set(signature, trackId);
|
||||
}
|
||||
return signature;
|
||||
}).sort();
|
||||
globalAudioTrackMaps.set(titleId, audioMap);
|
||||
if (titleAudioSignatures.join('||') !== masterAudioSignatures.join('||')) {
|
||||
audioCompatible = false;
|
||||
}
|
||||
|
||||
const subtitleTracks = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track));
|
||||
const subtitleSignatures = subtitleTracks.map((track) => buildSubtitleSignature(track)).sort();
|
||||
const subtitleLanguages = Array.from(new Set(subtitleTracks.map((track) =>
|
||||
normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')
|
||||
))).sort();
|
||||
globalSubtitleLanguagesByTitle.set(titleId, subtitleLanguages);
|
||||
if (subtitleSignatures.join('||') !== masterSubtitleSignatures.join('||')) {
|
||||
subtitleCompatible = false;
|
||||
}
|
||||
if (subtitleLanguages.join('||') !== masterSubtitleLanguages.join('||')) {
|
||||
subtitleCompatible = false;
|
||||
}
|
||||
}
|
||||
return { audio: audioCompatible, subtitle: subtitleCompatible };
|
||||
})();
|
||||
const showGlobalEpisodeTrackControls = Boolean(
|
||||
allowTrackSelection
|
||||
&& allowTitleSelection
|
||||
&& masterGlobalTitle
|
||||
&& selectedTitlesForGlobal.length > 1
|
||||
&& globalTrackCompatibility.audio
|
||||
&& globalTrackCompatibility.subtitle
|
||||
);
|
||||
const useEpisodeTrackAccordion = Boolean(
|
||||
masterGlobalTitle
|
||||
&& selectedTitlesForGlobal.length > 1
|
||||
&& globalTrackCompatibility.audio
|
||||
&& globalTrackCompatibility.subtitle
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="media-review-wrap">
|
||||
{allowUserPresetSelection && (
|
||||
{!compactTitleSelection && allowUserPresetSelection && (
|
||||
<div className="user-preset-selector" style={{ marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
|
||||
Encode-Preset auswählen
|
||||
@@ -1315,9 +1589,10 @@ export default function MediaInfoReviewPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="media-review-meta">
|
||||
<div>
|
||||
<strong>Preset:</strong>{' '}
|
||||
{!compactTitleSelection ? (
|
||||
<div className="media-review-meta">
|
||||
<div>
|
||||
<strong>Preset:</strong>{' '}
|
||||
{effectivePresetOverride
|
||||
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
|
||||
: presetLabel}
|
||||
@@ -1345,13 +1620,14 @@ export default function MediaInfoReviewPanel({
|
||||
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
|
||||
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
|
||||
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{review.partial ? (
|
||||
{!compactTitleSelection && review.partial ? (
|
||||
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
|
||||
) : null}
|
||||
|
||||
{playlistRecommendation ? (
|
||||
{!compactTitleSelection && playlistRecommendation ? (
|
||||
<div className="playlist-recommendation-box">
|
||||
<small>
|
||||
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
|
||||
@@ -1361,7 +1637,7 @@ export default function MediaInfoReviewPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(review.notes) && review.notes.length > 0 ? (
|
||||
{!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? (
|
||||
<div className="media-review-notes">
|
||||
{review.notes.map((note, idx) => (
|
||||
<small key={`${idx}-${note}`}>{note}</small>
|
||||
@@ -1370,7 +1646,7 @@ export default function MediaInfoReviewPanel({
|
||||
) : null}
|
||||
|
||||
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||
<div className="post-script-box">
|
||||
<h4>Pre-Encode Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
@@ -1491,7 +1767,8 @@ export default function MediaInfoReviewPanel({
|
||||
) : null}
|
||||
|
||||
{/* Post-Encode Items (scripts + chains unified) */}
|
||||
<div className="post-script-box">
|
||||
{!compactTitleSelection ? (
|
||||
<div className="post-script-box">
|
||||
<h4>Post-Encode Ausführungen (optional)</h4>
|
||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
||||
@@ -1606,22 +1883,140 @@ export default function MediaInfoReviewPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
||||
</div>
|
||||
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h4>Titel</h4>
|
||||
{episodeFillOptions.length > 0 ? (
|
||||
<div className="episode-fill-box">
|
||||
<div className="episode-fill-field">
|
||||
<label>Episodentitel befüllen ab Episode</label>
|
||||
<Dropdown
|
||||
value={String(selectedEpisodeFillStart || '').trim() || null}
|
||||
options={episodeFillOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => onEpisodeFillStartChange?.(event?.value || null)}
|
||||
placeholder="Episode auswählen …"
|
||||
disabled={!allowTitleSelection}
|
||||
style={{ width: '100%' }}
|
||||
filter
|
||||
showClear
|
||||
/>
|
||||
<small>
|
||||
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showGlobalEpisodeTrackControls ? (
|
||||
<div className="episode-global-selection-box">
|
||||
<h4>Spurauswahl für alle Episoden</h4>
|
||||
<small>Gilt für alle aktuell gewählten Episoden. Einzelne Episoden können danach weiterhin manuell überschrieben werden.</small>
|
||||
<div className="media-track-grid">
|
||||
{globalTrackCompatibility.audio && globalMasterSelectionState ? (
|
||||
<TrackList
|
||||
title="Tonspuren (alle Episoden)"
|
||||
tracks={masterGlobalTitle?.audioTracks || []}
|
||||
type="audio"
|
||||
allowSelection={allowTrackSelection}
|
||||
selectedTrackIds={globalMasterSelectionState.selectedAudioTrackIds}
|
||||
audioSelector={effectiveAudioSelector}
|
||||
onToggleTrack={(masterTrackId, checked) => {
|
||||
if (!allowTrackSelection || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
const masterTrack = (Array.isArray(masterGlobalTitle?.audioTracks) ? masterGlobalTitle.audioTracks : [])
|
||||
.find((track) => normalizeTrackId(track?.id) === normalizeTrackId(masterTrackId));
|
||||
if (!masterTrack) {
|
||||
return;
|
||||
}
|
||||
const signature = buildAudioSignature(masterTrack);
|
||||
for (const title of selectedTitlesForGlobal) {
|
||||
const titleId = normalizeTitleId(title?.id);
|
||||
if (!titleId) {
|
||||
continue;
|
||||
}
|
||||
const titleTrackMap = globalAudioTrackMaps.get(titleId);
|
||||
const mappedTrackId = titleTrackMap?.get(signature) ?? null;
|
||||
if (!mappedTrackId) {
|
||||
continue;
|
||||
}
|
||||
onTrackSelectionChange(titleId, 'audio', mappedTrackId, checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<h4>Tonspuren (alle Episoden)</h4>
|
||||
<small>Globale Auswahl nicht verfügbar, da die Tonspur-Layouts zwischen Episoden abweichen.</small>
|
||||
</div>
|
||||
)}
|
||||
{globalTrackCompatibility.subtitle && globalMasterSelectionState ? (
|
||||
<TrackList
|
||||
title="Subtitles (alle Episoden)"
|
||||
tracks={(Array.isArray(masterGlobalTitle?.subtitleTracks) ? masterGlobalTitle.subtitleTracks : [])
|
||||
.filter((track) => !isBurnedSubtitleTrack(track))}
|
||||
type="subtitle"
|
||||
allowSelection={allowTrackSelection}
|
||||
selectedTrackIds={globalMasterSelectionState.selectedSubtitleTrackIds}
|
||||
selectedSubtitleVariantSelection={globalMasterSelectionState.selectedSubtitleVariantSelection}
|
||||
selectedSubtitleLanguageOrder={globalMasterSelectionState.selectedSubtitleLanguageOrder}
|
||||
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||
if (!allowTrackSelection || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
const normalizedLanguage = normalizeSubtitleLanguage(language);
|
||||
for (const title of selectedTitlesForGlobal) {
|
||||
const titleId = normalizeTitleId(title?.id);
|
||||
if (!titleId) {
|
||||
continue;
|
||||
}
|
||||
const subtitleLanguages = globalSubtitleLanguagesByTitle.get(titleId) || [];
|
||||
if (!subtitleLanguages.includes(normalizedLanguage)) {
|
||||
continue;
|
||||
}
|
||||
onSubtitleVariantSelectionChange(titleId, normalizedLanguage, variant, checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<h4>Subtitles (alle Episoden)</h4>
|
||||
<small>Globale Auswahl nicht verfügbar, da die Untertitel-Layouts zwischen Episoden abweichen.</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="media-title-list">
|
||||
{titles.length === 0 ? (
|
||||
{displayTitles.length === 0 ? (
|
||||
<p>Keine Titel analysiert.</p>
|
||||
) : titles.map((title) => {
|
||||
const titleEligible = title?.eligibleForEncode !== false;
|
||||
) : displayTitles.map((title) => {
|
||||
const normalizedTitleId = normalizeTitleId(title.id);
|
||||
const titleChecked = allowTitleSelection
|
||||
? (currentSelectedId !== null
|
||||
? currentSelectedId === normalizeTitleId(title.id)
|
||||
? (selectedTitleIdSet.size > 0
|
||||
? selectedTitleIdSet.has(String(normalizedTitleId))
|
||||
: Boolean(title.selectedForEncode))
|
||||
: Boolean(title.selectedForEncode);
|
||||
const titleIsPrimary = currentSelectedId !== null && currentSelectedId === normalizedTitleId;
|
||||
const titleEpisodeAssignment = episodeAssignmentsByTitle?.[normalizedTitleId]
|
||||
|| episodeAssignmentsByTitle?.[String(normalizedTitleId)]
|
||||
|| null;
|
||||
const episodeInputValue = String(
|
||||
titleEpisodeAssignment?.episodeTitle
|
||||
|| title?.episodeTitle
|
||||
|| ''
|
||||
);
|
||||
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
const audioCount = Number.isFinite(Number(title?.audioTrackCount))
|
||||
? Number(title.audioTrackCount)
|
||||
: (Array.isArray(title.audioTracks) ? title.audioTracks.length : 0);
|
||||
const subtitleCount = Number.isFinite(Number(title?.subtitleTrackCount))
|
||||
? Number(title.subtitleTrackCount)
|
||||
: subtitleTracks.length;
|
||||
const selectableSubtitleTrackIds = subtitleTracks
|
||||
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||
.map((track) => normalizeTrackId(track?.id))
|
||||
@@ -1691,21 +2086,52 @@ export default function MediaInfoReviewPanel({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleChecked}
|
||||
onChange={() => {
|
||||
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
|
||||
onChange={(event) => {
|
||||
if (!allowTitleSelection) {
|
||||
return;
|
||||
}
|
||||
onSelectEncodeTitle(normalizeTitleId(title.id));
|
||||
const checked = Boolean(event?.target?.checked);
|
||||
if (typeof onToggleEncodeTitle === 'function') {
|
||||
onToggleEncodeTitle(normalizedTitleId, checked);
|
||||
}
|
||||
if (checked && typeof onSelectEncodeTitle === 'function') {
|
||||
onSelectEncodeTitle(normalizedTitleId);
|
||||
}
|
||||
}}
|
||||
readOnly={!allowTitleSelection}
|
||||
disabled={!allowTitleSelection}
|
||||
/>
|
||||
<span>
|
||||
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} | {formatBytes(title.sizeBytes)}
|
||||
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)}
|
||||
{audioCount > 0 ? ` | Audio: ${audioCount}` : ''}
|
||||
{subtitleCount > 0 ? ` | Untertitel: ${subtitleCount}` : ''}
|
||||
{title.encodeInput ? ' | Encode-Input' : ''}
|
||||
{titleIsPrimary ? ' | Primär' : ''}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{titleChecked ? (
|
||||
<div className="episode-title-input-row">
|
||||
<label htmlFor={`episode-title-${title.id}`}>Episodentitel</label>
|
||||
<input
|
||||
id={`episode-title-${title.id}`}
|
||||
className="p-inputtext p-component"
|
||||
type="text"
|
||||
value={episodeInputValue}
|
||||
onChange={(event) => {
|
||||
if (!allowTitleSelection || typeof onEpisodeAssignmentChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onEpisodeAssignmentChange(normalizedTitleId, {
|
||||
episodeTitle: event?.target?.value || ''
|
||||
});
|
||||
}}
|
||||
placeholder="Episodentitel"
|
||||
disabled={!allowTitleSelection}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
|
||||
<div className="playlist-info-box">
|
||||
<small>
|
||||
@@ -1729,40 +2155,85 @@ export default function MediaInfoReviewPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="media-track-grid">
|
||||
<TrackList
|
||||
title={`Tonspuren (Titel #${title.id})`}
|
||||
tracks={title.audioTracks || []}
|
||||
type="audio"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedAudioTrackIds}
|
||||
audioSelector={effectiveAudioSelector}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'audio', trackId, checked);
|
||||
}}
|
||||
/>
|
||||
<TrackList
|
||||
title={`Subtitles (Titel #${title.id})`}
|
||||
tracks={allowTrackSelectionForTitle
|
||||
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
|
||||
: subtitleTracks}
|
||||
type="subtitle"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
|
||||
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
|
||||
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{titleChecked ? (() => {
|
||||
{!compactTitleSelection && useEpisodeTrackAccordion ? (
|
||||
<details className="episode-track-accordion">
|
||||
<summary>Tonspuren und Untertitel</summary>
|
||||
<div className="media-track-grid">
|
||||
<TrackList
|
||||
title={`Tonspuren (Titel #${title.id})`}
|
||||
tracks={title.audioTracks || []}
|
||||
type="audio"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedAudioTrackIds}
|
||||
audioSelector={effectiveAudioSelector}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'audio', trackId, checked);
|
||||
}}
|
||||
/>
|
||||
<TrackList
|
||||
title={`Subtitles (Titel #${title.id})`}
|
||||
tracks={allowTrackSelectionForTitle
|
||||
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
|
||||
: subtitleTracks}
|
||||
type="subtitle"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
|
||||
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
|
||||
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : (!compactTitleSelection ? (
|
||||
<div className="media-track-grid">
|
||||
<TrackList
|
||||
title={`Tonspuren (Titel #${title.id})`}
|
||||
tracks={title.audioTracks || []}
|
||||
type="audio"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedAudioTrackIds}
|
||||
audioSelector={effectiveAudioSelector}
|
||||
onToggleTrack={(trackId, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onTrackSelectionChange(title.id, 'audio', trackId, checked);
|
||||
}}
|
||||
/>
|
||||
<TrackList
|
||||
title={`Subtitles (Titel #${title.id})`}
|
||||
tracks={allowTrackSelectionForTitle
|
||||
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
|
||||
: subtitleTracks}
|
||||
type="subtitle"
|
||||
allowSelection={allowTrackSelectionForTitle}
|
||||
selectedTrackIds={selectedSubtitleTrackIds}
|
||||
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
|
||||
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
|
||||
onToggleSubtitleVariant={(language, variant, checked) => {
|
||||
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
|
||||
return;
|
||||
}
|
||||
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null)}
|
||||
{!compactTitleSelection && titleChecked ? (() => {
|
||||
const resolvedCommandOutputPath = String(
|
||||
commandOutputPathByTitle?.[normalizeTitleId(title?.id)]
|
||||
|| commandOutputPathByTitle?.[String(normalizeTitleId(title?.id))]
|
||||
|| commandOutputPath
|
||||
|| ''
|
||||
).trim() || null;
|
||||
const commandPreview = buildHandBrakeCommandPreview({
|
||||
review,
|
||||
title,
|
||||
@@ -1770,7 +2241,7 @@ export default function MediaInfoReviewPanel({
|
||||
selectedSubtitleTrackIds,
|
||||
selectedSubtitleVariantSelection,
|
||||
selectedSubtitleLanguageOrder,
|
||||
commandOutputPath,
|
||||
commandOutputPath: resolvedCommandOutputPath,
|
||||
presetOverride: effectivePresetOverride
|
||||
});
|
||||
return (
|
||||
|
||||
@@ -18,8 +18,14 @@ export default function MetadataSelectionDialog({
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualYear, setManualYear] = useState('');
|
||||
const [manualImdb, setManualImdb] = useState('');
|
||||
const [manualDiscNumber, setManualDiscNumber] = useState('');
|
||||
const [extraResults, setExtraResults] = useState([]);
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
const [seasonFilter, setSeasonFilter] = useState('');
|
||||
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
|
||||
const supportsExternalIdInput = metadataProvider === 'omdb';
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
@@ -30,30 +36,64 @@ export default function MetadataSelectionDialog({
|
||||
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
||||
const defaultImdb = selectedMetadata.imdbId || '';
|
||||
const hintedDiscNumber = selectedMetadata.discNumber || context?.seriesLookupHint?.discNumber || null;
|
||||
const defaultDiscNumber = Number(hintedDiscNumber || 0) > 0
|
||||
? String(Math.trunc(Number(hintedDiscNumber || 0)))
|
||||
: '';
|
||||
|
||||
setSelected(null);
|
||||
setQuery(defaultTitle);
|
||||
setManualTitle(defaultTitle);
|
||||
setManualYear(defaultYear);
|
||||
setManualImdb(defaultImdb);
|
||||
setManualDiscNumber(defaultDiscNumber);
|
||||
setExtraResults([]);
|
||||
setSearchBusy(false);
|
||||
setSearchError('');
|
||||
setSeasonFilter('');
|
||||
}, [visible, context]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const base = context?.omdbCandidates || [];
|
||||
const base = Array.isArray(context?.metadataCandidates)
|
||||
? context.metadataCandidates
|
||||
: (context?.omdbCandidates || []);
|
||||
const all = [...base, ...extraResults];
|
||||
const map = new Map();
|
||||
|
||||
all.forEach((item) => {
|
||||
if (item?.imdbId) {
|
||||
map.set(item.imdbId, item);
|
||||
const rowKey = String(
|
||||
item?.providerId
|
||||
|| item?.imdbId
|
||||
|| item?.tmdbId
|
||||
|| `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}`
|
||||
).trim();
|
||||
if (!rowKey) {
|
||||
return;
|
||||
}
|
||||
map.set(rowKey, {
|
||||
...item,
|
||||
_metadataKey: rowKey
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [context, extraResults]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (metadataProvider !== 'tmdb') {
|
||||
return rows;
|
||||
}
|
||||
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
|
||||
if (!normalizedFilter) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
|
||||
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
|
||||
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
|
||||
});
|
||||
}, [metadataProvider, rows, seasonFilter]);
|
||||
|
||||
const titleWithPosterBody = (row) => (
|
||||
<div className="omdb-row">
|
||||
{row.poster && row.poster !== 'N/A' ? (
|
||||
@@ -63,7 +103,14 @@ export default function MetadataSelectionDialog({
|
||||
)}
|
||||
<div>
|
||||
<div><strong>{row.title}</strong></div>
|
||||
<small>{row.year} | {row.imdbId}</small>
|
||||
<small>
|
||||
{row.year || '-'}
|
||||
{row.seasonNumber ? ` | Staffel ${row.seasonNumber}` : ''}
|
||||
{row.seasonName ? ` | ${row.seasonName}` : ''}
|
||||
{row.episodeCount ? ` | ${row.episodeCount} Episoden` : ''}
|
||||
{row.imdbId ? ` | ${row.imdbId}` : ''}
|
||||
{!row.imdbId && row.tmdbId ? ` | TMDb ${row.tmdbId}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -74,15 +121,26 @@ export default function MetadataSelectionDialog({
|
||||
return;
|
||||
}
|
||||
setSearchBusy(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const results = await onSearch(trimmedQuery);
|
||||
const results = await onSearch(trimmedQuery, {
|
||||
metadataProvider
|
||||
});
|
||||
setExtraResults(Array.isArray(results) ? results : []);
|
||||
} catch (error) {
|
||||
setExtraResults([]);
|
||||
setSearchError(error?.message || 'Suche fehlgeschlagen.');
|
||||
} finally {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const parsedDiscNumber = Number(manualDiscNumber);
|
||||
const effectiveDiscNumber = Number.isFinite(parsedDiscNumber) && parsedDiscNumber > 0
|
||||
? Math.trunc(parsedDiscNumber)
|
||||
: null;
|
||||
|
||||
const payload = selected
|
||||
? {
|
||||
jobId: context.jobId,
|
||||
@@ -90,15 +148,27 @@ export default function MetadataSelectionDialog({
|
||||
year: selected.year,
|
||||
imdbId: selected.imdbId,
|
||||
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
||||
fromOmdb: true
|
||||
fromOmdb: metadataProvider === 'omdb',
|
||||
metadataProvider,
|
||||
providerId: selected.providerId || null,
|
||||
tmdbId: selected.tmdbId || null,
|
||||
metadataKind: selected.metadataKind || null,
|
||||
seasonNumber: selected.seasonNumber || null,
|
||||
seasonName: selected.seasonName || null,
|
||||
episodeCount: selected.episodeCount || 0,
|
||||
episodes: Array.isArray(selected.episodes) ? selected.episodes : [],
|
||||
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
||||
}
|
||||
: {
|
||||
jobId: context.jobId,
|
||||
title: manualTitle,
|
||||
year: manualYear,
|
||||
imdbId: manualImdb,
|
||||
imdbId: supportsExternalIdInput ? manualImdb : null,
|
||||
poster: null,
|
||||
fromOmdb: false
|
||||
fromOmdb: false,
|
||||
metadataProvider,
|
||||
seasonNumber: null,
|
||||
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
@@ -126,16 +196,39 @@ export default function MetadataSelectionDialog({
|
||||
}}
|
||||
placeholder="Titel suchen"
|
||||
/>
|
||||
<Button label="OMDb 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 className={`metadata-filter-grid${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}>
|
||||
{metadataProvider === 'tmdb' ? (
|
||||
<InputText
|
||||
value={seasonFilter}
|
||||
onChange={(event) => setSeasonFilter(event.target.value)}
|
||||
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
|
||||
disabled={searchBusy || busy}
|
||||
/>
|
||||
) : null}
|
||||
<div className="metadata-filter-disc">
|
||||
<InputText
|
||||
value={manualDiscNumber}
|
||||
onChange={(event) => setManualDiscNumber(event.target.value)}
|
||||
placeholder="Disc-Nr (optional)"
|
||||
inputMode="numeric"
|
||||
disabled={searchBusy || busy}
|
||||
/>
|
||||
<small className="metadata-filter-subtitle">
|
||||
Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{searchError ? <small className="error-text">{searchError}</small> : null}
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-medium">
|
||||
<DataTable
|
||||
value={rows}
|
||||
value={filteredRows}
|
||||
selectionMode="single"
|
||||
selection={selected}
|
||||
onSelectionChange={(event) => setSelected(event.value)}
|
||||
dataKey="imdbId"
|
||||
dataKey="_metadataKey"
|
||||
size="small"
|
||||
scrollable
|
||||
scrollHeight="22rem"
|
||||
@@ -145,7 +238,13 @@ export default function MetadataSelectionDialog({
|
||||
>
|
||||
<Column header="Titel" body={titleWithPosterBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
||||
<Column field="imdbId" header="IMDb" style={{ width: '10rem' }} />
|
||||
<Column
|
||||
header={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'}
|
||||
body={(row) => (metadataProvider === 'tmdb'
|
||||
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
|
||||
: (row.imdbId || '-'))}
|
||||
style={{ width: '10rem' }}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@@ -166,8 +265,8 @@ export default function MetadataSelectionDialog({
|
||||
<InputText
|
||||
value={manualImdb}
|
||||
onChange={(event) => setManualImdb(event.target.value)}
|
||||
placeholder="IMDb-ID"
|
||||
disabled={!!selected}
|
||||
placeholder={supportsExternalIdInput ? 'IMDb-ID' : 'Externe ID'}
|
||||
disabled={!!selected || !supportsExternalIdInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,7 +112,7 @@ export default function DatabasePage() {
|
||||
|
||||
<Card
|
||||
title="Gefundene RAW-Einträge"
|
||||
subTitle="Es werden nur RAW-Ordner ohne zugehörigen Historienjob aus den konfigurierten RAW-Pfaden angezeigt."
|
||||
subTitle="Es werden RAW-Ordner ohne zugehörigen Historienjob aus Settings- und Default-RAW-Pfaden angezeigt."
|
||||
>
|
||||
<div className="table-filters">
|
||||
<Button
|
||||
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
STATUS_FILTER_OPTIONS
|
||||
} from '../utils/statusPresentation';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
|
||||
|
||||
const MEDIA_FILTER_OPTIONS = [
|
||||
{ label: 'Alle Medien', value: '' },
|
||||
{ label: 'Blu-ray', value: 'bluray' },
|
||||
{ label: 'DVD', value: 'dvd' },
|
||||
{ label: 'DVD Serie', value: 'dvd_series' },
|
||||
{ label: 'Audio CD', value: 'cd' },
|
||||
{ label: 'Audiobook', value: 'audiobook' },
|
||||
{ label: 'Sonstiges', value: 'other' }
|
||||
@@ -123,6 +125,7 @@ function resolveMediaType(row) {
|
||||
|
||||
function resolveMediaTypeMeta(row) {
|
||||
const mediaType = resolveMediaType(row);
|
||||
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(row);
|
||||
if (mediaType === 'bluray') {
|
||||
return {
|
||||
mediaType,
|
||||
@@ -135,8 +138,8 @@ function resolveMediaTypeMeta(row) {
|
||||
return {
|
||||
mediaType,
|
||||
icon: discIndicatorIcon,
|
||||
label: 'DVD',
|
||||
alt: 'DVD'
|
||||
label: isSeriesDvd ? 'DVD Serie' : 'DVD',
|
||||
alt: isSeriesDvd ? 'DVD Serie' : 'DVD'
|
||||
};
|
||||
}
|
||||
if (mediaType === 'cd') {
|
||||
@@ -171,6 +174,14 @@ function resolveMediaTypeMeta(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function isSeriesBatchChildHistoryRow(row) {
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
|
||||
if (!encodePlan) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(encodePlan?.seriesBatchChild || encodePlan?.seriesBatchVirtualEpisode);
|
||||
}
|
||||
|
||||
function formatDurationSeconds(totalSeconds) {
|
||||
const parsed = Number(totalSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -438,10 +449,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
||||
const [pendingRestartRow, setPendingRestartRow] = useState(null);
|
||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const [conflictModalVisible, setConflictModalVisible] = useState(false);
|
||||
@@ -464,19 +475,26 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}, [queuedJobIds]);
|
||||
|
||||
const preparedJobs = useMemo(
|
||||
() => jobs.map((job) => ({
|
||||
...job,
|
||||
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
||||
sortMediaType: resolveMediaType(job),
|
||||
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
|
||||
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
|
||||
})),
|
||||
() => jobs
|
||||
.filter((job) => !isSeriesBatchChildHistoryRow(job))
|
||||
.map((job) => ({
|
||||
...job,
|
||||
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
||||
sortMediaType: resolveMediaType(job),
|
||||
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
|
||||
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
|
||||
})),
|
||||
[jobs]
|
||||
);
|
||||
|
||||
const visibleJobs = useMemo(
|
||||
() => (mediumFilter
|
||||
? preparedJobs.filter((job) => job.sortMediaType === mediumFilter)
|
||||
? preparedJobs.filter((job) => {
|
||||
if (mediumFilter === 'dvd_series') {
|
||||
return job.sortMediaType === 'dvd' && isSeriesDvdJob(job);
|
||||
}
|
||||
return job.sortMediaType === mediumFilter;
|
||||
})
|
||||
: preparedJobs),
|
||||
[preparedJobs, mediumFilter]
|
||||
);
|
||||
@@ -1003,12 +1021,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
};
|
||||
|
||||
const handleReencode = async (row) => {
|
||||
const mediaType = resolveMediaType(row);
|
||||
if (mediaType === 'bluray' || mediaType === 'dvd') {
|
||||
setPendingRestartRow(row);
|
||||
handleAssignOmdb(row);
|
||||
return;
|
||||
}
|
||||
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
||||
return;
|
||||
}
|
||||
@@ -1086,50 +1098,93 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const handleAssignOmdb = (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) return;
|
||||
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 isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
|
||||
const metadataProvider = isSeriesDvd ? 'tmdb' : 'omdb';
|
||||
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
const context = {
|
||||
jobId,
|
||||
detectedTitle: row?.detected_title || row?.title || '',
|
||||
selectedMetadata: {
|
||||
title: row?.title || row?.detected_title || '',
|
||||
year: row?.year || null,
|
||||
imdbId: row?.imdb_id || null,
|
||||
poster: row?.poster_url || null
|
||||
...(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,
|
||||
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,
|
||||
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
|
||||
omdbCandidates: []
|
||||
};
|
||||
setMetadataDialogContext(context);
|
||||
setMetadataDialogVisible(true);
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query) => {
|
||||
const handleOmdbSearch = async (query, options = {}) => {
|
||||
const provider = String(
|
||||
options?.metadataProvider
|
||||
|| metadataDialogContext?.metadataProvider
|
||||
|| 'omdb'
|
||||
).trim().toLowerCase() || 'omdb';
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
|
||||
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
|
||||
?? null;
|
||||
const response = provider === 'tmdb'
|
||||
? await api.searchTmdbSeries(query, tmdbSeasonHint)
|
||||
: await api.searchOmdb(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Suche fehlgeschlagen', detail: error.message });
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSubmit = async (payload) => {
|
||||
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
setOmdbAssignBusy(true);
|
||||
try {
|
||||
await api.assignJobOmdb(payload.jobId, payload);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'OMDB-Metadaten wurden aktualisiert.', life: 3000 });
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Metadaten zugewiesen',
|
||||
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
|
||||
life: 3000
|
||||
});
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
await load();
|
||||
await refreshDetailIfOpen(payload.jobId);
|
||||
|
||||
const rowToRestart = pendingRestartRow;
|
||||
if (rowToRestart && Number(rowToRestart.id) === Number(payload.jobId)) {
|
||||
setPendingRestartRow(null);
|
||||
if (!(await maybeOpenOutputConflictModal(rowToRestart, 'reencode'))) {
|
||||
await executeHistoryAction(rowToRestart, 'reencode');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setOmdbAssignBusy(false);
|
||||
}
|
||||
@@ -1189,6 +1244,37 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledgeError = async (job) => {
|
||||
const jobId = normalizeJobId(job?.id || selectedJob?.id);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setAcknowledgeErrorBusy(true);
|
||||
try {
|
||||
const response = await api.acknowledgeJobError(jobId);
|
||||
const updated = response?.job || null;
|
||||
if (updated) {
|
||||
setSelectedJob(updated);
|
||||
}
|
||||
await load();
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Fehler quittiert',
|
||||
detail: `Job #${jobId} wurde quittiert.`,
|
||||
life: 2500
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Quittierung fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setAcknowledgeErrorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteEntryDialog = () => {
|
||||
if (deleteEntryTargetBusy) {
|
||||
return;
|
||||
@@ -1373,12 +1459,56 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
return <div className="history-dv-poster-fallback">{isAudioSquare ? 'Kein Cover' : 'Kein Poster'}</div>;
|
||||
};
|
||||
|
||||
const renderPresenceChip = (label, available) => (
|
||||
<span className={`history-dv-chip ${available ? 'tone-ok' : 'tone-no'}`}>
|
||||
<i className={`pi ${available ? 'pi-check-circle' : 'pi-times-circle'}`} aria-hidden="true" />
|
||||
<span>{label}: {available ? 'Ja' : 'Nein'}</span>
|
||||
</span>
|
||||
);
|
||||
const renderPresenceChip = (label, available, tone = null, suffix = '') => {
|
||||
const resolvedTone = tone || (available ? 'tone-ok' : 'tone-no');
|
||||
const iconClass = available ? 'pi-check-circle' : 'pi-times-circle';
|
||||
return (
|
||||
<span className={`history-dv-chip ${resolvedTone}`}>
|
||||
<i className={`pi ${iconClass}`} aria-hidden="true" />
|
||||
<span>{label}: {available ? 'Ja' : 'Nein'}{suffix}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSeriesOutputChip = (row) => {
|
||||
const summary = row?.seriesOutputSummary || null;
|
||||
if (!summary) {
|
||||
return renderPresenceChip('Episoden', Boolean(row?.outputStatus?.exists));
|
||||
}
|
||||
const expected = Number(summary.expected || 0);
|
||||
const existing = Number(summary.existing || 0);
|
||||
const hasExpected = Number.isFinite(expected) && expected > 0;
|
||||
const safeExpected = hasExpected ? expected : 0;
|
||||
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
|
||||
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
|
||||
if (safeExisting <= 0) {
|
||||
return renderPresenceChip('Episoden', false, 'tone-no', suffix || ' (0/0)');
|
||||
}
|
||||
if (hasExpected && safeExisting < safeExpected) {
|
||||
return renderPresenceChip('Episoden', true, 'tone-warn', suffix);
|
||||
}
|
||||
return renderPresenceChip('Episoden', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
|
||||
};
|
||||
|
||||
const renderSeriesRawChip = (row) => {
|
||||
const summary = row?.seriesChildSummary?.raw || null;
|
||||
if (!summary) {
|
||||
return renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists));
|
||||
}
|
||||
const expected = Number(summary.expected || 0);
|
||||
const existing = Number(summary.existing || 0);
|
||||
const hasExpected = Number.isFinite(expected) && expected > 0;
|
||||
const safeExpected = hasExpected ? expected : 0;
|
||||
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
|
||||
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
|
||||
if (safeExisting <= 0) {
|
||||
return renderPresenceChip('RAW', false, 'tone-no', suffix || ' (0/0)');
|
||||
}
|
||||
if (hasExpected && safeExisting < safeExpected) {
|
||||
return renderPresenceChip('RAW', true, 'tone-warn', suffix);
|
||||
}
|
||||
return renderPresenceChip('RAW', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
|
||||
};
|
||||
|
||||
const renderSupplementalInfo = (row) => {
|
||||
if (resolveMediaType(row) === 'cd') {
|
||||
@@ -1464,6 +1594,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||
const outputIsAudio = hasAudioOutput(row);
|
||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
|
||||
? row.makemkvInfo.selectedMetadata
|
||||
: {};
|
||||
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
|
||||
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
|
||||
? `Staffel ${selectedMetadata.seasonNumber}`
|
||||
: null;
|
||||
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
|
||||
? `TMDb ${selectedMetadata.tmdbId}`
|
||||
: null;
|
||||
const subtitle = isCdJob
|
||||
? [
|
||||
`#${row?.id || '-'}`,
|
||||
@@ -1471,7 +1611,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
row?.year || null,
|
||||
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||
].filter(Boolean).join(' | ')
|
||||
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
||||
: [
|
||||
`#${row?.id || '-'}`,
|
||||
row?.year || '-',
|
||||
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
||||
tmdbLabel
|
||||
].filter(Boolean).join(' | ');
|
||||
|
||||
return (
|
||||
<div className="col-12" key={row.id}>
|
||||
@@ -1507,8 +1652,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{isSeriesDvd
|
||||
? renderSeriesRawChip(row)
|
||||
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{isSeriesDvd
|
||||
? renderSeriesOutputChip(row)
|
||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</div>
|
||||
|
||||
@@ -1571,8 +1720,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</div>
|
||||
|
||||
<div className="history-dv-flags-row">
|
||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{isSeriesDvd
|
||||
? renderSeriesRawChip(row)
|
||||
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||
{isSeriesDvd
|
||||
? renderSeriesOutputChip(row)
|
||||
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||
</div>
|
||||
|
||||
@@ -1686,6 +1839,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||
onAssignOmdb={handleAssignOmdb}
|
||||
onAssignCdMetadata={handleAssignCdMetadata}
|
||||
onAcknowledgeError={handleAcknowledgeError}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
@@ -1695,6 +1849,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
actionBusy={actionBusy}
|
||||
omdbAssignBusy={omdbAssignBusy}
|
||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
downloadBusyTarget={downloadBusyTarget}
|
||||
@@ -1855,7 +2010,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onHide={() => {
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
setPendingRestartRow(null);
|
||||
}}
|
||||
onSubmit={handleOmdbSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
|
||||
@@ -21,7 +21,7 @@ import otherIndicatorIcon from '../assets/media-other.svg';
|
||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
import { isConverterJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
||||
import { isConverterJob, isSeriesDvdJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
||||
|
||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
||||
@@ -543,11 +543,18 @@ function resolveMediaType(job) {
|
||||
|
||||
function mediaIndicatorMeta(job) {
|
||||
const mediaType = resolveMediaType(job);
|
||||
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(job);
|
||||
if (mediaType === 'bluray') {
|
||||
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
|
||||
}
|
||||
if (mediaType === 'dvd') {
|
||||
return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' };
|
||||
return {
|
||||
mediaType,
|
||||
src: discIndicatorIcon,
|
||||
alt: isSeriesDvd ? 'DVD Serie' : 'DVD',
|
||||
title: isSeriesDvd ? 'DVD Serie' : 'DVD',
|
||||
isSeriesDvd
|
||||
};
|
||||
}
|
||||
if (mediaType === 'cd') {
|
||||
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
||||
@@ -748,7 +755,16 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
mbId: resolvedCdMbId,
|
||||
coverUrl: resolvedCdCoverUrl,
|
||||
imdbId: job?.imdb_id || null,
|
||||
poster: job?.poster_url || resolvedCdCoverUrl || null
|
||||
poster: job?.poster_url || resolvedCdCoverUrl || null,
|
||||
metadataProvider: analyzeContext?.metadataProvider || 'omdb',
|
||||
providerId: analyzeContext?.selectedMetadata?.providerId || null,
|
||||
tmdbId: analyzeContext?.selectedMetadata?.tmdbId || null,
|
||||
metadataKind: analyzeContext?.selectedMetadata?.metadataKind || null,
|
||||
seasonNumber: analyzeContext?.selectedMetadata?.seasonNumber || analyzeContext?.seriesLookupHint?.seasonNumber || null,
|
||||
seasonName: analyzeContext?.selectedMetadata?.seasonName || null,
|
||||
episodeCount: analyzeContext?.selectedMetadata?.episodeCount || 0,
|
||||
episodes: Array.isArray(analyzeContext?.selectedMetadata?.episodes) ? analyzeContext.selectedMetadata.episodes : [],
|
||||
discNumber: analyzeContext?.selectedMetadata?.discNumber || analyzeContext?.seriesLookupHint?.discNumber || null
|
||||
};
|
||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||
@@ -836,6 +852,10 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
: [],
|
||||
selectedPlaylist: analyzeContext.selectedPlaylist || null,
|
||||
selectedTitleId: analyzeContext.selectedTitleId ?? null,
|
||||
metadataProvider: analyzeContext.metadataProvider || 'omdb',
|
||||
metadataCandidates: Array.isArray(analyzeContext.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
||||
seriesAnalysis: analyzeContext.seriesAnalysis || null,
|
||||
seriesLookupHint: analyzeContext.seriesLookupHint || null,
|
||||
omdbCandidates: [],
|
||||
canRestartEncodeFromLastSettings,
|
||||
canRestartReviewFromRaw
|
||||
@@ -1048,7 +1068,8 @@ export default function RipperPage({
|
||||
api.getJobs({
|
||||
statuses: Array.from(ripperStatuses),
|
||||
limit: 160,
|
||||
lite: true
|
||||
lite: true,
|
||||
includeChildren: true
|
||||
}),
|
||||
api.getPipelineQueue()
|
||||
]);
|
||||
@@ -1062,6 +1083,9 @@ export default function RipperPage({
|
||||
if (isConverterJob(job)) {
|
||||
return false;
|
||||
}
|
||||
if (String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container') {
|
||||
return false;
|
||||
}
|
||||
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
||||
if (!ripperStatuses.has(normalizedStatus)) {
|
||||
return false;
|
||||
@@ -1113,8 +1137,12 @@ export default function RipperPage({
|
||||
seen.add(String(id));
|
||||
deduped.push(job);
|
||||
}
|
||||
const filteredJobs = deduped.filter((job) => {
|
||||
const isSeriesBatchChild = Boolean(job?.encodePlan?.seriesBatchChild);
|
||||
return !isSeriesBatchChild;
|
||||
});
|
||||
|
||||
setRipperJobs(deduped);
|
||||
setRipperJobs(filteredJobs);
|
||||
|
||||
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
|
||||
try {
|
||||
@@ -1314,13 +1342,20 @@ export default function RipperPage({
|
||||
title: job?.title || job?.detected_title || context?.detectedTitle || '',
|
||||
year: job?.year || null,
|
||||
imdbId: job?.imdb_id || null,
|
||||
poster: job?.poster_url || null
|
||||
poster: job?.poster_url || null,
|
||||
metadataProvider: context?.metadataProvider || 'omdb',
|
||||
seasonNumber: context?.seriesLookupHint?.seasonNumber || null,
|
||||
discNumber: context?.seriesLookupHint?.discNumber || null
|
||||
};
|
||||
return {
|
||||
...context,
|
||||
jobId: normalizedJobId,
|
||||
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
|
||||
selectedMetadata,
|
||||
metadataProvider: context?.metadataProvider || selectedMetadata?.metadataProvider || 'omdb',
|
||||
metadataCandidates: Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [],
|
||||
seriesAnalysis: context?.seriesAnalysis || null,
|
||||
seriesLookupHint: context?.seriesLookupHint || null,
|
||||
omdbCandidates: Array.isArray(context?.omdbCandidates) ? context.omdbCandidates : []
|
||||
};
|
||||
};
|
||||
@@ -1342,8 +1377,15 @@ export default function RipperPage({
|
||||
title: currentContext?.detectedTitle || '',
|
||||
year: null,
|
||||
imdbId: null,
|
||||
poster: null
|
||||
poster: null,
|
||||
metadataProvider: currentContext?.metadataProvider || 'omdb',
|
||||
seasonNumber: currentContext?.seriesLookupHint?.seasonNumber || null,
|
||||
discNumber: currentContext?.seriesLookupHint?.discNumber || null
|
||||
},
|
||||
metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb',
|
||||
metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [],
|
||||
seriesAnalysis: currentContext?.seriesAnalysis || null,
|
||||
seriesLookupHint: currentContext?.seriesLookupHint || null,
|
||||
omdbCandidates: Array.isArray(currentContext?.omdbCandidates) ? currentContext.omdbCandidates : []
|
||||
};
|
||||
}
|
||||
@@ -1409,8 +1451,16 @@ export default function RipperPage({
|
||||
title: response?.result?.detectedTitle || '',
|
||||
year: null,
|
||||
imdbId: null,
|
||||
poster: null
|
||||
poster: null,
|
||||
metadataProvider: response?.result?.metadataProvider || 'omdb',
|
||||
seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
|
||||
},
|
||||
metadataProvider: response?.result?.metadataProvider || 'omdb',
|
||||
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
||||
? response.result.metadataCandidates
|
||||
: [],
|
||||
seriesAnalysis: response?.result?.seriesAnalysis || null,
|
||||
seriesLookupHint: response?.result?.seriesLookupHint || null,
|
||||
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
|
||||
? response.result.omdbCandidates
|
||||
: []
|
||||
@@ -1443,8 +1493,16 @@ export default function RipperPage({
|
||||
title: response?.result?.detectedTitle || '',
|
||||
year: null,
|
||||
imdbId: null,
|
||||
poster: null
|
||||
poster: null,
|
||||
metadataProvider: response?.result?.metadataProvider || 'omdb',
|
||||
seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
|
||||
},
|
||||
metadataProvider: response?.result?.metadataProvider || 'omdb',
|
||||
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
||||
? response.result.metadataCandidates
|
||||
: [],
|
||||
seriesAnalysis: response?.result?.seriesAnalysis || null,
|
||||
seriesLookupHint: response?.result?.seriesLookupHint || null,
|
||||
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
|
||||
? response.result.omdbCandidates
|
||||
: []
|
||||
@@ -1672,7 +1730,9 @@ export default function RipperPage({
|
||||
if (startOptions.ensureConfirmed) {
|
||||
const confirmPayload = {
|
||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||
selectedEncodeTitleIds: startOptions.selectedEncodeTitleIds ?? null,
|
||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||
episodeAssignments: startOptions.episodeAssignments ?? null,
|
||||
skipPipelineStateUpdate: true
|
||||
};
|
||||
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
|
||||
@@ -1773,16 +1833,26 @@ export default function RipperPage({
|
||||
selectedTrackSelection = null,
|
||||
selectedPostEncodeScriptIds = undefined,
|
||||
selectedUserPresetId = undefined,
|
||||
selectedHandBrakePreset = undefined
|
||||
selectedHandBrakePreset = undefined,
|
||||
selectedEncodeTitleIds = undefined,
|
||||
episodeAssignments = undefined
|
||||
) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
const payload = {
|
||||
selectedEncodeTitleId,
|
||||
selectedTrackSelection,
|
||||
selectedPostEncodeScriptIds
|
||||
selectedTrackSelection
|
||||
};
|
||||
if (selectedEncodeTitleIds !== undefined) {
|
||||
payload.selectedEncodeTitleIds = selectedEncodeTitleIds;
|
||||
}
|
||||
if (selectedPostEncodeScriptIds !== undefined) {
|
||||
payload.selectedPostEncodeScriptIds = selectedPostEncodeScriptIds;
|
||||
}
|
||||
if (episodeAssignments !== undefined) {
|
||||
payload.episodeAssignments = episodeAssignments;
|
||||
}
|
||||
if (selectedUserPresetId !== undefined) {
|
||||
payload.selectedUserPresetId = selectedUserPresetId;
|
||||
}
|
||||
@@ -1818,13 +1888,19 @@ export default function RipperPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleId = null) => {
|
||||
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleIds = null) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
const normalizedIds = (Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : [selectedHandBrakeTitleIds])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
const dedupedIds = Array.from(new Set(normalizedIds));
|
||||
await api.selectMetadata({
|
||||
jobId,
|
||||
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
|
||||
selectedHandBrakeTitleId: dedupedIds[0] || null,
|
||||
selectedHandBrakeTitleIds: dedupedIds
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadRipperJobs();
|
||||
@@ -1914,9 +1990,12 @@ export default function RipperPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
||||
const handleRestartEncodeWithLastSettings = async (jobId, restartOptions = {}) => {
|
||||
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||
const restartMode = String(restartOptions?.restartMode || 'all').trim().toLowerCase() === 'from_abort'
|
||||
? 'from_abort'
|
||||
: 'all';
|
||||
if (job?.encodeSuccess) {
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Encode neu starten',
|
||||
@@ -1934,7 +2013,7 @@ export default function RipperPage({
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.restartEncodeWithLastSettings(jobId);
|
||||
const response = await api.restartEncodeWithLastSettings(jobId, { restartMode });
|
||||
const result = getQueueActionResult(response);
|
||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||
await refreshPipeline();
|
||||
@@ -2115,14 +2194,12 @@ export default function RipperPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOmdbSearch = async (query) => {
|
||||
try {
|
||||
const response = await api.searchOmdb(query);
|
||||
return response.results || [];
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
return [];
|
||||
}
|
||||
const handleMetadataSearch = async (query, options = {}) => {
|
||||
const provider = String(options?.metadataProvider || effectiveMetadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
const response = provider === 'tmdb'
|
||||
? await api.searchTmdbSeries(query)
|
||||
: await api.searchOmdb(query);
|
||||
return response.results || [];
|
||||
};
|
||||
|
||||
const doSelectMetadata = async (payload) => {
|
||||
@@ -2772,6 +2849,42 @@ export default function RipperPage({
|
||||
normalizedStatus === 'READY_TO_ENCODE'
|
||||
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
||||
) && !isCurrentSession;
|
||||
const analyzeContext = getAnalyzeContext(job);
|
||||
const selectedMetadata = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
||||
? pipelineForJob.context.selectedMetadata
|
||||
: (analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
|
||||
? job.makemkvInfo.selectedMetadata
|
||||
: {}));
|
||||
const seriesSeasonNumber = mediaIndicator.isSeriesDvd
|
||||
? (selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null)
|
||||
: null;
|
||||
const seriesTmdbId = mediaIndicator.isSeriesDvd
|
||||
? (selectedMetadata?.tmdbId ?? analyzeContext?.tmdbId ?? null)
|
||||
: null;
|
||||
const seriesDiscNumber = mediaIndicator.isSeriesDvd
|
||||
? (selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null)
|
||||
: null;
|
||||
const isSeriesContainer = mediaIndicator.isSeriesDvd
|
||||
&& String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
||||
const parentContainerId = normalizeJobId(job?.parent_job_id);
|
||||
const seriesTmdbText = String(seriesTmdbId ?? '').trim();
|
||||
const seriesSeasonLabel = mediaIndicator.isSeriesDvd && seriesSeasonNumber
|
||||
? ` Staffel ${seriesSeasonNumber}`
|
||||
: '';
|
||||
const seriesDiscLabel = mediaIndicator.isSeriesDvd && seriesDiscNumber
|
||||
? ` | Disk ${seriesDiscNumber}`
|
||||
: '';
|
||||
const seriesContainerLabel = isSeriesContainer
|
||||
? ' | Container'
|
||||
: (parentContainerId ? ` | Container #${parentContainerId}` : '');
|
||||
const seriesTmdbLabel = mediaIndicator.isSeriesDvd && seriesTmdbText && seriesTmdbText !== '0'
|
||||
? ` | TMDb ${seriesTmdbId}`
|
||||
: '';
|
||||
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
|
||||
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
|
||||
: '';
|
||||
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||
@@ -2784,12 +2897,45 @@ export default function RipperPage({
|
||||
const isAudiobookJob = mediaProfile === 'audiobook'
|
||||
|| mediaIndicator.mediaType === 'audiobook'
|
||||
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
|
||||
const rawProgress = Number(pipelineForJob?.progress ?? 0);
|
||||
const seriesBatchContext = pipelineForJob?.context?.seriesBatch && typeof pipelineForJob.context.seriesBatch === 'object'
|
||||
? pipelineForJob.context.seriesBatch
|
||||
: null;
|
||||
const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
|
||||
const hasSeriesBatchProgress = seriesBatchChildren.length > 0 || Number(seriesBatchContext?.totalCount || 0) > 0;
|
||||
const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
|
||||
const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
|
||||
? Math.trunc(seriesBatchTotalCountRaw)
|
||||
: seriesBatchChildren.length;
|
||||
const seriesBatchAggregateProgress = hasSeriesBatchProgress && seriesBatchTotalCount > 0
|
||||
? (
|
||||
seriesBatchChildren.reduce((sum, child) => {
|
||||
const status = String(child?.status || '').trim().toUpperCase();
|
||||
if (status === 'FINISHED') {
|
||||
return sum + 100;
|
||||
}
|
||||
const childProgress = Number(child?.progress || 0);
|
||||
return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
|
||||
}, 0) / seriesBatchTotalCount
|
||||
)
|
||||
: 0;
|
||||
const runningSeriesChild = hasSeriesBatchProgress
|
||||
? (
|
||||
seriesBatchChildren.find((child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING')
|
||||
|| null
|
||||
)
|
||||
: null;
|
||||
const rawProgress = hasSeriesBatchProgress
|
||||
? seriesBatchAggregateProgress
|
||||
: Number(pipelineForJob?.progress ?? 0);
|
||||
const clampedProgress = Number.isFinite(rawProgress)
|
||||
? Math.max(0, Math.min(100, rawProgress))
|
||||
: 0;
|
||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
||||
const etaLabel = String(pipelineForJob?.eta || '').trim();
|
||||
const etaLabel = String(
|
||||
hasSeriesBatchProgress
|
||||
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
||||
: (pipelineForJob?.eta || '')
|
||||
).trim();
|
||||
|
||||
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
||||
? pipelineForJob.context.selectedMetadata
|
||||
@@ -2814,8 +2960,12 @@ export default function RipperPage({
|
||||
/>
|
||||
<span>#{jobId} | {jobTitle}</span>
|
||||
</strong>
|
||||
{mediaIndicator.isSeriesDvd ? (
|
||||
<small className="ripper-job-subtitle">{seriesTitleSuffix || '-'}</small>
|
||||
) : null}
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||
{normalizedStatus === 'READY_TO_ENCODE'
|
||||
@@ -2934,21 +3084,30 @@ export default function RipperPage({
|
||||
<span>{jobTitle}</span>
|
||||
</strong>
|
||||
<small>
|
||||
#{jobId}
|
||||
{isAudiobookJob
|
||||
? (
|
||||
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
||||
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
||||
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
|
||||
)
|
||||
{mediaIndicator.isSeriesDvd
|
||||
? (seriesTitleSuffix || '-')
|
||||
: (
|
||||
`${job?.year ? ` | ${job.year}` : ''}`
|
||||
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
|
||||
`#${jobId}`
|
||||
+ (isAudiobookJob
|
||||
? (
|
||||
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
||||
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
||||
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
|
||||
)
|
||||
: (
|
||||
`${job?.year ? ` | ${job.year}` : ''}`
|
||||
+ (mediaIndicator.isSeriesDvd ? seriesSeasonLabel : '')
|
||||
+ (mediaIndicator.isSeriesDvd ? seriesDiscLabel : '')
|
||||
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
|
||||
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
|
||||
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
|
||||
))
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||
{normalizedStatus === 'READY_TO_ENCODE'
|
||||
@@ -2995,7 +3154,35 @@ export default function RipperPage({
|
||||
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
||||
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||
const jobProgressEntry = pipeline?.jobProgress?.[String(item.jobId)] || null;
|
||||
const rawQueueProgress = Number(jobProgressEntry?.progress ?? 0);
|
||||
const seriesBatchContext = jobProgressEntry?.context?.seriesBatch && typeof jobProgressEntry.context.seriesBatch === 'object'
|
||||
? jobProgressEntry.context.seriesBatch
|
||||
: null;
|
||||
const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
|
||||
const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
|
||||
const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
|
||||
? Math.trunc(seriesBatchTotalCountRaw)
|
||||
: seriesBatchChildren.length;
|
||||
const seriesBatchOverallProgress = seriesBatchTotalCount > 0
|
||||
? (
|
||||
seriesBatchChildren.reduce((sum, child) => {
|
||||
const status = String(child?.status || '').trim().toUpperCase();
|
||||
if (status === 'FINISHED') {
|
||||
return sum + 100;
|
||||
}
|
||||
const childProgress = Number(child?.progress || 0);
|
||||
return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
|
||||
}, 0) / seriesBatchTotalCount
|
||||
)
|
||||
: null;
|
||||
const runningSeriesChild = seriesBatchChildren.find(
|
||||
(child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING'
|
||||
) || null;
|
||||
const runningChildProgress = Number(runningSeriesChild?.progress ?? NaN);
|
||||
const rawQueueProgress = Number(
|
||||
Number.isFinite(runningChildProgress)
|
||||
? runningChildProgress
|
||||
: (jobProgressEntry?.progress ?? 0)
|
||||
);
|
||||
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
|
||||
return (
|
||||
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
||||
@@ -3006,6 +3193,7 @@ export default function RipperPage({
|
||||
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||
<small>{getStatusLabel(item.status)}</small>
|
||||
{clampedQueueProgress > 0 && (
|
||||
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
||||
@@ -3122,6 +3310,7 @@ export default function RipperPage({
|
||||
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||
<small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
|
||||
</>
|
||||
)}
|
||||
@@ -3497,7 +3686,7 @@ export default function RipperPage({
|
||||
setMetadataDialogReassignMode(false);
|
||||
}}
|
||||
onSubmit={handleMetadataSubmit}
|
||||
onSearch={handleOmdbSearch}
|
||||
onSearch={handleMetadataSearch}
|
||||
busy={busy}
|
||||
/>
|
||||
|
||||
@@ -3521,31 +3710,60 @@ export default function RipperPage({
|
||||
style={{ width: '30rem', maxWidth: '96vw' }}
|
||||
modal
|
||||
>
|
||||
{(() => {
|
||||
const pendingPayload = duplicateJobDialog.pendingPayload || {};
|
||||
const metadataProvider = String(pendingPayload?.metadataProvider || '').trim().toLowerCase();
|
||||
const seasonNumber = Number(pendingPayload?.seasonNumber || 0) || 0;
|
||||
const tmdbId = Number(pendingPayload?.tmdbId || 0) || 0;
|
||||
const isSeriesContainerAssign = metadataProvider === 'tmdb' && seasonNumber > 0 && tmdbId > 0;
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
|
||||
</p>
|
||||
<p>Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?</p>
|
||||
<p>
|
||||
{isSeriesContainerAssign
|
||||
? 'Dieser Job kann dem vorhandenen Serien-Container automatisch zugewiesen werden.'
|
||||
: 'Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?'}
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<Button
|
||||
label="Vorhandenen Job öffnen"
|
||||
icon="pi pi-history"
|
||||
onClick={() => {
|
||||
label={isSeriesContainerAssign ? 'Vorhandenem Container zuweisen' : 'Vorhandenen Job öffnen'}
|
||||
icon={isSeriesContainerAssign ? 'pi pi-sitemap' : 'pi pi-history'}
|
||||
onClick={async () => {
|
||||
const payload = duplicateJobDialog.pendingPayload;
|
||||
const jobId = duplicateJobDialog.existingJob?.id;
|
||||
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
|
||||
if (isSeriesContainerAssign) {
|
||||
await doSelectMetadata(payload);
|
||||
return;
|
||||
}
|
||||
navigate(`/history?open=${jobId}`);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Neuen Job anlegen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={async () => {
|
||||
const payload = duplicateJobDialog.pendingPayload;
|
||||
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
|
||||
await doSelectMetadata(payload);
|
||||
}}
|
||||
/>
|
||||
{isSeriesContainerAssign ? (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label="Neuen Job anlegen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={async () => {
|
||||
const payload = duplicateJobDialog.pendingPayload;
|
||||
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
|
||||
await doSelectMetadata(payload);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -1134,6 +1134,7 @@ export default function SettingsPage() {
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={handleFieldChange}
|
||||
effectivePaths={effectivePaths}
|
||||
onNotify={(msg) => toastRef.current?.show(msg)}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
@@ -1849,6 +1850,7 @@ export default function SettingsPage() {
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1640,6 +1640,29 @@ body {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.metadata-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metadata-filter-grid-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metadata-filter-disc {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.metadata-filter-subtitle {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
margin-top: 0.75rem;
|
||||
display: grid;
|
||||
@@ -2864,6 +2887,12 @@ body {
|
||||
background: #f8e1df;
|
||||
}
|
||||
|
||||
.history-dv-chip.tone-warn {
|
||||
color: #92400e;
|
||||
border-color: #f2c286;
|
||||
background: #fff1db;
|
||||
}
|
||||
|
||||
.history-dv-rating-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3773,6 +3802,115 @@ body {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.episode-fill-box {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
}
|
||||
|
||||
.episode-fill-field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.episode-fill-field label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.episode-fill-field small {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.episode-title-input-row {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.episode-title-input-row label {
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.episode-global-selection-box {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.episode-global-selection-box h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.episode-track-accordion {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--rip-panel);
|
||||
}
|
||||
|
||||
.episode-track-accordion > summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
color: var(--rip-muted);
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.episode-track-accordion[open] > summary {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.series-batch-progress-wrap {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--rip-panel-soft);
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.series-batch-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.series-batch-episodes {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.series-batch-episode-row {
|
||||
border: 1px solid var(--rip-border);
|
||||
border-radius: 0.38rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--rip-panel);
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.series-batch-episode-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.series-batch-episode-title {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.playlist-info-box {
|
||||
border: 1px dashed var(--rip-border);
|
||||
border-radius: 0.4rem;
|
||||
@@ -4076,6 +4214,10 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metadata-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions-row .p-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,56 @@ export function resolveMediaType(job) {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function isSeriesDvdJob(job) {
|
||||
if (resolveMediaType(job) !== 'dvd') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const analyzeContext = job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
||||
? job.makemkvInfo.analyzeContext
|
||||
: {};
|
||||
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
|
||||
? job.makemkvInfo.selectedMetadata
|
||||
: {});
|
||||
|
||||
const metadataProvider = String(
|
||||
analyzeContext?.metadataProvider
|
||||
|| selectedMetadata?.metadataProvider
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const metadataKind = String(
|
||||
selectedMetadata?.metadataKind
|
||||
|| analyzeContext?.metadataKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
|
||||
const seasonNumberText = String(seasonNumberRaw ?? '').trim();
|
||||
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
|
||||
const hasSeasonNumber = seasonNumberText
|
||||
? (Number.isFinite(parsedSeasonNumber) ? parsedSeasonNumber > 0 : true)
|
||||
: false;
|
||||
const seasonName = String(selectedMetadata?.seasonName || '').trim();
|
||||
const episodeCount = Number(selectedMetadata?.episodeCount ?? 0);
|
||||
const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0;
|
||||
const isSeriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind);
|
||||
|
||||
if (isSeriesKind || hasSeasonNumber || Boolean(seasonName) || hasEpisodeList || (Number.isFinite(episodeCount) && episodeCount > 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tmdbIdRaw = selectedMetadata?.tmdbId
|
||||
?? analyzeContext?.tmdbId
|
||||
?? selectedMetadata?.providerId
|
||||
?? analyzeContext?.providerId
|
||||
?? null;
|
||||
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
|
||||
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
|
||||
|
||||
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
|
||||
}
|
||||
|
||||
export function isConverterJob(job) {
|
||||
return resolveMediaType(job) === 'converter';
|
||||
}
|
||||
@@ -233,4 +283,3 @@ export function classifyJob(job) {
|
||||
family
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -776,6 +776,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
|
||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/series"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||
@@ -837,6 +838,7 @@ LOG_LEVEL=info
|
||||
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
||||
DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series
|
||||
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||
EOF
|
||||
|
||||
@@ -554,6 +554,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
|
||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/series"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||
@@ -616,6 +617,7 @@ CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
|
||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
||||
DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series
|
||||
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||
EOF
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.12.0-19",
|
||||
"version": "0.13.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user