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.)
|
# Leer lassen = relativ zum data/-Verzeichnis der DB (data/output/raw etc.)
|
||||||
DEFAULT_RAW_DIR=
|
DEFAULT_RAW_DIR=
|
||||||
DEFAULT_MOVIE_DIR=
|
DEFAULT_MOVIE_DIR=
|
||||||
|
DEFAULT_SERIES_DIR=
|
||||||
DEFAULT_CD_DIR=
|
DEFAULT_CD_DIR=
|
||||||
DEFAULT_DOWNLOAD_DIR=
|
DEFAULT_DOWNLOAD_DIR=
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ module.exports = {
|
|||||||
logLevel: process.env.LOG_LEVEL || 'info',
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
|
||||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
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'),
|
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
||||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
||||||
|
|||||||
+152
-9
@@ -838,6 +838,18 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
|||||||
required: 0,
|
required: 0,
|
||||||
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
|
||||||
validation_json: '{}'
|
validation_json: '{}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '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'`
|
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
|
// Add movie_dir_cd if not already present
|
||||||
await db.run(
|
await db.run(
|
||||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
@@ -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_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
|
// Converter-Pfade und Eigentümer
|
||||||
await db.run(
|
await db.run(
|
||||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
@@ -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')`);
|
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(`
|
await db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -1093,6 +1187,55 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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) {
|
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,
|
cmd: scanConfig.cmd,
|
||||||
args: scanConfig.args,
|
args: scanConfig.args,
|
||||||
collectLines: scanLines,
|
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
|
silent: reviewSilent
|
||||||
});
|
});
|
||||||
} catch (cmdError) {
|
} catch (cmdError) {
|
||||||
@@ -179,6 +181,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
|||||||
cmd: scanConfig.cmd,
|
cmd: scanConfig.cmd,
|
||||||
args: scanConfig.args,
|
args: scanConfig.args,
|
||||||
onStdoutLine: (line) => scanLines.push(line),
|
onStdoutLine: (line) => scanLines.push(line),
|
||||||
|
onStderrLine: (line) => scanLines.push(line),
|
||||||
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -219,6 +222,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
|||||||
deviceInfo,
|
deviceInfo,
|
||||||
selectedTitleId = null,
|
selectedTitleId = null,
|
||||||
backupOutputBase = null,
|
backupOutputBase = null,
|
||||||
|
disableMinLengthFilter = false,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
onProcessHandle
|
onProcessHandle
|
||||||
} = ctx.extra || {};
|
} = ctx.extra || {};
|
||||||
@@ -237,7 +241,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
|||||||
selectedTitleId,
|
selectedTitleId,
|
||||||
mediaProfile: this.mediaProfile,
|
mediaProfile: this.mediaProfile,
|
||||||
settingsMap: settings,
|
settingsMap: settings,
|
||||||
backupOutputBase
|
backupOutputBase,
|
||||||
|
disableMinLengthFilter
|
||||||
});
|
});
|
||||||
|
|
||||||
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||||
@@ -249,7 +254,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
|||||||
args: ripConfig.args,
|
args: ripConfig.args,
|
||||||
ripMode,
|
ripMode,
|
||||||
rawJobDir,
|
rawJobDir,
|
||||||
selectedTitleId
|
selectedTitleId,
|
||||||
|
disableMinLengthFilter
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.emitProgress(0, ripMode === 'backup'
|
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 lines = [];
|
||||||
const handle = spawnTrackedProcess({
|
const handle = spawnTrackedProcess({
|
||||||
cmd,
|
cmd,
|
||||||
@@ -454,7 +460,12 @@ async function _runCommandCaptured({ cmd, args, onStdoutLine, context }) {
|
|||||||
onStdoutLine(line);
|
onStdoutLine(line);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStderrLine: () => {},
|
onStderrLine: (line) => {
|
||||||
|
lines.push(line);
|
||||||
|
if (typeof onStderrLine === 'function') {
|
||||||
|
onStderrLine(line);
|
||||||
|
}
|
||||||
|
},
|
||||||
context: context || {}
|
context: context || {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ router.get(
|
|||||||
.map((value) => String(value || '').trim())
|
.map((value) => String(value || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
|
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', {
|
logger.info('get:jobs', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
status: req.query.status,
|
status: req.query.status,
|
||||||
statuses: statuses.length > 0 ? statuses : null,
|
statuses: statuses.length > 0 ? statuses : null,
|
||||||
search: req.query.search,
|
search: req.query.search,
|
||||||
limit,
|
limit,
|
||||||
lite
|
lite,
|
||||||
|
includeChildren
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobs = await historyService.getJobs({
|
const jobs = await historyService.getJobs({
|
||||||
@@ -32,7 +34,8 @@ router.get(
|
|||||||
statuses,
|
statuses,
|
||||||
search: req.query.search,
|
search: req.query.search,
|
||||||
limit,
|
limit,
|
||||||
includeFsChecks: !lite
|
includeFsChecks: !lite,
|
||||||
|
includeChildren
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ jobs });
|
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(
|
router.post(
|
||||||
'/:id/delete-files',
|
'/:id/delete-files',
|
||||||
asyncHandler(async (req, res) => {
|
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(
|
router.get(
|
||||||
'/cd/musicbrainz/search',
|
'/cd/musicbrainz/search',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
@@ -341,7 +352,26 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
'/select-metadata',
|
'/select-metadata',
|
||||||
asyncHandler(async (req, res) => {
|
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) {
|
if (!jobId) {
|
||||||
const error = new Error('jobId fehlt.');
|
const error = new Error('jobId fehlt.');
|
||||||
@@ -358,7 +388,13 @@ router.post(
|
|||||||
poster,
|
poster,
|
||||||
fromOmdb,
|
fromOmdb,
|
||||||
selectedPlaylist,
|
selectedPlaylist,
|
||||||
selectedHandBrakeTitleId
|
selectedHandBrakeTitleId,
|
||||||
|
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
|
||||||
|
metadataProvider,
|
||||||
|
providerId,
|
||||||
|
tmdbId,
|
||||||
|
seasonNumber,
|
||||||
|
discNumber
|
||||||
});
|
});
|
||||||
|
|
||||||
const job = await pipelineService.selectMetadata({
|
const job = await pipelineService.selectMetadata({
|
||||||
@@ -369,7 +405,17 @@ router.post(
|
|||||||
poster,
|
poster,
|
||||||
fromOmdb,
|
fromOmdb,
|
||||||
selectedPlaylist,
|
selectedPlaylist,
|
||||||
selectedHandBrakeTitleId
|
selectedHandBrakeTitleId,
|
||||||
|
selectedHandBrakeTitleIds,
|
||||||
|
metadataProvider,
|
||||||
|
providerId,
|
||||||
|
tmdbId,
|
||||||
|
metadataKind,
|
||||||
|
seasonNumber,
|
||||||
|
seasonName,
|
||||||
|
episodeCount,
|
||||||
|
episodes,
|
||||||
|
discNumber
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ job });
|
res.json({ job });
|
||||||
@@ -391,7 +437,9 @@ router.post(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||||
|
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
|
||||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||||
|
const episodeAssignments = req.body?.episodeAssignments ?? null;
|
||||||
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
|
||||||
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
|
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
|
||||||
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
||||||
@@ -403,7 +451,9 @@ router.post(
|
|||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
jobId,
|
jobId,
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
|
selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0,
|
||||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||||
|
episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'),
|
||||||
skipPipelineStateUpdate,
|
skipPipelineStateUpdate,
|
||||||
selectedUserPresetId,
|
selectedUserPresetId,
|
||||||
selectedHandBrakePreset,
|
selectedHandBrakePreset,
|
||||||
@@ -422,7 +472,9 @@ router.post(
|
|||||||
});
|
});
|
||||||
const job = await pipelineService.confirmEncodeReview(jobId, {
|
const job = await pipelineService.confirmEncodeReview(jobId, {
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
|
selectedEncodeTitleIds,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
|
episodeAssignments,
|
||||||
selectedPostEncodeScriptIds,
|
selectedPostEncodeScriptIds,
|
||||||
selectedPreEncodeScriptIds,
|
selectedPreEncodeScriptIds,
|
||||||
selectedPostEncodeChainIds,
|
selectedPostEncodeChainIds,
|
||||||
@@ -525,8 +577,19 @@ router.post(
|
|||||||
const jobId = Number(req.params.jobId);
|
const jobId = Number(req.params.jobId);
|
||||||
const keepBoth = Boolean(req.body?.keepBoth);
|
const keepBoth = Boolean(req.body?.keepBoth);
|
||||||
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
|
||||||
logger.info('post:restart-encode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
|
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
|
||||||
const result = await pipelineService.restartEncodeWithLastSettings(jobId, { keepBoth, deleteFolders });
|
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 });
|
res.json({ result });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function isSensitiveSettingKey(key) {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return false;
|
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(
|
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)
|
// User preferences (UI state, persisted per-installation)
|
||||||
router.get(
|
router.get(
|
||||||
'/prefs/:key',
|
'/prefs/:key',
|
||||||
|
|||||||
@@ -374,13 +374,67 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return String(devicePath || '').trim();
|
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);
|
const normalized = this.normalizeDevicePath(devicePath);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
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,
|
count: 0,
|
||||||
owners: []
|
owners: []
|
||||||
};
|
};
|
||||||
@@ -389,16 +443,16 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (owner) {
|
if (owner) {
|
||||||
entry.owners.push(owner);
|
entry.owners.push(owner);
|
||||||
}
|
}
|
||||||
this.deviceLocks.set(normalized, entry);
|
this.deviceLocks.set(lockKey, entry);
|
||||||
|
|
||||||
logger.info('lock:add', {
|
logger.info('lock:add', {
|
||||||
devicePath: normalized,
|
devicePath: lockKey,
|
||||||
count: entry.count,
|
count: entry.count,
|
||||||
owner
|
owner
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devicePath: normalized,
|
devicePath: lockKey,
|
||||||
owner
|
owner
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -409,24 +463,33 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return;
|
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) {
|
if (!entry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.count = Math.max(0, entry.count - 1);
|
entry.count = Math.max(0, entry.count - 1);
|
||||||
if (entry.count === 0) {
|
if (entry.count === 0) {
|
||||||
this.deviceLocks.delete(normalized);
|
this.deviceLocks.delete(lockKey);
|
||||||
logger.info('lock:remove', {
|
logger.info('lock:remove', {
|
||||||
devicePath: normalized,
|
devicePath: lockKey,
|
||||||
owner
|
owner
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.deviceLocks.set(normalized, entry);
|
this.deviceLocks.set(lockKey, entry);
|
||||||
logger.info('lock:decrement', {
|
logger.info('lock:decrement', {
|
||||||
devicePath: normalized,
|
devicePath: lockKey,
|
||||||
count: entry.count,
|
count: entry.count,
|
||||||
owner
|
owner
|
||||||
});
|
});
|
||||||
@@ -438,20 +501,26 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = this.deviceLocks.get(normalized);
|
const candidateKeys = Array.from(this.deviceLocks.keys())
|
||||||
if (!entry) {
|
.filter((key) => this._isSameDevicePath(key, normalized));
|
||||||
|
if (candidateKeys.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousCount = Math.max(0, Number(entry.count) || 0);
|
let removed = 0;
|
||||||
this.deviceLocks.delete(normalized);
|
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', {
|
logger.warn('lock:force-remove', {
|
||||||
devicePath: normalized,
|
devicePath: lockKey,
|
||||||
previousCount,
|
previousCount,
|
||||||
reason: String(options?.reason || '').trim() || null,
|
reason: String(options?.reason || '').trim() || null,
|
||||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||||
});
|
});
|
||||||
return previousCount;
|
}
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
isDeviceLocked(devicePath) {
|
isDeviceLocked(devicePath) {
|
||||||
@@ -459,7 +528,11 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return false;
|
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() {
|
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
+3459
-224
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ const { setLogRootDir } = require('./logPathService');
|
|||||||
const {
|
const {
|
||||||
defaultRawDir: DEFAULT_RAW_DIR,
|
defaultRawDir: DEFAULT_RAW_DIR,
|
||||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||||
|
defaultSeriesDir: DEFAULT_SERIES_DIR,
|
||||||
defaultCdDir: DEFAULT_CD_DIR,
|
defaultCdDir: DEFAULT_CD_DIR,
|
||||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||||
@@ -38,6 +39,7 @@ const HANDBRAKE_PRESET_RELEVANT_SETTING_KEYS = new Set([
|
|||||||
const SENSITIVE_SETTING_KEYS = new Set([
|
const SENSITIVE_SETTING_KEYS = new Set([
|
||||||
'makemkv_registration_key',
|
'makemkv_registration_key',
|
||||||
'omdb_api_key',
|
'omdb_api_key',
|
||||||
|
'tmdb_api_read_access_token',
|
||||||
'pushover_token',
|
'pushover_token',
|
||||||
'pushover_user'
|
'pushover_user'
|
||||||
]);
|
]);
|
||||||
@@ -62,18 +64,30 @@ const PROFILED_SETTINGS = {
|
|||||||
cd: 'raw_dir_cd_owner',
|
cd: 'raw_dir_cd_owner',
|
||||||
audiobook: 'raw_dir_audiobook_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: {
|
movie_dir: {
|
||||||
bluray: 'movie_dir_bluray',
|
bluray: 'movie_dir_bluray',
|
||||||
dvd: 'movie_dir_dvd',
|
dvd: 'movie_dir_dvd',
|
||||||
cd: 'movie_dir_cd',
|
cd: 'movie_dir_cd',
|
||||||
audiobook: 'movie_dir_audiobook'
|
audiobook: 'movie_dir_audiobook'
|
||||||
},
|
},
|
||||||
|
series_dir: {
|
||||||
|
dvd: 'series_dir_dvd'
|
||||||
|
},
|
||||||
movie_dir_owner: {
|
movie_dir_owner: {
|
||||||
bluray: 'movie_dir_bluray_owner',
|
bluray: 'movie_dir_bluray_owner',
|
||||||
dvd: 'movie_dir_dvd_owner',
|
dvd: 'movie_dir_dvd_owner',
|
||||||
cd: 'movie_dir_cd_owner',
|
cd: 'movie_dir_cd_owner',
|
||||||
audiobook: 'movie_dir_audiobook_owner'
|
audiobook: 'movie_dir_audiobook_owner'
|
||||||
},
|
},
|
||||||
|
series_dir_owner: {
|
||||||
|
dvd: 'series_dir_dvd_owner'
|
||||||
|
},
|
||||||
mediainfo_extra_args: {
|
mediainfo_extra_args: {
|
||||||
bluray: 'mediainfo_extra_args_bluray',
|
bluray: 'mediainfo_extra_args_bluray',
|
||||||
dvd: 'mediainfo_extra_args_dvd'
|
dvd: 'mediainfo_extra_args_dvd'
|
||||||
@@ -111,8 +125,12 @@ const PROFILED_SETTINGS = {
|
|||||||
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
const STRICT_PROFILE_ONLY_SETTING_KEYS = new Set([
|
||||||
'raw_dir',
|
'raw_dir',
|
||||||
'raw_dir_owner',
|
'raw_dir_owner',
|
||||||
|
'series_raw_dir',
|
||||||
|
'series_raw_dir_owner',
|
||||||
'movie_dir',
|
'movie_dir',
|
||||||
'movie_dir_owner'
|
'movie_dir_owner',
|
||||||
|
'series_dir',
|
||||||
|
'series_dir_owner'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function applyRuntimeLogDirSetting(rawValue) {
|
function applyRuntimeLogDirSetting(rawValue) {
|
||||||
@@ -813,6 +831,8 @@ class SettingsService {
|
|||||||
} else {
|
} else {
|
||||||
resolvedValue = DEFAULT_RAW_DIR;
|
resolvedValue = DEFAULT_RAW_DIR;
|
||||||
}
|
}
|
||||||
|
} else if (legacyKey === 'series_dir') {
|
||||||
|
resolvedValue = DEFAULT_SERIES_DIR;
|
||||||
} else if (legacyKey === 'movie_dir') {
|
} else if (legacyKey === 'movie_dir') {
|
||||||
if (normalizedRequestedProfile === 'cd') {
|
if (normalizedRequestedProfile === 'cd') {
|
||||||
resolvedValue = DEFAULT_CD_DIR;
|
resolvedValue = DEFAULT_CD_DIR;
|
||||||
@@ -858,7 +878,7 @@ class SettingsService {
|
|||||||
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
const audiobook = this.resolveEffectiveToolSettings(map, 'audiobook');
|
||||||
return {
|
return {
|
||||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
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 },
|
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||||
downloads: { path: bluray.download_dir },
|
downloads: { path: bluray.download_dir },
|
||||||
@@ -870,6 +890,7 @@ class SettingsService {
|
|||||||
defaults: {
|
defaults: {
|
||||||
raw: DEFAULT_RAW_DIR,
|
raw: DEFAULT_RAW_DIR,
|
||||||
movies: DEFAULT_MOVIE_DIR,
|
movies: DEFAULT_MOVIE_DIR,
|
||||||
|
series: DEFAULT_SERIES_DIR,
|
||||||
cd: DEFAULT_CD_DIR,
|
cd: DEFAULT_CD_DIR,
|
||||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||||
@@ -1116,6 +1137,7 @@ class SettingsService {
|
|||||||
: 'mkv';
|
: 'mkv';
|
||||||
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
||||||
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
|
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
|
||||||
|
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||||
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
||||||
let extra = [];
|
let extra = [];
|
||||||
let baseArgs = [];
|
let baseArgs = [];
|
||||||
@@ -1141,6 +1163,9 @@ class SettingsService {
|
|||||||
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
const minLength = Number(map.makemkv_min_length_minutes || 60);
|
||||||
const hasExplicitTitle = rawSelectedTitleId !== null;
|
const hasExplicitTitle = rawSelectedTitleId !== null;
|
||||||
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
const targetTitle = hasExplicitTitle ? String(Math.trunc(rawSelectedTitleId)) : 'all';
|
||||||
|
const minLengthSeconds = Number.isFinite(minLength) && minLength > 0
|
||||||
|
? Math.round(minLength * 60)
|
||||||
|
: 0;
|
||||||
if (hasExplicitTitle) {
|
if (hasExplicitTitle) {
|
||||||
baseArgs = [
|
baseArgs = [
|
||||||
'-r', '--progress=-same',
|
'-r', '--progress=-same',
|
||||||
@@ -1150,9 +1175,12 @@ class SettingsService {
|
|||||||
rawJobDir
|
rawJobDir
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
const minLengthArgs = (!disableMinLengthFilter && minLengthSeconds > 0)
|
||||||
|
? [`--minlength=${minLengthSeconds}`]
|
||||||
|
: [];
|
||||||
baseArgs = [
|
baseArgs = [
|
||||||
'-r', '--progress=-same',
|
'-r', '--progress=-same',
|
||||||
'--minlength=' + Math.round(minLength * 60),
|
...minLengthArgs,
|
||||||
'mkv',
|
'mkv',
|
||||||
sourceArg,
|
sourceArg,
|
||||||
targetTitle,
|
targetTitle,
|
||||||
@@ -1166,6 +1194,7 @@ class SettingsService {
|
|||||||
ripMode,
|
ripMode,
|
||||||
rawJobDir,
|
rawJobDir,
|
||||||
deviceInfo,
|
deviceInfo,
|
||||||
|
disableMinLengthFilter: ripMode === 'mkv' ? disableMinLengthFilter : false,
|
||||||
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
|
selectedTitleId: ripMode === 'mkv' && Number.isFinite(rawSelectedTitleId) && rawSelectedTitleId >= 0
|
||||||
? Math.trunc(rawSelectedTitleId)
|
? Math.trunc(rawSelectedTitleId)
|
||||||
: null
|
: 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) {
|
function renderTemplate(template, values) {
|
||||||
const rawSource = String(template || '${title} (${year})');
|
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 resolveToken = (rawKey) => {
|
||||||
const key = String(rawKey || '').trim();
|
const { format, key } = parseToken(rawKey);
|
||||||
const val = values[key];
|
const val = values[key];
|
||||||
if (val === undefined || val === null || val === '') {
|
if (val === undefined || val === null || val === '') {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
return String(val);
|
return applyFormat(val, format);
|
||||||
};
|
};
|
||||||
|
|
||||||
const source = (
|
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_rel_path ON converter_scan_entries(rel_path);
|
||||||
CREATE INDEX idx_converter_scan_entries_job_id ON converter_scan_entries(job_id);
|
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
|
-- 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);
|
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_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)
|
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);
|
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);
|
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);
|
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_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
|
-- Laufwerk
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('drive_mode', 'Laufwerk', 'Laufwerksmodus', 'select', 1, 'Auto-Discovery oder explizites Device.', 'auto', '[{"label":"Auto Discovery","value":"auto"},{"label":"Explizites Device","value":"explicit"}]', '{}', 10);
|
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);
|
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_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)
|
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);
|
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);
|
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);
|
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_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)
|
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);
|
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');
|
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);
|
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_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
|
-- Tools
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('makemkv_command', 'Tools', 'MakeMKV Kommando', 'string', 1, 'Pfad oder Befehl für makemkvcon.', 'makemkvcon', '[]', '{"minLength":1}', 200);
|
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);
|
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_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
|
-- Tools – CD
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('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);
|
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);
|
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_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)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
VALUES ('omdb_default_type', 'Metadaten', 'OMDb Typ', 'select', 1, 'Vorauswahl für Suche.', 'movie', '[{"label":"Movie","value":"movie"},{"label":"Series","value":"series"},{"label":"Episode","value":"episode"}]', '{}', 410);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('omdb_default_type', 'movie');
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -363,6 +363,12 @@ export const api = {
|
|||||||
getDetectedDrives() {
|
getDetectedDrives() {
|
||||||
return request('/settings/drives');
|
return request('/settings/drives');
|
||||||
},
|
},
|
||||||
|
forceUnlockDrive(payload = {}) {
|
||||||
|
return request('/settings/drives/force-unlock', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
},
|
||||||
async getHandBrakePresets(options = {}) {
|
async getHandBrakePresets(options = {}) {
|
||||||
const result = await requestCachedGet('/settings/handbrake-presets', {
|
const result = await requestCachedGet('/settings/handbrake-presets', {
|
||||||
ttlMs: 10 * 60 * 1000,
|
ttlMs: 10 * 60 * 1000,
|
||||||
@@ -547,6 +553,14 @@ export const api = {
|
|||||||
searchOmdb(q) {
|
searchOmdb(q) {
|
||||||
return request(`/pipeline/omdb/search?q=${encodeURIComponent(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) {
|
searchMusicBrainz(q) {
|
||||||
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
|
||||||
},
|
},
|
||||||
@@ -675,6 +689,7 @@ export const api = {
|
|||||||
const body = {};
|
const body = {};
|
||||||
if (options.keepBoth) body.keepBoth = true;
|
if (options.keepBoth) body.keepBoth = true;
|
||||||
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
|
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}`, {
|
const result = await request(`/pipeline/restart-encode/${jobId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
|
||||||
@@ -743,6 +758,9 @@ export const api = {
|
|||||||
if (params.lite) {
|
if (params.lite) {
|
||||||
query.set('lite', '1');
|
query.set('lite', '1');
|
||||||
}
|
}
|
||||||
|
if (params.includeChildren) {
|
||||||
|
query.set('includeChildren', '1');
|
||||||
|
}
|
||||||
const suffix = query.toString() ? `?${query.toString()}` : '';
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
||||||
return request(`/history${suffix}`);
|
return request(`/history${suffix}`);
|
||||||
},
|
},
|
||||||
@@ -773,6 +791,13 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history']);
|
afterMutationInvalidate(['/history']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
async acknowledgeJobError(jobId) {
|
||||||
|
const result = await request(`/history/${jobId}/error/ack`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async deleteJobFiles(jobId, target = 'both') {
|
async deleteJobFiles(jobId, target = 'both') {
|
||||||
const result = await request(`/history/${jobId}/delete-files`, {
|
const result = await request(`/history/${jobId}/delete-files`, {
|
||||||
method: 'POST',
|
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 [drives, setDrives] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -307,22 +308,71 @@ function DetectedDrivesInfo() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
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 (
|
return (
|
||||||
<div className="detected-drives-info">
|
<div className="detected-drives-info">
|
||||||
<div className="detected-drives-header">
|
<div className="detected-drives-header">
|
||||||
<span className="detected-drives-label">Erkannte optische Laufwerke</span>
|
<span className="detected-drives-label">Erkannte optische Laufwerke</span>
|
||||||
|
<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
|
<Button
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
text
|
text
|
||||||
rounded
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={unlocking}
|
||||||
onClick={load}
|
onClick={load}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Laufwerke neu einlesen"
|
aria-label="Laufwerke neu einlesen"
|
||||||
title="Laufwerke neu einlesen"
|
title="Laufwerke neu einlesen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{drives === null || loading
|
{drives === null || loading
|
||||||
? <span className="detected-drives-empty">Wird geladen…</span>
|
? <span className="detected-drives-empty">Wird geladen…</span>
|
||||||
: drives.length === 0
|
: drives.length === 0
|
||||||
@@ -334,6 +384,7 @@ function DetectedDrivesInfo() {
|
|||||||
<th>Gerät</th>
|
<th>Gerät</th>
|
||||||
<th>Modell</th>
|
<th>Modell</th>
|
||||||
<th>MakeMKV Index</th>
|
<th>MakeMKV Index</th>
|
||||||
|
{expertModeEnabled ? <th>Freigabe</th> : null}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -342,6 +393,19 @@ function DetectedDrivesInfo() {
|
|||||||
<td><code>{d.path}</code></td>
|
<td><code>{d.path}</code></td>
|
||||||
<td>{d.model || '—'}</td>
|
<td>{d.model || '—'}</td>
|
||||||
<td><Tag value={d.discArg ?? '—'} severity="info" /></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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -438,7 +502,15 @@ function buildToolSections(settings) {
|
|||||||
|
|
||||||
// Path keys per medium — _owner keys are rendered inline
|
// Path keys per medium — _owner keys are rendered inline
|
||||||
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
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 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 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'];
|
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 defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||||
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
|
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 defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||||
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
||||||
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
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 blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||||
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||||
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
||||||
|
const dvdSeriesRaw = ep.dvd?.seriesRaw || dvdRaw;
|
||||||
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||||
|
const dvdSeries = ep.dvd?.series || defaultSeries;
|
||||||
const cdRaw = ep.cd?.raw || defaultCd;
|
const cdRaw = ep.cd?.raw || defaultCd;
|
||||||
const cdMovies = ep.cd?.movies || cdRaw;
|
const cdMovies = ep.cd?.movies || cdRaw;
|
||||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
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>}
|
{isDefault(dvdMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td><strong>CD / Audio</strong></td>
|
<td><strong>CD / Audio</strong></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -915,7 +1001,8 @@ export default function DynamicSettingsForm({
|
|||||||
errors,
|
errors,
|
||||||
dirtyKeys,
|
dirtyKeys,
|
||||||
onChange,
|
onChange,
|
||||||
effectivePaths
|
effectivePaths,
|
||||||
|
onNotify
|
||||||
}) {
|
}) {
|
||||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||||
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||||
@@ -1009,7 +1096,7 @@ export default function DynamicSettingsForm({
|
|||||||
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
|
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
|
||||||
return (
|
return (
|
||||||
<div className="settings-sections">
|
<div className="settings-sections">
|
||||||
{isDriveCategory && <DetectedDrivesInfo />}
|
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<section
|
<section
|
||||||
key={`${category?.category || 'category'}-${section.id}`}
|
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 discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
|
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
|
||||||
|
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
|
||||||
|
|
||||||
const CD_FORMAT_LABELS = {
|
const CD_FORMAT_LABELS = {
|
||||||
flac: 'FLAC',
|
flac: 'FLAC',
|
||||||
@@ -190,6 +191,98 @@ function buildExecutedHandBrakeCommand(handbrakeInfo) {
|
|||||||
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
|
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) {
|
function buildConfiguredScriptAndChainSelection(job) {
|
||||||
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
||||||
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
|
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
|
||||||
@@ -505,6 +598,147 @@ function omdbRottenTomatoesScore(omdbInfo) {
|
|||||||
return omdbField(entry?.Value);
|
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 }) {
|
function BoolState({ value }) {
|
||||||
const isTrue = Boolean(value);
|
const isTrue = Boolean(value);
|
||||||
return isTrue ? (
|
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 }) {
|
function resolveSelectionStateMeta({ selected, outcome }) {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return {
|
return {
|
||||||
@@ -616,6 +877,7 @@ export default function JobDetailDialog({
|
|||||||
logLoadingMode = null,
|
logLoadingMode = null,
|
||||||
onAssignOmdb,
|
onAssignOmdb,
|
||||||
onAssignCdMetadata,
|
onAssignCdMetadata,
|
||||||
|
onAcknowledgeError,
|
||||||
onResumeReady,
|
onResumeReady,
|
||||||
onRestartEncode,
|
onRestartEncode,
|
||||||
onRestartReview,
|
onRestartReview,
|
||||||
@@ -631,6 +893,7 @@ export default function JobDetailDialog({
|
|||||||
isQueued = false,
|
isQueued = false,
|
||||||
omdbAssignBusy = false,
|
omdbAssignBusy = false,
|
||||||
cdMetadataAssignBusy = false,
|
cdMetadataAssignBusy = false,
|
||||||
|
acknowledgeErrorBusy = false,
|
||||||
actionBusy = false,
|
actionBusy = false,
|
||||||
cancelBusy = false,
|
cancelBusy = false,
|
||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
@@ -639,16 +902,39 @@ export default function JobDetailDialog({
|
|||||||
downloadFolderBusyPath = null
|
downloadFolderBusyPath = null
|
||||||
}) {
|
}) {
|
||||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
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 statusUpper = String(job?.status || '').trim().toUpperCase();
|
||||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
|
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 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 showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
|
||||||
const showFinalLog = !running;
|
const showFinalLog = !running;
|
||||||
const mediaType = resolveMediaType(job);
|
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 isCd = mediaType === 'cd';
|
||||||
const isAudiobook = mediaType === 'audiobook';
|
const isAudiobook = mediaType === 'audiobook';
|
||||||
const isConverter = mediaType === 'converter';
|
const isConverter = mediaType === 'converter';
|
||||||
@@ -811,7 +1097,7 @@ export default function JobDetailDialog({
|
|||||||
const mediaTypeLabel = mediaType === 'bluray'
|
const mediaTypeLabel = mediaType === 'bluray'
|
||||||
? 'Blu-ray'
|
? 'Blu-ray'
|
||||||
: mediaType === 'dvd'
|
: mediaType === 'dvd'
|
||||||
? 'DVD'
|
? (isDvdSeries ? 'DVD Serie' : 'DVD')
|
||||||
: isCd
|
: isCd
|
||||||
? 'Audio CD'
|
? 'Audio CD'
|
||||||
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
|
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
|
||||||
@@ -823,6 +1109,27 @@ export default function JobDetailDialog({
|
|||||||
const mediaTypeAlt = mediaTypeLabel;
|
const mediaTypeAlt = mediaTypeLabel;
|
||||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||||
|
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 configuredSelection = buildConfiguredScriptAndChainSelection(job);
|
||||||
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|
||||||
|| configuredSelection.postScriptIds.length > 0
|
|| configuredSelection.postScriptIds.length > 0
|
||||||
@@ -832,8 +1139,13 @@ export default function JobDetailDialog({
|
|||||||
? job.encodePlan.userPreset
|
? job.encodePlan.userPreset
|
||||||
: null;
|
: null;
|
||||||
const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? 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 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 canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||||
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||||
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
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">
|
<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-list">
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Titel:</strong>
|
<strong>Titel:</strong>
|
||||||
<span>{job.title || job.detected_title || '-'}</span>
|
<span>{job.title || job.detected_title || '-'}</span>
|
||||||
</div>
|
</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">
|
<div className="job-meta-item">
|
||||||
<strong>Jahr:</strong>
|
<strong>Jahr:</strong>
|
||||||
<span>{job.year || '-'}</span>
|
<span>{job.year || '-'}</span>
|
||||||
@@ -1024,11 +1348,17 @@ export default function JobDetailDialog({
|
|||||||
<>
|
<>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>IMDb:</strong>
|
<strong>IMDb:</strong>
|
||||||
<span>{job.imdb_id || '-'}</span>
|
<span>{displayedImdbId || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isDvdSeries ? (
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>OMDb Match:</strong>
|
<strong>TMDb:</strong>
|
||||||
<BoolState value={job.selected_from_omdb} />
|
<span>{metadataContext?.selectedMetadata?.tmdbId || '-'}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>{metadataDetails.matchLabel}:</strong>
|
||||||
|
<BoolState value={metadataDetails.hasMatch} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1044,32 +1374,41 @@ export default function JobDetailDialog({
|
|||||||
|
|
||||||
{!isAudiobook ? (
|
{!isAudiobook ? (
|
||||||
<section className="job-meta-block job-meta-block-film">
|
<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-list">
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Regisseur:</strong>
|
<strong>Regisseur:</strong>
|
||||||
<span>{omdbField(omdbInfo?.Director)}</span>
|
<span>{omdbField(metadataDetails.director)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Schauspieler:</strong>
|
<strong>Schauspieler:</strong>
|
||||||
<span>{omdbField(omdbInfo?.Actors)}</span>
|
<span>{omdbField(metadataDetails.actors)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Laufzeit:</strong>
|
<strong>Laufzeit:</strong>
|
||||||
<span>{omdbField(omdbInfo?.Runtime)}</span>
|
<span>{omdbField(metadataDetails.runtime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Genre:</strong>
|
<strong>Genre:</strong>
|
||||||
<span>{omdbField(omdbInfo?.Genre)}</span>
|
<span>{omdbField(metadataDetails.genre)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{metadataDetails.provider === 'tmdb' ? (
|
||||||
|
<div className="job-meta-item">
|
||||||
|
<strong>TMDb Rating:</strong>
|
||||||
|
<span>{omdbField(metadataDetails.tmdbRating)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>Rotten Tomatoes:</strong>
|
<strong>Rotten Tomatoes:</strong>
|
||||||
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
|
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-meta-item">
|
<div className="job-meta-item">
|
||||||
<strong>imdbRating:</strong>
|
<strong>imdbRating:</strong>
|
||||||
<span>{omdbField(omdbInfo?.imdbRating)}</span>
|
<span>{omdbField(metadataDetails.imdbRating)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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 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>
|
</div>
|
||||||
@@ -1115,11 +1462,29 @@ export default function JobDetailDialog({
|
|||||||
{/* Zeile 3+4: Pfade */}
|
{/* Zeile 3+4: Pfade */}
|
||||||
<PathField
|
<PathField
|
||||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
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}
|
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||||
downloadDisabled={!canDownloadRaw}
|
downloadDisabled={!canDownloadRaw}
|
||||||
downloadLoading={downloadBusyTarget === 'raw'}
|
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.length > 0 ? (
|
||||||
outputFolders.map((folder, idx) => {
|
outputFolders.map((folder, idx) => {
|
||||||
const folderPath = String(folder?.output_path || '').trim();
|
const folderPath = String(folder?.output_path || '').trim();
|
||||||
@@ -1149,7 +1514,23 @@ export default function JobDetailDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{job.error_message ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1219,6 +1600,119 @@ export default function JobDetailDialog({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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)) ? (
|
{!isCd && !isAudiobook && !isConverter && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Encode-Konfiguration</h4>
|
<h4>Encode-Konfiguration</h4>
|
||||||
@@ -1257,12 +1751,15 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Spurauswahl – read-only */}
|
{/* Spurauswahl – read-only */}
|
||||||
{job.encodePlan && Array.isArray(job.encodePlan.titles) && job.encodePlan.titles.length > 0 ? (
|
{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-block">
|
||||||
<div className="spurauswahl-label">Spurauswahl</div>
|
<div className="spurauswahl-label">Spurauswahl</div>
|
||||||
{job.encodePlan.titles
|
{selectedEncodeTitles.map((title) => {
|
||||||
.filter((t) => t.selectedForEncode || t.encodeInput || String(t.id) === String(reviewSelectedEncodeTitleId))
|
|
||||||
.map((title) => {
|
|
||||||
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
||||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||||
return (
|
return (
|
||||||
@@ -1315,9 +1812,68 @@ export default function JobDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1421,8 +1977,36 @@ export default function JobDetailDialog({
|
|||||||
|
|
||||||
<section className="job-meta-block job-meta-block-full">
|
<section className="job-meta-block job-meta-block-full">
|
||||||
<h4>Logs</h4>
|
<h4>Logs</h4>
|
||||||
|
{isSeriesContainer && childJobs.length > 0 ? (
|
||||||
|
<>
|
||||||
<div className="job-json-grid">
|
<div className="job-json-grid">
|
||||||
{!isCd && !isAudiobook && !isConverter ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
|
{!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}
|
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
|
||||||
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? 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} />
|
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
|
||||||
@@ -1430,6 +2014,18 @@ export default function JobDetailDialog({
|
|||||||
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
|
<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} />
|
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
@@ -1461,7 +2057,24 @@ export default function JobDetailDialog({
|
|||||||
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
|
||||||
</div>
|
</div>
|
||||||
{logLoaded ? (
|
{logLoaded ? (
|
||||||
|
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>
|
<pre className="log-box">{job.log || ''}</pre>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||||
)}
|
)}
|
||||||
@@ -1595,7 +2208,7 @@ export default function JobDetailDialog({
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => onRestartReview?.(job)}
|
onClick={() => onRestartReview?.(job)}
|
||||||
loading={actionBusy}
|
loading={actionBusy}
|
||||||
disabled={!canRestartReview || isUnencodedOrphanImport}
|
disabled={!canRestartReview}
|
||||||
/>
|
/>
|
||||||
<span className="action-desc">{isAudiobook
|
<span className="action-desc">{isAudiobook
|
||||||
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
|
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
|
||||||
@@ -1610,9 +2223,9 @@ export default function JobDetailDialog({
|
|||||||
icon="pi pi-sync"
|
icon="pi pi-sync"
|
||||||
severity="info"
|
severity="info"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
|
onClick={() => onReencode?.(job)}
|
||||||
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
|
loading={reencodeBusy}
|
||||||
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
|
disabled={!canReencode}
|
||||||
/>
|
/>
|
||||||
<span className="action-desc">{isAudiobook
|
<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.'
|
? '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="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||||
<div className="action-item">
|
<div className="action-item">
|
||||||
<Button
|
<Button
|
||||||
label="OMDB neu zuweisen"
|
label={metadataAssignButtonLabel}
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
@@ -1638,7 +2251,7 @@ export default function JobDetailDialog({
|
|||||||
loading={omdbAssignBusy}
|
loading={omdbAssignBusy}
|
||||||
disabled={running}
|
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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -411,6 +411,46 @@ function shellQuote(value) {
|
|||||||
return `'${raw.replace(/'/g, `'"'"'`)}'`;
|
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({
|
function buildHandBrakeCommandPreview({
|
||||||
review,
|
review,
|
||||||
title,
|
title,
|
||||||
@@ -433,7 +473,12 @@ function buildHandBrakeCommandPreview({
|
|||||||
const extraArgs = presetOverride !== null
|
const extraArgs = presetOverride !== null
|
||||||
? String(presetOverride.extraArgs || '').trim()
|
? String(presetOverride.extraArgs || '').trim()
|
||||||
: String(review?.selectors?.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
|
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
|
||||||
? Math.trunc(rawMappedTitleId)
|
? Math.trunc(rawMappedTitleId)
|
||||||
: null;
|
: null;
|
||||||
@@ -463,11 +508,12 @@ function buildHandBrakeCommandPreview({
|
|||||||
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
|
||||||
)[0] || null;
|
)[0] || null;
|
||||||
|
|
||||||
|
const sanitizedOutputPath = sanitizeOutputPathForCommand(commandOutputPath);
|
||||||
const baseArgs = [
|
const baseArgs = [
|
||||||
'-i',
|
'-i',
|
||||||
inputPath || '<encode-input>',
|
inputPath || '<encode-input>',
|
||||||
'-o',
|
'-o',
|
||||||
String(commandOutputPath || '').trim() || '<encode-output>'
|
String(sanitizedOutputPath || '').trim() || '<encode-output>'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (mappedTitleId !== null) {
|
if (mappedTitleId !== null) {
|
||||||
@@ -1195,13 +1241,22 @@ export default function MediaInfoReviewPanel({
|
|||||||
review,
|
review,
|
||||||
presetDisplayValue = '',
|
presetDisplayValue = '',
|
||||||
commandOutputPath = null,
|
commandOutputPath = null,
|
||||||
|
commandOutputPathByTitle = {},
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
|
selectedEncodeTitleIds = [],
|
||||||
allowTitleSelection = false,
|
allowTitleSelection = false,
|
||||||
|
compactTitleSelection = false,
|
||||||
onSelectEncodeTitle = null,
|
onSelectEncodeTitle = null,
|
||||||
|
onToggleEncodeTitle = null,
|
||||||
allowTrackSelection = false,
|
allowTrackSelection = false,
|
||||||
trackSelectionByTitle = {},
|
trackSelectionByTitle = {},
|
||||||
onTrackSelectionChange = null,
|
onTrackSelectionChange = null,
|
||||||
onSubtitleVariantSelectionChange = null,
|
onSubtitleVariantSelectionChange = null,
|
||||||
|
selectedMetadataEpisodes = [],
|
||||||
|
selectedEpisodeFillStart = null,
|
||||||
|
onEpisodeFillStartChange = null,
|
||||||
|
episodeAssignmentsByTitle = {},
|
||||||
|
onEpisodeAssignmentChange = null,
|
||||||
availableScripts = [],
|
availableScripts = [],
|
||||||
availableChains = [],
|
availableChains = [],
|
||||||
preEncodeItems = [],
|
preEncodeItems = [],
|
||||||
@@ -1225,10 +1280,68 @@ export default function MediaInfoReviewPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const titles = review.titles || [];
|
const titles = review.titles || [];
|
||||||
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
|
const candidateTitles = compactTitleSelection && Array.isArray(review?.handBrakeTitleCandidates)
|
||||||
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
|
? review.handBrakeTitleCandidates
|
||||||
const processedFiles = Number(review.processedFiles || titles.length || 0);
|
.map((candidate) => {
|
||||||
const totalFiles = Number(review.totalFiles || titles.length || 0);
|
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 playlistRecommendation = review.playlistRecommendation || null;
|
||||||
const rawPreset = String(review.selectors?.preset || '').trim();
|
const rawPreset = String(review.selectors?.preset || '').trim();
|
||||||
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
|
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
|
||||||
@@ -1280,9 +1393,170 @@ export default function MediaInfoReviewPanel({
|
|||||||
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
|
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
|
||||||
const handlePostDrop = makeHandleDrop(postEncodeItems, onReorderPostEncodeItem);
|
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 (
|
return (
|
||||||
<div className="media-review-wrap">
|
<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)' }}>
|
<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 }}>
|
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
|
||||||
Encode-Preset auswählen
|
Encode-Preset auswählen
|
||||||
@@ -1315,6 +1589,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!compactTitleSelection ? (
|
||||||
<div className="media-review-meta">
|
<div className="media-review-meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>Preset:</strong>{' '}
|
<strong>Preset:</strong>{' '}
|
||||||
@@ -1346,12 +1621,13 @@ export default function MediaInfoReviewPanel({
|
|||||||
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</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><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>
|
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{playlistRecommendation ? (
|
{!compactTitleSelection && playlistRecommendation ? (
|
||||||
<div className="playlist-recommendation-box">
|
<div className="playlist-recommendation-box">
|
||||||
<small>
|
<small>
|
||||||
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
|
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
|
||||||
@@ -1361,7 +1637,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{Array.isArray(review.notes) && review.notes.length > 0 ? (
|
{!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? (
|
||||||
<div className="media-review-notes">
|
<div className="media-review-notes">
|
||||||
{review.notes.map((note, idx) => (
|
{review.notes.map((note, idx) => (
|
||||||
<small key={`${idx}-${note}`}>{note}</small>
|
<small key={`${idx}-${note}`}>{note}</small>
|
||||||
@@ -1370,7 +1646,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Pre-Encode Items (scripts + chains unified) */}
|
{/* Pre-Encode Items (scripts + chains unified) */}
|
||||||
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
|
||||||
<div className="post-script-box">
|
<div className="post-script-box">
|
||||||
<h4>Pre-Encode Ausführungen (optional)</h4>
|
<h4>Pre-Encode Ausführungen (optional)</h4>
|
||||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||||
@@ -1491,6 +1767,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Post-Encode Items (scripts + chains unified) */}
|
{/* Post-Encode Items (scripts + chains unified) */}
|
||||||
|
{!compactTitleSelection ? (
|
||||||
<div className="post-script-box">
|
<div className="post-script-box">
|
||||||
<h4>Post-Encode Ausführungen (optional)</h4>
|
<h4>Post-Encode Ausführungen (optional)</h4>
|
||||||
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
|
||||||
@@ -1608,20 +1885,138 @@ export default function MediaInfoReviewPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<h4>Titel</h4>
|
<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">
|
<div className="media-title-list">
|
||||||
{titles.length === 0 ? (
|
{displayTitles.length === 0 ? (
|
||||||
<p>Keine Titel analysiert.</p>
|
<p>Keine Titel analysiert.</p>
|
||||||
) : titles.map((title) => {
|
) : displayTitles.map((title) => {
|
||||||
const titleEligible = title?.eligibleForEncode !== false;
|
const normalizedTitleId = normalizeTitleId(title.id);
|
||||||
const titleChecked = allowTitleSelection
|
const titleChecked = allowTitleSelection
|
||||||
? (currentSelectedId !== null
|
? (selectedTitleIdSet.size > 0
|
||||||
? currentSelectedId === normalizeTitleId(title.id)
|
? selectedTitleIdSet.has(String(normalizedTitleId))
|
||||||
: Boolean(title.selectedForEncode))
|
: Boolean(title.selectedForEncode))
|
||||||
: 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 titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
|
||||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
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
|
const selectableSubtitleTrackIds = subtitleTracks
|
||||||
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
|
||||||
.map((track) => normalizeTrackId(track?.id))
|
.map((track) => normalizeTrackId(track?.id))
|
||||||
@@ -1691,21 +2086,52 @@ export default function MediaInfoReviewPanel({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={titleChecked}
|
checked={titleChecked}
|
||||||
onChange={() => {
|
onChange={(event) => {
|
||||||
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
|
if (!allowTitleSelection) {
|
||||||
return;
|
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}
|
readOnly={!allowTitleSelection}
|
||||||
disabled={!allowTitleSelection}
|
disabled={!allowTitleSelection}
|
||||||
/>
|
/>
|
||||||
<span>
|
<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' : ''}
|
{title.encodeInput ? ' | Encode-Input' : ''}
|
||||||
|
{titleIsPrimary ? ' | Primär' : ''}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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 ? (
|
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
|
||||||
<div className="playlist-info-box">
|
<div className="playlist-info-box">
|
||||||
<small>
|
<small>
|
||||||
@@ -1729,6 +2155,9 @@ export default function MediaInfoReviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!compactTitleSelection && useEpisodeTrackAccordion ? (
|
||||||
|
<details className="episode-track-accordion">
|
||||||
|
<summary>Tonspuren und Untertitel</summary>
|
||||||
<div className="media-track-grid">
|
<div className="media-track-grid">
|
||||||
<TrackList
|
<TrackList
|
||||||
title={`Tonspuren (Titel #${title.id})`}
|
title={`Tonspuren (Titel #${title.id})`}
|
||||||
@@ -1762,7 +2191,49 @@ export default function MediaInfoReviewPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{titleChecked ? (() => {
|
</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({
|
const commandPreview = buildHandBrakeCommandPreview({
|
||||||
review,
|
review,
|
||||||
title,
|
title,
|
||||||
@@ -1770,7 +2241,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
selectedSubtitleTrackIds,
|
selectedSubtitleTrackIds,
|
||||||
selectedSubtitleVariantSelection,
|
selectedSubtitleVariantSelection,
|
||||||
selectedSubtitleLanguageOrder,
|
selectedSubtitleLanguageOrder,
|
||||||
commandOutputPath,
|
commandOutputPath: resolvedCommandOutputPath,
|
||||||
presetOverride: effectivePresetOverride
|
presetOverride: effectivePresetOverride
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ export default function MetadataSelectionDialog({
|
|||||||
const [manualTitle, setManualTitle] = useState('');
|
const [manualTitle, setManualTitle] = useState('');
|
||||||
const [manualYear, setManualYear] = useState('');
|
const [manualYear, setManualYear] = useState('');
|
||||||
const [manualImdb, setManualImdb] = useState('');
|
const [manualImdb, setManualImdb] = useState('');
|
||||||
|
const [manualDiscNumber, setManualDiscNumber] = useState('');
|
||||||
const [extraResults, setExtraResults] = useState([]);
|
const [extraResults, setExtraResults] = useState([]);
|
||||||
const [searchBusy, setSearchBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
@@ -30,30 +36,64 @@ export default function MetadataSelectionDialog({
|
|||||||
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||||
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
||||||
const defaultImdb = selectedMetadata.imdbId || '';
|
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);
|
setSelected(null);
|
||||||
setQuery(defaultTitle);
|
setQuery(defaultTitle);
|
||||||
setManualTitle(defaultTitle);
|
setManualTitle(defaultTitle);
|
||||||
setManualYear(defaultYear);
|
setManualYear(defaultYear);
|
||||||
setManualImdb(defaultImdb);
|
setManualImdb(defaultImdb);
|
||||||
|
setManualDiscNumber(defaultDiscNumber);
|
||||||
setExtraResults([]);
|
setExtraResults([]);
|
||||||
setSearchBusy(false);
|
setSearchBusy(false);
|
||||||
|
setSearchError('');
|
||||||
|
setSeasonFilter('');
|
||||||
}, [visible, context]);
|
}, [visible, context]);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const base = context?.omdbCandidates || [];
|
const base = Array.isArray(context?.metadataCandidates)
|
||||||
|
? context.metadataCandidates
|
||||||
|
: (context?.omdbCandidates || []);
|
||||||
const all = [...base, ...extraResults];
|
const all = [...base, ...extraResults];
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
|
|
||||||
all.forEach((item) => {
|
all.forEach((item) => {
|
||||||
if (item?.imdbId) {
|
const rowKey = String(
|
||||||
map.set(item.imdbId, item);
|
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());
|
return Array.from(map.values());
|
||||||
}, [context, extraResults]);
|
}, [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) => (
|
const titleWithPosterBody = (row) => (
|
||||||
<div className="omdb-row">
|
<div className="omdb-row">
|
||||||
{row.poster && row.poster !== 'N/A' ? (
|
{row.poster && row.poster !== 'N/A' ? (
|
||||||
@@ -63,7 +103,14 @@ export default function MetadataSelectionDialog({
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div><strong>{row.title}</strong></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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,15 +121,26 @@ export default function MetadataSelectionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearchBusy(true);
|
setSearchBusy(true);
|
||||||
|
setSearchError('');
|
||||||
try {
|
try {
|
||||||
const results = await onSearch(trimmedQuery);
|
const results = await onSearch(trimmedQuery, {
|
||||||
|
metadataProvider
|
||||||
|
});
|
||||||
setExtraResults(Array.isArray(results) ? results : []);
|
setExtraResults(Array.isArray(results) ? results : []);
|
||||||
|
} catch (error) {
|
||||||
|
setExtraResults([]);
|
||||||
|
setSearchError(error?.message || 'Suche fehlgeschlagen.');
|
||||||
} finally {
|
} finally {
|
||||||
setSearchBusy(false);
|
setSearchBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
const parsedDiscNumber = Number(manualDiscNumber);
|
||||||
|
const effectiveDiscNumber = Number.isFinite(parsedDiscNumber) && parsedDiscNumber > 0
|
||||||
|
? Math.trunc(parsedDiscNumber)
|
||||||
|
: null;
|
||||||
|
|
||||||
const payload = selected
|
const payload = selected
|
||||||
? {
|
? {
|
||||||
jobId: context.jobId,
|
jobId: context.jobId,
|
||||||
@@ -90,15 +148,27 @@ export default function MetadataSelectionDialog({
|
|||||||
year: selected.year,
|
year: selected.year,
|
||||||
imdbId: selected.imdbId,
|
imdbId: selected.imdbId,
|
||||||
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
|
||||||
fromOmdb: 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,
|
jobId: context.jobId,
|
||||||
title: manualTitle,
|
title: manualTitle,
|
||||||
year: manualYear,
|
year: manualYear,
|
||||||
imdbId: manualImdb,
|
imdbId: supportsExternalIdInput ? manualImdb : null,
|
||||||
poster: null,
|
poster: null,
|
||||||
fromOmdb: false
|
fromOmdb: false,
|
||||||
|
metadataProvider,
|
||||||
|
seasonNumber: null,
|
||||||
|
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
@@ -126,16 +196,39 @@ export default function MetadataSelectionDialog({
|
|||||||
}}
|
}}
|
||||||
placeholder="Titel suchen"
|
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>
|
||||||
|
<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">
|
<div className="table-scroll-wrap table-scroll-medium">
|
||||||
<DataTable
|
<DataTable
|
||||||
value={rows}
|
value={filteredRows}
|
||||||
selectionMode="single"
|
selectionMode="single"
|
||||||
selection={selected}
|
selection={selected}
|
||||||
onSelectionChange={(event) => setSelected(event.value)}
|
onSelectionChange={(event) => setSelected(event.value)}
|
||||||
dataKey="imdbId"
|
dataKey="_metadataKey"
|
||||||
size="small"
|
size="small"
|
||||||
scrollable
|
scrollable
|
||||||
scrollHeight="22rem"
|
scrollHeight="22rem"
|
||||||
@@ -145,7 +238,13 @@ export default function MetadataSelectionDialog({
|
|||||||
>
|
>
|
||||||
<Column header="Titel" body={titleWithPosterBody} />
|
<Column header="Titel" body={titleWithPosterBody} />
|
||||||
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
|
||||||
<Column 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>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,8 +265,8 @@ export default function MetadataSelectionDialog({
|
|||||||
<InputText
|
<InputText
|
||||||
value={manualImdb}
|
value={manualImdb}
|
||||||
onChange={(event) => setManualImdb(event.target.value)}
|
onChange={(event) => setManualImdb(event.target.value)}
|
||||||
placeholder="IMDb-ID"
|
placeholder={supportsExternalIdInput ? 'IMDb-ID' : 'Externe ID'}
|
||||||
disabled={!!selected}
|
disabled={!!selected || !supportsExternalIdInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -112,7 +112,7 @@ export default function DatabasePage() {
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="Gefundene RAW-Einträge"
|
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">
|
<div className="table-filters">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ import {
|
|||||||
STATUS_FILTER_OPTIONS
|
STATUS_FILTER_OPTIONS
|
||||||
} from '../utils/statusPresentation';
|
} from '../utils/statusPresentation';
|
||||||
import { confirmModal } from '../utils/confirmModal';
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
|
||||||
|
|
||||||
const MEDIA_FILTER_OPTIONS = [
|
const MEDIA_FILTER_OPTIONS = [
|
||||||
{ label: 'Alle Medien', value: '' },
|
{ label: 'Alle Medien', value: '' },
|
||||||
{ label: 'Blu-ray', value: 'bluray' },
|
{ label: 'Blu-ray', value: 'bluray' },
|
||||||
{ label: 'DVD', value: 'dvd' },
|
{ label: 'DVD', value: 'dvd' },
|
||||||
|
{ label: 'DVD Serie', value: 'dvd_series' },
|
||||||
{ label: 'Audio CD', value: 'cd' },
|
{ label: 'Audio CD', value: 'cd' },
|
||||||
{ label: 'Audiobook', value: 'audiobook' },
|
{ label: 'Audiobook', value: 'audiobook' },
|
||||||
{ label: 'Sonstiges', value: 'other' }
|
{ label: 'Sonstiges', value: 'other' }
|
||||||
@@ -123,6 +125,7 @@ function resolveMediaType(row) {
|
|||||||
|
|
||||||
function resolveMediaTypeMeta(row) {
|
function resolveMediaTypeMeta(row) {
|
||||||
const mediaType = resolveMediaType(row);
|
const mediaType = resolveMediaType(row);
|
||||||
|
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(row);
|
||||||
if (mediaType === 'bluray') {
|
if (mediaType === 'bluray') {
|
||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
@@ -135,8 +138,8 @@ function resolveMediaTypeMeta(row) {
|
|||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
icon: discIndicatorIcon,
|
icon: discIndicatorIcon,
|
||||||
label: 'DVD',
|
label: isSeriesDvd ? 'DVD Serie' : 'DVD',
|
||||||
alt: 'DVD'
|
alt: isSeriesDvd ? 'DVD Serie' : 'DVD'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (mediaType === 'cd') {
|
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) {
|
function formatDurationSeconds(totalSeconds) {
|
||||||
const parsed = Number(totalSeconds);
|
const parsed = Number(totalSeconds);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -438,10 +449,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
|
||||||
const [pendingRestartRow, setPendingRestartRow] = useState(null);
|
|
||||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||||
|
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
const [conflictModalVisible, setConflictModalVisible] = useState(false);
|
const [conflictModalVisible, setConflictModalVisible] = useState(false);
|
||||||
@@ -464,7 +475,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
}, [queuedJobIds]);
|
}, [queuedJobIds]);
|
||||||
|
|
||||||
const preparedJobs = useMemo(
|
const preparedJobs = useMemo(
|
||||||
() => jobs.map((job) => ({
|
() => jobs
|
||||||
|
.filter((job) => !isSeriesBatchChildHistoryRow(job))
|
||||||
|
.map((job) => ({
|
||||||
...job,
|
...job,
|
||||||
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
|
||||||
sortMediaType: resolveMediaType(job),
|
sortMediaType: resolveMediaType(job),
|
||||||
@@ -476,7 +489,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
const visibleJobs = useMemo(
|
const visibleJobs = useMemo(
|
||||||
() => (mediumFilter
|
() => (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),
|
||||||
[preparedJobs, mediumFilter]
|
[preparedJobs, mediumFilter]
|
||||||
);
|
);
|
||||||
@@ -1003,12 +1021,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReencode = async (row) => {
|
const handleReencode = async (row) => {
|
||||||
const mediaType = resolveMediaType(row);
|
|
||||||
if (mediaType === 'bluray' || mediaType === 'dvd') {
|
|
||||||
setPendingRestartRow(row);
|
|
||||||
handleAssignOmdb(row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1086,50 +1098,93 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const handleAssignOmdb = (row) => {
|
const handleAssignOmdb = (row) => {
|
||||||
const jobId = Number(row?.id || 0);
|
const jobId = Number(row?.id || 0);
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||||
|
const 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 = {
|
const context = {
|
||||||
jobId,
|
jobId,
|
||||||
detectedTitle: row?.detected_title || row?.title || '',
|
detectedTitle: row?.detected_title || row?.title || '',
|
||||||
selectedMetadata: {
|
selectedMetadata: {
|
||||||
title: row?.title || row?.detected_title || '',
|
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
|
||||||
year: row?.year || null,
|
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
|
||||||
imdbId: row?.imdb_id || null,
|
year: row?.year || selectedMetadata?.year || null,
|
||||||
poster: row?.poster_url || 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: []
|
omdbCandidates: []
|
||||||
};
|
};
|
||||||
setMetadataDialogContext(context);
|
setMetadataDialogContext(context);
|
||||||
setMetadataDialogVisible(true);
|
setMetadataDialogVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOmdbSearch = async (query) => {
|
const handleOmdbSearch = async (query, options = {}) => {
|
||||||
|
const provider = String(
|
||||||
|
options?.metadataProvider
|
||||||
|
|| metadataDialogContext?.metadataProvider
|
||||||
|
|| 'omdb'
|
||||||
|
).trim().toLowerCase() || 'omdb';
|
||||||
try {
|
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 || [];
|
return response.results || [];
|
||||||
} catch (error) {
|
} 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOmdbSubmit = async (payload) => {
|
const handleOmdbSubmit = async (payload) => {
|
||||||
|
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||||
setOmdbAssignBusy(true);
|
setOmdbAssignBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.assignJobOmdb(payload.jobId, payload);
|
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);
|
setMetadataDialogVisible(false);
|
||||||
setMetadataDialogContext(null);
|
setMetadataDialogContext(null);
|
||||||
await load();
|
await load();
|
||||||
await refreshDetailIfOpen(payload.jobId);
|
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) {
|
} 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 {
|
} finally {
|
||||||
setOmdbAssignBusy(false);
|
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 = () => {
|
const closeDeleteEntryDialog = () => {
|
||||||
if (deleteEntryTargetBusy) {
|
if (deleteEntryTargetBusy) {
|
||||||
return;
|
return;
|
||||||
@@ -1373,12 +1459,56 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
return <div className="history-dv-poster-fallback">{isAudioSquare ? 'Kein Cover' : 'Kein Poster'}</div>;
|
return <div className="history-dv-poster-fallback">{isAudioSquare ? 'Kein Cover' : 'Kein Poster'}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPresenceChip = (label, available) => (
|
const renderPresenceChip = (label, available, tone = null, suffix = '') => {
|
||||||
<span className={`history-dv-chip ${available ? 'tone-ok' : 'tone-no'}`}>
|
const resolvedTone = tone || (available ? 'tone-ok' : 'tone-no');
|
||||||
<i className={`pi ${available ? 'pi-check-circle' : 'pi-times-circle'}`} aria-hidden="true" />
|
const iconClass = available ? 'pi-check-circle' : 'pi-times-circle';
|
||||||
<span>{label}: {available ? 'Ja' : 'Nein'}</span>
|
return (
|
||||||
|
<span className={`history-dv-chip ${resolvedTone}`}>
|
||||||
|
<i className={`pi ${iconClass}`} aria-hidden="true" />
|
||||||
|
<span>{label}: {available ? 'Ja' : 'Nein'}{suffix}</span>
|
||||||
</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) => {
|
const renderSupplementalInfo = (row) => {
|
||||||
if (resolveMediaType(row) === 'cd') {
|
if (resolveMediaType(row) === 'cd') {
|
||||||
@@ -1464,6 +1594,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||||
const outputIsAudio = hasAudioOutput(row);
|
const outputIsAudio = hasAudioOutput(row);
|
||||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
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
|
const subtitle = isCdJob
|
||||||
? [
|
? [
|
||||||
`#${row?.id || '-'}`,
|
`#${row?.id || '-'}`,
|
||||||
@@ -1471,7 +1611,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
row?.year || null,
|
row?.year || null,
|
||||||
cdDetails?.mbId ? 'MusicBrainz' : null
|
cdDetails?.mbId ? 'MusicBrainz' : null
|
||||||
].filter(Boolean).join(' | ')
|
].filter(Boolean).join(' | ')
|
||||||
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
|
: [
|
||||||
|
`#${row?.id || '-'}`,
|
||||||
|
row?.year || '-',
|
||||||
|
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
|
||||||
|
tmdbLabel
|
||||||
|
].filter(Boolean).join(' | ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-12" key={row.id}>
|
<div className="col-12" key={row.id}>
|
||||||
@@ -1507,8 +1652,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
{isSeriesDvd
|
||||||
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
? renderSeriesRawChip(row)
|
||||||
|
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
|
{isSeriesDvd
|
||||||
|
? renderSeriesOutputChip(row)
|
||||||
|
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1571,8 +1720,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
{isSeriesDvd
|
||||||
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
? renderSeriesRawChip(row)
|
||||||
|
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
|
{isSeriesDvd
|
||||||
|
? renderSeriesOutputChip(row)
|
||||||
|
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1686,6 +1839,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||||
onAssignOmdb={handleAssignOmdb}
|
onAssignOmdb={handleAssignOmdb}
|
||||||
onAssignCdMetadata={handleAssignCdMetadata}
|
onAssignCdMetadata={handleAssignCdMetadata}
|
||||||
|
onAcknowledgeError={handleAcknowledgeError}
|
||||||
onDeleteFiles={handleDeleteFiles}
|
onDeleteFiles={handleDeleteFiles}
|
||||||
onDeleteEntry={handleDeleteEntry}
|
onDeleteEntry={handleDeleteEntry}
|
||||||
onDownloadArchive={handleDownloadArchive}
|
onDownloadArchive={handleDownloadArchive}
|
||||||
@@ -1695,6 +1849,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
omdbAssignBusy={omdbAssignBusy}
|
omdbAssignBusy={omdbAssignBusy}
|
||||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||||
|
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
deleteEntryBusy={deleteEntryBusy}
|
deleteEntryBusy={deleteEntryBusy}
|
||||||
downloadBusyTarget={downloadBusyTarget}
|
downloadBusyTarget={downloadBusyTarget}
|
||||||
@@ -1855,7 +2010,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
onHide={() => {
|
onHide={() => {
|
||||||
setMetadataDialogVisible(false);
|
setMetadataDialogVisible(false);
|
||||||
setMetadataDialogContext(null);
|
setMetadataDialogContext(null);
|
||||||
setPendingRestartRow(null);
|
|
||||||
}}
|
}}
|
||||||
onSubmit={handleOmdbSubmit}
|
onSubmit={handleOmdbSubmit}
|
||||||
onSearch={handleOmdbSearch}
|
onSearch={handleOmdbSearch}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import otherIndicatorIcon from '../assets/media-other.svg';
|
|||||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
import { confirmModal } from '../utils/confirmModal';
|
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 processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
||||||
@@ -543,11 +543,18 @@ function resolveMediaType(job) {
|
|||||||
|
|
||||||
function mediaIndicatorMeta(job) {
|
function mediaIndicatorMeta(job) {
|
||||||
const mediaType = resolveMediaType(job);
|
const mediaType = resolveMediaType(job);
|
||||||
|
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(job);
|
||||||
if (mediaType === 'bluray') {
|
if (mediaType === 'bluray') {
|
||||||
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
|
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
|
||||||
}
|
}
|
||||||
if (mediaType === 'dvd') {
|
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') {
|
if (mediaType === 'cd') {
|
||||||
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
||||||
@@ -748,7 +755,16 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
mbId: resolvedCdMbId,
|
mbId: resolvedCdMbId,
|
||||||
coverUrl: resolvedCdCoverUrl,
|
coverUrl: resolvedCdCoverUrl,
|
||||||
imdbId: job?.imdb_id || null,
|
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 mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||||
@@ -836,6 +852,10 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
: [],
|
: [],
|
||||||
selectedPlaylist: analyzeContext.selectedPlaylist || null,
|
selectedPlaylist: analyzeContext.selectedPlaylist || null,
|
||||||
selectedTitleId: analyzeContext.selectedTitleId ?? 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: [],
|
omdbCandidates: [],
|
||||||
canRestartEncodeFromLastSettings,
|
canRestartEncodeFromLastSettings,
|
||||||
canRestartReviewFromRaw
|
canRestartReviewFromRaw
|
||||||
@@ -1048,7 +1068,8 @@ export default function RipperPage({
|
|||||||
api.getJobs({
|
api.getJobs({
|
||||||
statuses: Array.from(ripperStatuses),
|
statuses: Array.from(ripperStatuses),
|
||||||
limit: 160,
|
limit: 160,
|
||||||
lite: true
|
lite: true,
|
||||||
|
includeChildren: true
|
||||||
}),
|
}),
|
||||||
api.getPipelineQueue()
|
api.getPipelineQueue()
|
||||||
]);
|
]);
|
||||||
@@ -1062,6 +1083,9 @@ export default function RipperPage({
|
|||||||
if (isConverterJob(job)) {
|
if (isConverterJob(job)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
||||||
if (!ripperStatuses.has(normalizedStatus)) {
|
if (!ripperStatuses.has(normalizedStatus)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1113,8 +1137,12 @@ export default function RipperPage({
|
|||||||
seen.add(String(id));
|
seen.add(String(id));
|
||||||
deduped.push(job);
|
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
|
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
|
||||||
try {
|
try {
|
||||||
@@ -1314,13 +1342,20 @@ export default function RipperPage({
|
|||||||
title: job?.title || job?.detected_title || context?.detectedTitle || '',
|
title: job?.title || job?.detected_title || context?.detectedTitle || '',
|
||||||
year: job?.year || null,
|
year: job?.year || null,
|
||||||
imdbId: job?.imdb_id || 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 {
|
return {
|
||||||
...context,
|
...context,
|
||||||
jobId: normalizedJobId,
|
jobId: normalizedJobId,
|
||||||
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
|
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
|
||||||
selectedMetadata,
|
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 : []
|
omdbCandidates: Array.isArray(context?.omdbCandidates) ? context.omdbCandidates : []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1342,8 +1377,15 @@ export default function RipperPage({
|
|||||||
title: currentContext?.detectedTitle || '',
|
title: currentContext?.detectedTitle || '',
|
||||||
year: null,
|
year: null,
|
||||||
imdbId: 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 : []
|
omdbCandidates: Array.isArray(currentContext?.omdbCandidates) ? currentContext.omdbCandidates : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1409,8 +1451,16 @@ export default function RipperPage({
|
|||||||
title: response?.result?.detectedTitle || '',
|
title: response?.result?.detectedTitle || '',
|
||||||
year: null,
|
year: null,
|
||||||
imdbId: 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)
|
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
|
||||||
? response.result.omdbCandidates
|
? response.result.omdbCandidates
|
||||||
: []
|
: []
|
||||||
@@ -1443,8 +1493,16 @@ export default function RipperPage({
|
|||||||
title: response?.result?.detectedTitle || '',
|
title: response?.result?.detectedTitle || '',
|
||||||
year: null,
|
year: null,
|
||||||
imdbId: 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)
|
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
|
||||||
? response.result.omdbCandidates
|
? response.result.omdbCandidates
|
||||||
: []
|
: []
|
||||||
@@ -1672,7 +1730,9 @@ export default function RipperPage({
|
|||||||
if (startOptions.ensureConfirmed) {
|
if (startOptions.ensureConfirmed) {
|
||||||
const confirmPayload = {
|
const confirmPayload = {
|
||||||
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
||||||
|
selectedEncodeTitleIds: startOptions.selectedEncodeTitleIds ?? null,
|
||||||
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
||||||
|
episodeAssignments: startOptions.episodeAssignments ?? null,
|
||||||
skipPipelineStateUpdate: true
|
skipPipelineStateUpdate: true
|
||||||
};
|
};
|
||||||
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
|
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
|
||||||
@@ -1773,16 +1833,26 @@ export default function RipperPage({
|
|||||||
selectedTrackSelection = null,
|
selectedTrackSelection = null,
|
||||||
selectedPostEncodeScriptIds = undefined,
|
selectedPostEncodeScriptIds = undefined,
|
||||||
selectedUserPresetId = undefined,
|
selectedUserPresetId = undefined,
|
||||||
selectedHandBrakePreset = undefined
|
selectedHandBrakePreset = undefined,
|
||||||
|
selectedEncodeTitleIds = undefined,
|
||||||
|
episodeAssignments = undefined
|
||||||
) => {
|
) => {
|
||||||
const normalizedJobId = normalizeJobId(jobId);
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection
|
||||||
selectedPostEncodeScriptIds
|
|
||||||
};
|
};
|
||||||
|
if (selectedEncodeTitleIds !== undefined) {
|
||||||
|
payload.selectedEncodeTitleIds = selectedEncodeTitleIds;
|
||||||
|
}
|
||||||
|
if (selectedPostEncodeScriptIds !== undefined) {
|
||||||
|
payload.selectedPostEncodeScriptIds = selectedPostEncodeScriptIds;
|
||||||
|
}
|
||||||
|
if (episodeAssignments !== undefined) {
|
||||||
|
payload.episodeAssignments = episodeAssignments;
|
||||||
|
}
|
||||||
if (selectedUserPresetId !== undefined) {
|
if (selectedUserPresetId !== undefined) {
|
||||||
payload.selectedUserPresetId = selectedUserPresetId;
|
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);
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||||
try {
|
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({
|
await api.selectMetadata({
|
||||||
jobId,
|
jobId,
|
||||||
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
|
selectedHandBrakeTitleId: dedupedIds[0] || null,
|
||||||
|
selectedHandBrakeTitleIds: dedupedIds
|
||||||
});
|
});
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadRipperJobs();
|
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 job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
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) {
|
if (job?.encodeSuccess) {
|
||||||
const confirmed = await confirmModal({
|
const confirmed = await confirmModal({
|
||||||
header: 'Encode neu starten',
|
header: 'Encode neu starten',
|
||||||
@@ -1934,7 +2013,7 @@ export default function RipperPage({
|
|||||||
const normalizedJobId = normalizeJobId(jobId);
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
const response = await api.restartEncodeWithLastSettings(jobId);
|
const response = await api.restartEncodeWithLastSettings(jobId, { restartMode });
|
||||||
const result = getQueueActionResult(response);
|
const result = getQueueActionResult(response);
|
||||||
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
@@ -2115,14 +2194,12 @@ export default function RipperPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOmdbSearch = async (query) => {
|
const handleMetadataSearch = async (query, options = {}) => {
|
||||||
try {
|
const provider = String(options?.metadataProvider || effectiveMetadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||||
const response = await api.searchOmdb(query);
|
const response = provider === 'tmdb'
|
||||||
|
? await api.searchTmdbSeries(query)
|
||||||
|
: await api.searchOmdb(query);
|
||||||
return response.results || [];
|
return response.results || [];
|
||||||
} catch (error) {
|
|
||||||
showError(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const doSelectMetadata = async (payload) => {
|
const doSelectMetadata = async (payload) => {
|
||||||
@@ -2772,6 +2849,42 @@ export default function RipperPage({
|
|||||||
normalizedStatus === 'READY_TO_ENCODE'
|
normalizedStatus === 'READY_TO_ENCODE'
|
||||||
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
||||||
) && !isCurrentSession;
|
) && !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 mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||||
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||||
@@ -2784,12 +2897,45 @@ export default function RipperPage({
|
|||||||
const isAudiobookJob = mediaProfile === 'audiobook'
|
const isAudiobookJob = mediaProfile === 'audiobook'
|
||||||
|| mediaIndicator.mediaType === 'audiobook'
|
|| mediaIndicator.mediaType === 'audiobook'
|
||||||
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === '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)
|
const clampedProgress = Number.isFinite(rawProgress)
|
||||||
? Math.max(0, Math.min(100, rawProgress))
|
? Math.max(0, Math.min(100, rawProgress))
|
||||||
: 0;
|
: 0;
|
||||||
const progressLabel = `${Math.round(clampedProgress)}%`;
|
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'
|
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
||||||
? pipelineForJob.context.selectedMetadata
|
? pipelineForJob.context.selectedMetadata
|
||||||
@@ -2814,8 +2960,12 @@ export default function RipperPage({
|
|||||||
/>
|
/>
|
||||||
<span>#{jobId} | {jobTitle}</span>
|
<span>#{jobId} | {jobTitle}</span>
|
||||||
</strong>
|
</strong>
|
||||||
|
{mediaIndicator.isSeriesDvd ? (
|
||||||
|
<small className="ripper-job-subtitle">{seriesTitleSuffix || '-'}</small>
|
||||||
|
) : null}
|
||||||
<div className="ripper-job-badges">
|
<div className="ripper-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
@@ -2934,8 +3084,11 @@ export default function RipperPage({
|
|||||||
<span>{jobTitle}</span>
|
<span>{jobTitle}</span>
|
||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
#{jobId}
|
{mediaIndicator.isSeriesDvd
|
||||||
{isAudiobookJob
|
? (seriesTitleSuffix || '-')
|
||||||
|
: (
|
||||||
|
`#${jobId}`
|
||||||
|
+ (isAudiobookJob
|
||||||
? (
|
? (
|
||||||
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
||||||
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
||||||
@@ -2943,12 +3096,18 @@ export default function RipperPage({
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
`${job?.year ? ` | ${job.year}` : ''}`
|
`${job?.year ? ` | ${job.year}` : ''}`
|
||||||
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
|
+ (mediaIndicator.isSeriesDvd ? seriesSeasonLabel : '')
|
||||||
|
+ (mediaIndicator.isSeriesDvd ? seriesDiscLabel : '')
|
||||||
|
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
|
||||||
|
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
|
||||||
|
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="ripper-job-badges">
|
<div className="ripper-job-badges">
|
||||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||||
|
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
|
||||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||||
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
||||||
{normalizedStatus === 'READY_TO_ENCODE'
|
{normalizedStatus === 'READY_TO_ENCODE'
|
||||||
@@ -2995,7 +3154,35 @@ export default function RipperPage({
|
|||||||
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
const detailKey = buildRunningQueueScriptKey(item?.jobId);
|
||||||
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||||
const jobProgressEntry = pipeline?.jobProgress?.[String(item.jobId)] || null;
|
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;
|
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
|
||||||
return (
|
return (
|
||||||
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
<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.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}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
|
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||||
<small>{getStatusLabel(item.status)}</small>
|
<small>{getStatusLabel(item.status)}</small>
|
||||||
{clampedQueueProgress > 0 && (
|
{clampedQueueProgress > 0 && (
|
||||||
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
<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.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}
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
</strong>
|
</strong>
|
||||||
|
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||||
<small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
|
<small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -3497,7 +3686,7 @@ export default function RipperPage({
|
|||||||
setMetadataDialogReassignMode(false);
|
setMetadataDialogReassignMode(false);
|
||||||
}}
|
}}
|
||||||
onSubmit={handleMetadataSubmit}
|
onSubmit={handleMetadataSubmit}
|
||||||
onSearch={handleOmdbSearch}
|
onSearch={handleMetadataSearch}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -3521,20 +3710,45 @@ export default function RipperPage({
|
|||||||
style={{ width: '30rem', maxWidth: '96vw' }}
|
style={{ width: '30rem', maxWidth: '96vw' }}
|
||||||
modal
|
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>
|
<p>
|
||||||
<strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
|
<strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
|
||||||
</p>
|
</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">
|
<div className="dialog-actions">
|
||||||
<Button
|
<Button
|
||||||
label="Vorhandenen Job öffnen"
|
label={isSeriesContainerAssign ? 'Vorhandenem Container zuweisen' : 'Vorhandenen Job öffnen'}
|
||||||
icon="pi pi-history"
|
icon={isSeriesContainerAssign ? 'pi pi-sitemap' : 'pi pi-history'}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
|
const payload = duplicateJobDialog.pendingPayload;
|
||||||
const jobId = duplicateJobDialog.existingJob?.id;
|
const jobId = duplicateJobDialog.existingJob?.id;
|
||||||
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
|
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
|
||||||
|
if (isSeriesContainerAssign) {
|
||||||
|
await doSelectMetadata(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate(`/history?open=${jobId}`);
|
navigate(`/history?open=${jobId}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{isSeriesContainerAssign ? (
|
||||||
|
<Button
|
||||||
|
label="Abbrechen"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
onClick={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
label="Neuen Job anlegen"
|
label="Neuen Job anlegen"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
@@ -3545,7 +3759,11 @@ export default function RipperPage({
|
|||||||
await doSelectMetadata(payload);
|
await doSelectMetadata(payload);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -1134,6 +1134,7 @@ export default function SettingsPage() {
|
|||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
effectivePaths={effectivePaths}
|
effectivePaths={effectivePaths}
|
||||||
|
onNotify={(msg) => toastRef.current?.show(msg)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -1849,6 +1850,7 @@ export default function SettingsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1640,6 +1640,29 @@ body {
|
|||||||
margin-bottom: 0.75rem;
|
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 {
|
.metadata-grid {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2864,6 +2887,12 @@ body {
|
|||||||
background: #f8e1df;
|
background: #f8e1df;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-dv-chip.tone-warn {
|
||||||
|
color: #92400e;
|
||||||
|
border-color: #f2c286;
|
||||||
|
background: #fff1db;
|
||||||
|
}
|
||||||
|
|
||||||
.history-dv-rating-chip {
|
.history-dv-rating-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3773,6 +3802,115 @@ body {
|
|||||||
gap: 0.6rem;
|
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 {
|
.playlist-info-box {
|
||||||
border: 1px dashed var(--rip-border);
|
border: 1px dashed var(--rip-border);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
@@ -4076,6 +4214,10 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metadata-filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.actions-row .p-button {
|
.actions-row .p-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,56 @@ export function resolveMediaType(job) {
|
|||||||
return 'other';
|
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) {
|
export function isConverterJob(job) {
|
||||||
return resolveMediaType(job) === 'converter';
|
return resolveMediaType(job) === 'converter';
|
||||||
}
|
}
|
||||||
@@ -233,4 +283,3 @@ export function classifyJob(job) {
|
|||||||
family
|
family
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -776,6 +776,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
|
|||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/output/series"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||||
@@ -837,6 +838,7 @@ LOG_LEVEL=info
|
|||||||
CORS_ORIGIN=http://${FRONTEND_HOST}
|
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
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_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -554,6 +554,7 @@ mkdir -p "$INSTALL_DIR/backend/data"
|
|||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/output/series"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||||
@@ -616,6 +617,7 @@ CORS_ORIGIN=http://${FRONTEND_HOST}
|
|||||||
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
|
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
|
||||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
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_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0-19",
|
"version": "0.13.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user