0.13.0 DVD Series

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