0.15.0-3 Misc Fixes
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -941,6 +941,26 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
});
|
||||
}
|
||||
|
||||
const metadataReadyNotifyLabel = 'Bei manueller Auswahl senden';
|
||||
const metadataReadyNotifyDescription = 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).';
|
||||
const metadataReadyNotifyResult = await db.run(
|
||||
`UPDATE settings_schema
|
||||
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'pushover_notify_metadata_ready' AND (label != ? OR description != ?)`,
|
||||
[
|
||||
metadataReadyNotifyLabel,
|
||||
metadataReadyNotifyDescription,
|
||||
metadataReadyNotifyLabel,
|
||||
metadataReadyNotifyDescription
|
||||
]
|
||||
);
|
||||
if (metadataReadyNotifyResult?.changes > 0) {
|
||||
logger.info('migrate:settings-schema-metadata-ready-notify-updated', {
|
||||
key: 'pushover_notify_metadata_ready',
|
||||
label: metadataReadyNotifyLabel
|
||||
});
|
||||
}
|
||||
|
||||
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
|
||||
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
|
||||
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
|
||||
|
||||
@@ -230,6 +230,7 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
selectedTitleId = null,
|
||||
backupOutputBase = null,
|
||||
disableMinLengthFilter = false,
|
||||
sourceArgOverride = null,
|
||||
isCancelled,
|
||||
onProcessHandle
|
||||
} = ctx.extra || {};
|
||||
@@ -249,7 +250,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
mediaProfile: this.mediaProfile,
|
||||
settingsMap: settings,
|
||||
backupOutputBase,
|
||||
disableMinLengthFilter
|
||||
disableMinLengthFilter,
|
||||
sourceArgOverride
|
||||
});
|
||||
|
||||
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
|
||||
@@ -262,7 +264,8 @@ class VideoDiscPlugin extends SourcePlugin {
|
||||
ripMode,
|
||||
rawJobDir,
|
||||
selectedTitleId,
|
||||
disableMinLengthFilter
|
||||
disableMinLengthFilter,
|
||||
sourceArgOverride: sourceArgOverride || null
|
||||
});
|
||||
|
||||
ctx.emitProgress(0, ripMode === 'backup'
|
||||
|
||||
@@ -2558,6 +2558,96 @@ function deleteFilesRecursively(rootPath, keepRoot = true) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectFilesRecursively(rootPath) {
|
||||
const normalizedRoot = normalizeComparablePath(rootPath);
|
||||
if (!normalizedRoot) {
|
||||
return [];
|
||||
}
|
||||
const result = [];
|
||||
const visit = (currentPath) => {
|
||||
let stat = null;
|
||||
try {
|
||||
stat = fs.lstatSync(currentPath);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
visit(path.join(currentPath, entry.name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
result.push(normalizeComparablePath(currentPath));
|
||||
};
|
||||
visit(normalizedRoot);
|
||||
return Array.from(new Set(result.filter(Boolean))).sort((left, right) => left.localeCompare(right, 'de'));
|
||||
}
|
||||
|
||||
function removeEmptyParentDirectories(startPath, options = {}) {
|
||||
const start = normalizeComparablePath(startPath);
|
||||
if (!start) {
|
||||
return [];
|
||||
}
|
||||
const protectedRoots = new Set(
|
||||
(Array.isArray(options?.protectedRoots) ? options.protectedRoots : [])
|
||||
.map((entry) => normalizeComparablePath(entry))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const allowedRoots = Array.from(protectedRoots);
|
||||
const removed = [];
|
||||
const removedSet = new Set();
|
||||
|
||||
let current = start;
|
||||
while (current && !isFilesystemRootPath(current)) {
|
||||
if (protectedRoots.has(current)) {
|
||||
break;
|
||||
}
|
||||
if (allowedRoots.length > 0 && !allowedRoots.some((rootPath) => isPathInside(rootPath, current))) {
|
||||
break;
|
||||
}
|
||||
let stat = null;
|
||||
try {
|
||||
stat = fs.lstatSync(current);
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
break;
|
||||
}
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(current);
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
fs.rmdirSync(current);
|
||||
if (!removedSet.has(current)) {
|
||||
removedSet.add(current);
|
||||
removed.push(current);
|
||||
}
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
const parentDir = normalizeComparablePath(path.dirname(current));
|
||||
if (!parentDir || parentDir === current) {
|
||||
break;
|
||||
}
|
||||
current = parentDir;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
function normalizeJobIdValue(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -6378,7 +6468,6 @@ class HistoryService {
|
||||
}
|
||||
|
||||
const artifactMoviePathSet = new Set(artifactMoviePaths);
|
||||
const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter';
|
||||
for (const outputPath of explicitMoviePaths) {
|
||||
addCandidate(
|
||||
movieCandidates,
|
||||
@@ -6387,16 +6476,6 @@ class HistoryService {
|
||||
artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path',
|
||||
movieAllowedPaths
|
||||
);
|
||||
const parentDir = toNormalizedPath(path.dirname(outputPath));
|
||||
if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) {
|
||||
addCandidate(
|
||||
movieCandidates,
|
||||
'movie',
|
||||
parentDir,
|
||||
artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent',
|
||||
movieAllowedPaths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedJobId && resolvedPaths?.mediaType !== 'cd') {
|
||||
@@ -6505,16 +6584,14 @@ class HistoryService {
|
||||
return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path');
|
||||
};
|
||||
|
||||
const buildList = (target) => Array.from(candidateMap.values())
|
||||
.filter((row) => row.target === target)
|
||||
const buildList = (target) => {
|
||||
const rows = Array.from(candidateMap.values())
|
||||
.filter((row) => row.target === target);
|
||||
if (target !== 'movie') {
|
||||
return rows
|
||||
.map((row) => {
|
||||
const inspection = inspectDeletionPath(row.path);
|
||||
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
|
||||
// Do not expose stale output file paths from DB/lineage in delete preview.
|
||||
// They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks.
|
||||
if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
target,
|
||||
path: row.path,
|
||||
@@ -6527,6 +6604,97 @@ class HistoryService {
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
|
||||
}
|
||||
|
||||
const moviePathMap = new Map();
|
||||
const upsertMoviePath = ({ path: candidatePath, exists, isDirectory, isFile, jobIds, sources }) => {
|
||||
const normalizedPath = normalizeComparablePath(candidatePath);
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
if (!moviePathMap.has(normalizedPath)) {
|
||||
moviePathMap.set(normalizedPath, {
|
||||
target: 'movie',
|
||||
path: normalizedPath,
|
||||
exists: Boolean(exists),
|
||||
isDirectory: Boolean(isDirectory),
|
||||
isFile: Boolean(isFile),
|
||||
jobIds: new Set(),
|
||||
sources: new Set()
|
||||
});
|
||||
}
|
||||
const row = moviePathMap.get(normalizedPath);
|
||||
row.exists = row.exists || Boolean(exists);
|
||||
row.isDirectory = row.isDirectory || Boolean(isDirectory);
|
||||
row.isFile = row.isFile || Boolean(isFile);
|
||||
for (const jobId of (Array.isArray(jobIds) ? jobIds : [])) {
|
||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||
if (normalizedJobId) {
|
||||
row.jobIds.add(normalizedJobId);
|
||||
}
|
||||
}
|
||||
for (const source of (Array.isArray(sources) ? sources : [])) {
|
||||
const normalizedSource = String(source || '').trim();
|
||||
if (normalizedSource) {
|
||||
row.sources.add(normalizedSource);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const inspection = inspectDeletionPath(row.path);
|
||||
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
|
||||
// Do not expose stale output paths from DB/lineage in delete preview.
|
||||
if (!inspection.exists && hasTrackedOutputSource(sources)) {
|
||||
continue;
|
||||
}
|
||||
if (!inspection.exists) {
|
||||
upsertMoviePath({
|
||||
path: row.path,
|
||||
exists: false,
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
jobIds: Array.from(row.jobIds),
|
||||
sources
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (inspection.isDirectory) {
|
||||
const files = collectFilesRecursively(inspection.path);
|
||||
for (const filePath of files) {
|
||||
upsertMoviePath({
|
||||
path: filePath,
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
jobIds: Array.from(row.jobIds),
|
||||
sources
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
upsertMoviePath({
|
||||
path: inspection.path,
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
jobIds: Array.from(row.jobIds),
|
||||
sources
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(moviePathMap.values())
|
||||
.map((row) => ({
|
||||
target: row.target,
|
||||
path: row.path,
|
||||
exists: Boolean(row.exists),
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
|
||||
sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right))
|
||||
}))
|
||||
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
|
||||
};
|
||||
|
||||
return {
|
||||
pathCandidates: {
|
||||
@@ -6650,7 +6818,7 @@ class HistoryService {
|
||||
if (targetKey === 'raw' && selectedRawPathFilter.size > 0) {
|
||||
summary[targetKey].reason = 'Keine ausgewählten RAW-Dateien/Ordner gefunden.';
|
||||
} else if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) {
|
||||
summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Ordner gefunden.';
|
||||
summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Dateien gefunden.';
|
||||
} else {
|
||||
summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.';
|
||||
}
|
||||
@@ -6678,6 +6846,9 @@ class HistoryService {
|
||||
}
|
||||
|
||||
if (inspection.isDirectory) {
|
||||
if (targetKey === 'movie') {
|
||||
continue;
|
||||
}
|
||||
const keepRoot = protectedRoots.has(inspection.path);
|
||||
const result = deleteFilesRecursively(inspection.path, keepRoot);
|
||||
const filesDeleted = Number(result?.filesDeleted || 0);
|
||||
@@ -6709,6 +6880,23 @@ class HistoryService {
|
||||
keepRoot: false,
|
||||
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
||||
});
|
||||
|
||||
if (targetKey === 'movie') {
|
||||
const parentDir = normalizeComparablePath(path.dirname(inspection.path));
|
||||
const removedParentDirs = removeEmptyParentDirectories(parentDir, {
|
||||
protectedRoots: Array.from(protectedRoots)
|
||||
});
|
||||
for (const removedPath of removedParentDirs) {
|
||||
summary[targetKey].dirsRemoved += 1;
|
||||
summary.deletedPaths.push({
|
||||
target: targetKey,
|
||||
path: removedPath,
|
||||
type: 'directory',
|
||||
keepRoot: false,
|
||||
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary[targetKey].deleted = summary[targetKey].pathsDeleted > 0
|
||||
@@ -6810,38 +6998,10 @@ class HistoryService {
|
||||
));
|
||||
|
||||
if (summary.movie?.deleted) {
|
||||
const movieDeleteEntries = (Array.isArray(summary?.deletedPaths) ? summary.deletedPaths : [])
|
||||
.filter((entry) => String(entry?.target || '').trim().toLowerCase() === 'movie')
|
||||
.map((entry) => ({
|
||||
path: normalizeComparablePath(entry?.path),
|
||||
type: String(entry?.type || '').trim().toLowerCase()
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.path));
|
||||
const previewMoviePaths = (Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [])
|
||||
.map((candidate) => normalizeComparablePath(candidate?.path))
|
||||
.filter(Boolean);
|
||||
const cleanupPaths = new Set();
|
||||
|
||||
for (const deletedEntry of movieDeleteEntries) {
|
||||
cleanupPaths.add(deletedEntry.path);
|
||||
for (const candidatePath of previewMoviePaths) {
|
||||
if (deletedEntry.type === 'directory') {
|
||||
if (isPathInside(deletedEntry.path, candidatePath)) {
|
||||
cleanupPaths.add(candidatePath);
|
||||
}
|
||||
} else if (candidatePath === deletedEntry.path) {
|
||||
cleanupPaths.add(candidatePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const deletedPath of cleanupPaths) {
|
||||
await this.removeJobOutputFolderFromJobs(
|
||||
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId],
|
||||
deletedPath
|
||||
await this.removeMissingJobOutputFoldersFromJobs(
|
||||
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.appendLog(
|
||||
normalizedJobId,
|
||||
@@ -7340,6 +7500,57 @@ class HistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
async removeMissingJobOutputFoldersFromJobs(jobIds = []) {
|
||||
const normalizedJobIds = Array.isArray(jobIds)
|
||||
? jobIds
|
||||
.map((value) => normalizeJobIdValue(value))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (normalizedJobIds.length === 0) {
|
||||
return { removed: 0, removedPaths: [] };
|
||||
}
|
||||
const db = await getDb();
|
||||
const placeholders = normalizedJobIds.map(() => '?').join(', ');
|
||||
const rows = await db.all(
|
||||
`SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`,
|
||||
normalizedJobIds
|
||||
);
|
||||
const removedPaths = [];
|
||||
for (const row of (Array.isArray(rows) ? rows : [])) {
|
||||
const normalizedPath = normalizeComparablePath(row?.output_path);
|
||||
let removeEntry = false;
|
||||
if (!normalizedPath) {
|
||||
removeEntry = true;
|
||||
} else if (!fs.existsSync(normalizedPath)) {
|
||||
removeEntry = true;
|
||||
} else {
|
||||
try {
|
||||
const stat = fs.lstatSync(normalizedPath);
|
||||
if (stat.isDirectory()) {
|
||||
const entries = fs.readdirSync(normalizedPath);
|
||||
if (entries.length === 0) {
|
||||
fs.rmdirSync(normalizedPath);
|
||||
removeEntry = true;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
removeEntry = true;
|
||||
}
|
||||
}
|
||||
if (!removeEntry) {
|
||||
continue;
|
||||
}
|
||||
await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]);
|
||||
if (normalizedPath) {
|
||||
removedPaths.push(normalizedPath);
|
||||
}
|
||||
}
|
||||
return {
|
||||
removed: removedPaths.length,
|
||||
removedPaths: Array.from(new Set(removedPaths)).sort((left, right) => left.localeCompare(right, 'de'))
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSpecificOutputFolders(jobId, folderPaths = []) {
|
||||
const paths = Array.isArray(folderPaths) ? folderPaths.filter((p) => typeof p === 'string' && p.trim()) : [];
|
||||
if (paths.length === 0) return { deleted: [], failed: [] };
|
||||
@@ -7621,6 +7832,9 @@ class HistoryService {
|
||||
selectedRawPaths: selectedRawPathsForDelete,
|
||||
selectedMoviePaths: selectedMoviePathsForDelete
|
||||
});
|
||||
if (fileSummary?.movie?.deleted) {
|
||||
await this.removeMissingJobOutputFoldersFromJobs([normalizedJobId]);
|
||||
}
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
@@ -7798,6 +8012,9 @@ class HistoryService {
|
||||
selectedRawPaths: selectedRawPathsForDelete,
|
||||
selectedMoviePaths: selectedMoviePathsForDelete
|
||||
});
|
||||
if (fileSummary?.movie?.deleted) {
|
||||
await this.removeMissingJobOutputFoldersFromJobs(deleteJobIds);
|
||||
}
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
|
||||
@@ -23,7 +23,7 @@ const logger = require('./logger').child('PIPELINE');
|
||||
const { spawnTrackedProcess } = require('./processRunner');
|
||||
const { parseMakeMkvProgress, parseHandBrakeProgress, parseMkvmergeProgress } = require('../utils/progressParsers');
|
||||
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
|
||||
const { buildMediainfoReview } = require('../utils/encodePlan');
|
||||
const { buildMediainfoReview, refreshAudioTrackActionsForPlanTitles } = require('../utils/encodePlan');
|
||||
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
const userPresetService = require('./userPresetService');
|
||||
@@ -242,11 +242,32 @@ function resolveDiscNumberFromJobLike(job = null) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeContextJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) {
|
||||
const jobId = normalizeContextJobId(job?.id ?? job?.jobId ?? null);
|
||||
const activeContextJobId = normalizeContextJobId(
|
||||
activeContext?.jobId
|
||||
?? activeContext?.activeJobId
|
||||
?? null
|
||||
);
|
||||
const allowActiveContextMetadata = (
|
||||
jobId === null
|
||||
? true
|
||||
: (activeContextJobId !== null && activeContextJobId === jobId)
|
||||
);
|
||||
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
||||
? analyzeContext.selectedMetadata
|
||||
: {};
|
||||
const activeSelectedMetadata = activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object'
|
||||
const activeSelectedMetadata = allowActiveContextMetadata
|
||||
&& activeContext?.selectedMetadata
|
||||
&& typeof activeContext.selectedMetadata === 'object'
|
||||
? activeContext.selectedMetadata
|
||||
: {};
|
||||
const merged = {
|
||||
@@ -256,7 +277,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active
|
||||
const workflowKind = normalizeDvdMetadataWorkflowKind(
|
||||
merged.workflowKind
|
||||
|| analyzeContext?.workflowKind
|
||||
|| activeContext?.workflowKind
|
||||
|| (allowActiveContextMetadata ? activeContext?.workflowKind : null)
|
||||
|| null
|
||||
);
|
||||
|
||||
@@ -270,7 +291,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active
|
||||
metadataProvider: String(
|
||||
merged.metadataProvider
|
||||
|| analyzeContext?.metadataProvider
|
||||
|| activeContext?.metadataProvider
|
||||
|| (allowActiveContextMetadata ? activeContext?.metadataProvider : null)
|
||||
|| 'omdb'
|
||||
).trim().toLowerCase() || 'omdb'
|
||||
};
|
||||
@@ -11382,7 +11403,17 @@ class PipelineService extends EventEmitter {
|
||||
.filter((row) => Number(row?.id) > 0 && !this.isContainerHistoryJob(row))
|
||||
.filter((row) => {
|
||||
if (isSeriesContainer) {
|
||||
return !isSeriesBatchEpisodeSubJobRow(row);
|
||||
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object'
|
||||
? row.encodePlan
|
||||
: this.safeParseJson(row?.encode_plan_json);
|
||||
const rowJobKind = String(row?.job_kind || row?.jobKind || '').trim().toLowerCase();
|
||||
const isSeriesEpisodeSubJob = (
|
||||
isSeriesBatchChildPlan(encodePlan)
|
||||
|| Boolean(encodePlan?.seriesBatchVirtualEpisode)
|
||||
|| rowJobKind === 'dvd_series_episode'
|
||||
|| rowJobKind === 'dvd_series_virtual_episode'
|
||||
);
|
||||
return !isSeriesEpisodeSubJob;
|
||||
}
|
||||
if (isMultipartContainer) {
|
||||
return this.isMultipartMovieDiscChildHistoryJob(row) || this.isMultipartMovieMergeHistoryJob(row);
|
||||
@@ -16035,7 +16066,7 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
try {
|
||||
let effectiveDetectedTitle = detectedTitle;
|
||||
let omdbCandidates = null;
|
||||
let omdbCandidates = [];
|
||||
let metadataCandidates = null;
|
||||
let metadataProvider = 'omdb';
|
||||
let seriesAnalysis = null;
|
||||
@@ -16054,9 +16085,6 @@ class PipelineService extends EventEmitter {
|
||||
if (pluginDetectedTitle) {
|
||||
effectiveDetectedTitle = pluginDetectedTitle;
|
||||
}
|
||||
if (Array.isArray(pluginResult?.omdbCandidates)) {
|
||||
omdbCandidates = pluginResult.omdbCandidates;
|
||||
}
|
||||
logger.info('plugin:analyze:used', {
|
||||
jobId: normalizedJobId,
|
||||
pluginId: analyzePlugin.id,
|
||||
@@ -16076,7 +16104,6 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) {
|
||||
try {
|
||||
const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||
const rawMedia = collectRawMediaCandidates(resolvedRawPath);
|
||||
const seriesScanInputPath = mediaProfile === 'dvd'
|
||||
? (resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath)
|
||||
@@ -16183,21 +16210,6 @@ class PipelineService extends EventEmitter {
|
||||
seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery
|
||||
};
|
||||
}
|
||||
|
||||
if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) {
|
||||
metadataCandidates = await tmdbService.searchSeriesWithSeasons(
|
||||
seriesLookupHint.query,
|
||||
{ language: seriesSettings?.dvd_series_language || null }
|
||||
).catch((error) => {
|
||||
logger.warn('dvd-series:tmdb-search-failed', {
|
||||
jobId: normalizedJobId,
|
||||
query: seriesLookupHint?.query || null,
|
||||
seasonNumber: seriesLookupHint?.seasonNumber || null,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('dvd-series:analyze:done', {
|
||||
@@ -16220,7 +16232,7 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
|
||||
omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
|
||||
omdbCandidates = [];
|
||||
}
|
||||
if (metadataProvider !== 'tmdb') {
|
||||
metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : [];
|
||||
@@ -16262,8 +16274,8 @@ class PipelineService extends EventEmitter {
|
||||
normalizedJobId,
|
||||
'SYSTEM',
|
||||
metadataProvider === 'tmdb'
|
||||
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.`
|
||||
: `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
|
||||
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).`
|
||||
: `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").`
|
||||
);
|
||||
|
||||
await this.setState('METADATA_SELECTION', {
|
||||
@@ -16294,7 +16306,7 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
void this.notifyPushover('metadata_ready', {
|
||||
title: 'Ripster - Metadaten bereit',
|
||||
message: `Job #${normalizedJobId}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)`
|
||||
message: `Job #${normalizedJobId}: Metadaten müssen manuell zugeordnet werden`
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -16448,7 +16460,7 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
try {
|
||||
let effectiveDetectedTitle = detectedTitle;
|
||||
let omdbCandidates = null;
|
||||
let omdbCandidates = [];
|
||||
let metadataCandidates = null;
|
||||
let metadataProvider = 'omdb';
|
||||
let seriesAnalysis = null;
|
||||
@@ -16466,9 +16478,6 @@ class PipelineService extends EventEmitter {
|
||||
if (pluginDetectedTitle) {
|
||||
effectiveDetectedTitle = pluginDetectedTitle;
|
||||
}
|
||||
if (Array.isArray(pluginResult?.omdbCandidates)) {
|
||||
omdbCandidates = pluginResult.omdbCandidates;
|
||||
}
|
||||
logger.info('plugin:analyze:used', {
|
||||
jobId: job.id,
|
||||
pluginId: analyzePlugin.id,
|
||||
@@ -16486,7 +16495,6 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) {
|
||||
try {
|
||||
const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||
const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, {
|
||||
jobId: job.id,
|
||||
deviceInfo: deviceWithProfile,
|
||||
@@ -16586,21 +16594,6 @@ class PipelineService extends EventEmitter {
|
||||
seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery
|
||||
};
|
||||
}
|
||||
|
||||
if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) {
|
||||
metadataCandidates = await tmdbService.searchSeriesWithSeasons(
|
||||
seriesLookupHint.query,
|
||||
{ language: seriesSettings?.dvd_series_language || null }
|
||||
).catch((error) => {
|
||||
logger.warn('dvd-series:tmdb-search-failed', {
|
||||
jobId: job.id,
|
||||
query: seriesLookupHint?.query || null,
|
||||
seasonNumber: seriesLookupHint?.seasonNumber || null,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
return [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('dvd-series:analyze:done', {
|
||||
@@ -16621,7 +16614,7 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
|
||||
omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
|
||||
omdbCandidates = [];
|
||||
}
|
||||
if (metadataProvider !== 'tmdb') {
|
||||
metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : [];
|
||||
@@ -16662,8 +16655,8 @@ class PipelineService extends EventEmitter {
|
||||
job.id,
|
||||
'SYSTEM',
|
||||
metadataProvider === 'tmdb'
|
||||
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.`
|
||||
: `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
|
||||
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).`
|
||||
: `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").`
|
||||
);
|
||||
|
||||
const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id);
|
||||
@@ -16705,7 +16698,7 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
void this.notifyPushover('metadata_ready', {
|
||||
title: 'Ripster - Metadaten bereit',
|
||||
message: `Job #${job.id}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)`
|
||||
message: `Job #${job.id}: Metadaten müssen manuell zugeordnet werden`
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -21500,6 +21493,25 @@ class PipelineService extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
||||
const actionDisplaySettings = {
|
||||
...(confirmSettings && typeof confirmSettings === 'object' ? confirmSettings : {}),
|
||||
handbrake_preset: String(
|
||||
resolvedUserPreset?.handbrakePreset
|
||||
|| confirmSettings?.handbrake_preset
|
||||
|| ''
|
||||
).trim() || null,
|
||||
handbrake_extra_args: String(
|
||||
resolvedUserPreset?.extraArgs
|
||||
|| confirmSettings?.handbrake_extra_args
|
||||
|| ''
|
||||
).trim()
|
||||
};
|
||||
confirmedPlan.titles = refreshAudioTrackActionsForPlanTitles(
|
||||
confirmedPlan.titles,
|
||||
actionDisplaySettings,
|
||||
null
|
||||
);
|
||||
|
||||
const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings(
|
||||
confirmSettings,
|
||||
readyMediaProfile,
|
||||
@@ -25168,6 +25180,7 @@ class PipelineService extends EventEmitter {
|
||||
selectedTitleId: effectiveSelectedTitleId,
|
||||
disableMinLengthFilter,
|
||||
backupOutputBase,
|
||||
sourceArgOverride: null,
|
||||
runCommand: this.runCommand.bind(this)
|
||||
});
|
||||
makemkvInfo = await ripPlugin.rip(job, ripCtx);
|
||||
@@ -25325,7 +25338,6 @@ class PipelineService extends EventEmitter {
|
||||
// Retry starts processing immediately (Rip/Merge) and bypasses the encode queue.
|
||||
return this.retry(jobId, { ...options, immediate: true });
|
||||
}
|
||||
|
||||
const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry');
|
||||
jobId = resolvedRetryJob.resolvedJobId;
|
||||
this.ensureNotBusy('retry', jobId);
|
||||
@@ -25357,7 +25369,9 @@ class PipelineService extends EventEmitter {
|
||||
const isCdRetry = mediaProfile === 'cd';
|
||||
const isAudiobookRetry = mediaProfile === 'audiobook';
|
||||
const isMultipartMergeRetry = sourceJobKind === 'multipart_movie_merge';
|
||||
const isVideoDiscRetry = mediaProfile === 'dvd' || mediaProfile === 'bluray';
|
||||
const sourceParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id);
|
||||
|
||||
let mergeContainerJobId = null;
|
||||
if (isMultipartMergeRetry) {
|
||||
mergeContainerJobId = this.normalizeQueueJobId(sourceEncodePlan?.containerJobId);
|
||||
@@ -25391,12 +25405,21 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
|
||||
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|
||||
const retryableByStatus = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|
||||
|| ['ERROR', 'CANCELLED'].includes(sourceLastState)
|
||||
|| (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus));
|
||||
const incompleteRipRetryableStates = ['WAITING_FOR_USER_DECISION', 'READY_TO_ENCODE', 'MEDIAINFO_CHECK'];
|
||||
const retryableByIncompleteVideoRipState = isVideoDiscRetry
|
||||
&& !isMultipartMergeRetry
|
||||
&& (
|
||||
incompleteRipRetryableStates.includes(sourceStatus)
|
||||
|| incompleteRipRetryableStates.includes(sourceLastState)
|
||||
);
|
||||
const retryable = retryableByStatus || retryableByIncompleteVideoRipState;
|
||||
if (!retryable) {
|
||||
const currentStateLabel = sourceStatus || sourceLastState || '-';
|
||||
const error = new Error(
|
||||
`Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).`
|
||||
`Retry nicht möglich: Job ${jobId} ist nicht in einem retry-fähigen Status (aktuell ${currentStateLabel}).`
|
||||
);
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
@@ -25690,7 +25713,11 @@ class PipelineService extends EventEmitter {
|
||||
});
|
||||
} else {
|
||||
this.startRipEncode(retryJobId).catch((error) => {
|
||||
logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) });
|
||||
logger.error('retry:background-failed', {
|
||||
jobId: retryJobId,
|
||||
sourceJobId: jobId,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1176,7 +1176,8 @@ class SettingsService {
|
||||
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||||
? 'backup'
|
||||
: 'mkv';
|
||||
const sourceArg = this.resolveSourceArg(map, deviceInfo);
|
||||
const sourceArgOverride = String(options?.sourceArgOverride || '').trim();
|
||||
const sourceArg = sourceArgOverride || this.resolveSourceArg(map, deviceInfo);
|
||||
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
|
||||
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
|
||||
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
|
||||
@@ -1210,6 +1211,7 @@ class SettingsService {
|
||||
if (hasExplicitTitle) {
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--decrypt',
|
||||
'mkv',
|
||||
sourceArg,
|
||||
targetTitle,
|
||||
@@ -1221,6 +1223,7 @@ class SettingsService {
|
||||
: [];
|
||||
baseArgs = [
|
||||
'-r', '--progress=-same',
|
||||
'--decrypt',
|
||||
...minLengthArgs,
|
||||
'mkv',
|
||||
sourceArg,
|
||||
|
||||
@@ -1243,6 +1243,66 @@ function computeAudioTrackActions(track, selectedIndex, selector) {
|
||||
};
|
||||
}
|
||||
|
||||
function refreshAudioTrackActionsForPlanTitles(titles, settings = {}, presetProfile = null) {
|
||||
const sourceTitles = Array.isArray(titles) ? titles : [];
|
||||
if (sourceTitles.length === 0) {
|
||||
return sourceTitles;
|
||||
}
|
||||
|
||||
const selectors = buildTrackSelectors(settings || {}, presetProfile || null);
|
||||
const audioSelector = selectors?.audio && typeof selectors.audio === 'object'
|
||||
? selectors.audio
|
||||
: {};
|
||||
|
||||
return sourceTitles.map((title) => {
|
||||
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
|
||||
if (audioTracks.length === 0) {
|
||||
return title;
|
||||
}
|
||||
|
||||
const selectedTracks = audioTracks.filter((track) => Boolean(track?.selectedForEncode));
|
||||
const selectedIndexById = new Map(
|
||||
selectedTracks
|
||||
.map((track, index) => {
|
||||
const trackId = Number(track?.id);
|
||||
if (!Number.isFinite(trackId)) {
|
||||
return null;
|
||||
}
|
||||
return [Math.trunc(trackId), index];
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const nextAudioTracks = audioTracks.map((track) => {
|
||||
if (!track?.selectedForEncode) {
|
||||
return {
|
||||
...track,
|
||||
encodeActions: [],
|
||||
encodeActionSummary: 'Nicht übernommen'
|
||||
};
|
||||
}
|
||||
const numericTrackId = Number(track?.id);
|
||||
const normalizedTrackId = Number.isFinite(numericTrackId) ? Math.trunc(numericTrackId) : null;
|
||||
const selectedIndex = (normalizedTrackId !== null && selectedIndexById.has(normalizedTrackId))
|
||||
? selectedIndexById.get(normalizedTrackId)
|
||||
: 0;
|
||||
const actions = computeAudioTrackActions(track, selectedIndex, audioSelector);
|
||||
return {
|
||||
...track,
|
||||
encodePreviewActions: actions.actions,
|
||||
encodePreviewSummary: actions.summary,
|
||||
encodeActions: actions.actions,
|
||||
encodeActionSummary: actions.summary
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...title,
|
||||
audioTracks: nextAudioTracks
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function computeSubtitleFlags(trackId, selectedTrackIds, selector) {
|
||||
const selected = selectedTrackIds.includes(trackId);
|
||||
if (!selected) {
|
||||
@@ -1526,5 +1586,6 @@ function buildMediainfoReview({
|
||||
|
||||
module.exports = {
|
||||
parseDurationSeconds,
|
||||
buildMediainfoReview
|
||||
buildMediainfoReview,
|
||||
refreshAudioTrackActionsForPlanTitles
|
||||
};
|
||||
|
||||
+1
-1
@@ -647,7 +647,7 @@ VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'n
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei Metadaten-Auswahl senden', 'boolean', 1, 'Sendet wenn Metadaten zur Auswahl bereitstehen.', 'true', '[]', '{}', 570);
|
||||
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei manueller Auswahl senden', 'boolean', 1, 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).', 'true', '[]', '{}', 570);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -184,6 +184,78 @@ function trackSizeFormat(bytes) {
|
||||
return `${size.toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function resolveManualTrackSelectionForTitle(encodePlan, titleId) {
|
||||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
|
||||
const normalizedTitleId = normalizePositiveInteger(titleId);
|
||||
if (!normalizedTitleId) {
|
||||
return null;
|
||||
}
|
||||
const byTitle = plan?.manualTrackSelectionByTitle && typeof plan.manualTrackSelectionByTitle === 'object'
|
||||
? plan.manualTrackSelectionByTitle
|
||||
: {};
|
||||
const directSelection = byTitle[normalizedTitleId] || byTitle[String(normalizedTitleId)] || null;
|
||||
if (directSelection && typeof directSelection === 'object') {
|
||||
return directSelection;
|
||||
}
|
||||
const fallbackSelection = plan?.manualTrackSelection && typeof plan.manualTrackSelection === 'object'
|
||||
? plan.manualTrackSelection
|
||||
: null;
|
||||
if (!fallbackSelection) {
|
||||
return null;
|
||||
}
|
||||
const fallbackTitleId = normalizePositiveInteger(fallbackSelection?.titleId);
|
||||
return fallbackTitleId === normalizedTitleId ? fallbackSelection : null;
|
||||
}
|
||||
|
||||
function buildEffectiveTitleTrackSelection(encodePlan, title) {
|
||||
const manualSelection = resolveManualTrackSelectionForTitle(encodePlan, title?.id);
|
||||
const hasManualAudio = Array.isArray(manualSelection?.audioTrackIds);
|
||||
const hasManualSubtitle = Array.isArray(manualSelection?.subtitleTrackIdsOrdered) || Array.isArray(manualSelection?.subtitleTrackIds);
|
||||
const selectedAudioIds = hasManualAudio
|
||||
? normalizeIdList(manualSelection.audioTrackIds)
|
||||
: normalizeIdList((Array.isArray(title?.audioTracks) ? title.audioTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedForEncode))
|
||||
.map((track) => track?.id));
|
||||
const selectedSubtitleIds = hasManualSubtitle
|
||||
? normalizeIdList(
|
||||
Array.isArray(manualSelection?.subtitleTrackIdsOrdered) && manualSelection.subtitleTrackIdsOrdered.length > 0
|
||||
? manualSelection.subtitleTrackIdsOrdered
|
||||
: manualSelection?.subtitleTrackIds
|
||||
)
|
||||
: normalizeIdList((Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedForEncode))
|
||||
.map((track) => track?.id));
|
||||
|
||||
return {
|
||||
selectedAudioSet: new Set(selectedAudioIds.map((id) => String(id))),
|
||||
selectedSubtitleSet: new Set(selectedSubtitleIds.map((id) => String(id))),
|
||||
hasManualAudio,
|
||||
hasManualSubtitle
|
||||
};
|
||||
}
|
||||
|
||||
function getTrackActionLabel({
|
||||
selected = false,
|
||||
summary = '',
|
||||
manualSelection = false,
|
||||
fallback = 'Übernehmen'
|
||||
}) {
|
||||
if (!selected) {
|
||||
return 'Nicht übernommen';
|
||||
}
|
||||
const rawSummary = String(summary || '').trim();
|
||||
if (!rawSummary) {
|
||||
return manualSelection ? `${fallback} (manuell)` : fallback;
|
||||
}
|
||||
if (/^nicht übernommen/i.test(rawSummary)) {
|
||||
return manualSelection ? `${fallback} (manuell)` : fallback;
|
||||
}
|
||||
if (/^preset-default\b/i.test(rawSummary) || /Preset-Default \(HandBrake\)/i.test(rawSummary)) {
|
||||
return manualSelection ? `${fallback} (manuell)` : 'Übernehmen';
|
||||
}
|
||||
return rawSummary;
|
||||
}
|
||||
|
||||
function buildExecutedHandBrakeCommand(handbrakeInfo) {
|
||||
const cmd = String(handbrakeInfo?.cmd || '').trim();
|
||||
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
|
||||
@@ -1378,6 +1450,7 @@ export default function JobDetailDialog({
|
||||
const selectedEncodeTitles = encodePlanTitles.filter(
|
||||
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
|
||||
);
|
||||
const trackDisplayTitles = selectedEncodeTitles.length > 0 ? selectedEncodeTitles : encodePlanTitles;
|
||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||
const posterUrl = String(
|
||||
job?.poster_url
|
||||
@@ -1390,7 +1463,41 @@ export default function JobDetailDialog({
|
||||
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 outputFolders = (() => {
|
||||
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||
if (!isDiskContainer) {
|
||||
return baseFolders;
|
||||
}
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
const addFolder = (folder, fallbackId = null) => {
|
||||
const outputPath = String(folder?.output_path || '').trim();
|
||||
if (!outputPath || seen.has(outputPath)) {
|
||||
return;
|
||||
}
|
||||
seen.add(outputPath);
|
||||
merged.push({
|
||||
id: folder?.id ?? fallbackId ?? outputPath,
|
||||
output_path: outputPath
|
||||
});
|
||||
};
|
||||
for (const folder of baseFolders) {
|
||||
addFolder(folder);
|
||||
}
|
||||
for (const child of childJobs) {
|
||||
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
|
||||
for (const childFolder of childOutputFolders) {
|
||||
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`);
|
||||
}
|
||||
if (child?.output_path) {
|
||||
addFolder(
|
||||
{ output_path: child.output_path },
|
||||
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
})();
|
||||
const mergeOutputPathCandidates = new Set(
|
||||
[
|
||||
...(isMultipartMergeJob ? [job?.output_path] : []),
|
||||
@@ -2083,7 +2190,7 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
) : null}
|
||||
{/* Spurauswahl – read-only */}
|
||||
{selectedEncodeTitles.length > 0 ? (
|
||||
{trackDisplayTitles.length > 0 ? (
|
||||
isDvdSeries ? (
|
||||
<details className="episode-track-accordion">
|
||||
<summary className="series-batch-episode-head">
|
||||
@@ -2091,9 +2198,10 @@ export default function JobDetailDialog({
|
||||
</summary>
|
||||
<div className="spurauswahl-block">
|
||||
<div className="spurauswahl-label">Spurauswahl</div>
|
||||
{selectedEncodeTitles.map((title) => {
|
||||
{trackDisplayTitles.map((title) => {
|
||||
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title);
|
||||
return (
|
||||
<div key={title.id} className="track-title-block">
|
||||
<div className="track-title-row">
|
||||
@@ -2109,9 +2217,16 @@ export default function JobDetailDialog({
|
||||
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';
|
||||
const normalizedTrackId = normalizePositiveInteger(track?.id);
|
||||
const selected = normalizedTrackId
|
||||
? trackSelection.selectedAudioSet.has(String(normalizedTrackId))
|
||||
: Boolean(track?.selectedForEncode);
|
||||
const actionInfo = getTrackActionLabel({
|
||||
selected,
|
||||
summary: track.encodeActionSummary || track.encodePreviewSummary,
|
||||
manualSelection: trackSelection.hasManualAudio,
|
||||
fallback: 'Übernehmen'
|
||||
});
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<span>{displayText}</span>
|
||||
@@ -2128,10 +2243,16 @@ export default function JobDetailDialog({
|
||||
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';
|
||||
const normalizedTrackId = normalizePositiveInteger(track?.id);
|
||||
const selected = normalizedTrackId
|
||||
? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId))
|
||||
: Boolean(track?.selectedForEncode);
|
||||
const actionInfo = getTrackActionLabel({
|
||||
selected,
|
||||
summary: track.subtitleActionSummary || track.subtitlePreviewSummary,
|
||||
manualSelection: trackSelection.hasManualSubtitle,
|
||||
fallback: 'Übernehmen'
|
||||
});
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<span>{displayText}</span>
|
||||
@@ -2150,9 +2271,10 @@ export default function JobDetailDialog({
|
||||
) : (
|
||||
<div className="spurauswahl-block">
|
||||
<div className="spurauswahl-label">Spurauswahl</div>
|
||||
{selectedEncodeTitles.map((title) => {
|
||||
{trackDisplayTitles.map((title) => {
|
||||
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
|
||||
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
|
||||
const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title);
|
||||
return (
|
||||
<div key={title.id} className="track-title-block">
|
||||
<div className="track-title-row">
|
||||
@@ -2168,9 +2290,16 @@ export default function JobDetailDialog({
|
||||
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';
|
||||
const normalizedTrackId = normalizePositiveInteger(track?.id);
|
||||
const selected = normalizedTrackId
|
||||
? trackSelection.selectedAudioSet.has(String(normalizedTrackId))
|
||||
: Boolean(track?.selectedForEncode);
|
||||
const actionInfo = getTrackActionLabel({
|
||||
selected,
|
||||
summary: track.encodeActionSummary || track.encodePreviewSummary,
|
||||
manualSelection: trackSelection.hasManualAudio,
|
||||
fallback: 'Übernehmen'
|
||||
});
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<span>{displayText}</span>
|
||||
@@ -2187,10 +2316,16 @@ export default function JobDetailDialog({
|
||||
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';
|
||||
const normalizedTrackId = normalizePositiveInteger(track?.id);
|
||||
const selected = normalizedTrackId
|
||||
? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId))
|
||||
: Boolean(track?.selectedForEncode);
|
||||
const actionInfo = getTrackActionLabel({
|
||||
selected,
|
||||
summary: track.subtitleActionSummary || track.subtitlePreviewSummary,
|
||||
manualSelection: trackSelection.hasManualSubtitle,
|
||||
fallback: 'Übernehmen'
|
||||
});
|
||||
return (
|
||||
<div key={track.id} className="track-item">
|
||||
<span>{displayText}</span>
|
||||
|
||||
@@ -179,6 +179,85 @@ function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMakeMkvRipIssueMessage(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function parseMakeMkvRipIssueLine(rawLine) {
|
||||
const line = String(rawLine || '').trim();
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceMatch = line.match(/^\[[^\]]+\]\s+\[([A-Z0-9_]+)\]\s+/i);
|
||||
const source = String(sourceMatch?.[1] || '').trim().toUpperCase();
|
||||
if (source && source !== 'MAKEMKV_RIP') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const msgCodeMatch = line.match(/\bMSG:(\d+),/i);
|
||||
const parsedCode = msgCodeMatch ? Number(msgCodeMatch[1]) : null;
|
||||
const code = Number.isFinite(parsedCode) ? Math.trunc(parsedCode) : null;
|
||||
const quotedMessageMatch = line.match(/\bMSG:\d+,[^,]*,[^,]*,"((?:[^"\\]|\\.)*)"/i);
|
||||
const fallback = line
|
||||
.replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/i, '')
|
||||
.trim();
|
||||
const rawMessage = quotedMessageMatch?.[1]
|
||||
? quotedMessageMatch[1].replace(/\\"/g, '"')
|
||||
: (fallback || line);
|
||||
const message = normalizeMakeMkvRipIssueMessage(rawMessage);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isErrorLine = /\berror\b|\bfail(?:ed|ure)?\b|\bhash check\b|\buncorrectable\b/i.test(message);
|
||||
if (!isErrorLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
function extractMakeMkvRipIssues(makemkvInfo) {
|
||||
if (!makemkvInfo || typeof makemkvInfo !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceLines = [];
|
||||
if (Array.isArray(makemkvInfo?.highlights)) {
|
||||
sourceLines.push(...makemkvInfo.highlights);
|
||||
}
|
||||
|
||||
const stdoutTail = String(makemkvInfo?.stdoutTail || '').trim();
|
||||
if (stdoutTail) {
|
||||
sourceLines.push(...stdoutTail.split('\n'));
|
||||
}
|
||||
|
||||
const stderrTail = String(makemkvInfo?.stderrTail || '').trim();
|
||||
if (stderrTail) {
|
||||
sourceLines.push(...stderrTail.split('\n'));
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const issues = [];
|
||||
for (const line of sourceLines) {
|
||||
const parsed = parseMakeMkvRipIssueLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const key = `${parsed.code ?? '-'}|${parsed.message.toLowerCase()}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
issues.push(parsed);
|
||||
if (issues.length >= 12) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function isBurnedSubtitleTrack(track) {
|
||||
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
||||
? track.subtitlePreviewFlags
|
||||
@@ -2880,6 +2959,33 @@ export default function PipelineStatusCard({
|
||||
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
|
||||
)
|
||||
: (pipeline?.statusText || 'Bereit');
|
||||
const ripIssueEntries = useMemo(
|
||||
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
|
||||
[jobRow?.makemkvInfo]
|
||||
);
|
||||
const ripIssueVisibleEntries = ripIssueEntries.slice(0, 8);
|
||||
const hiddenRipIssueCount = Math.max(0, ripIssueEntries.length - ripIssueVisibleEntries.length);
|
||||
const ripIssueSummaryLabel = ripIssueEntries.length === 1
|
||||
? '1 Rip-Fehler erkannt'
|
||||
: `${ripIssueEntries.length} Rip-Fehler erkannt`;
|
||||
const showRipIssueNotice = Boolean(
|
||||
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
|
||||
&& ripCompleted
|
||||
&& ripIssueEntries.length > 0
|
||||
&& !running
|
||||
&& (
|
||||
state === 'WAITING_FOR_USER_DECISION'
|
||||
|| state === 'READY_TO_ENCODE'
|
||||
|| state === 'MEDIAINFO_CHECK'
|
||||
|| Boolean(mediaInfoReview)
|
||||
)
|
||||
);
|
||||
const canTriggerRipRetry = Boolean(
|
||||
showRipIssueNotice
|
||||
&& retryJobId
|
||||
&& typeof onRetry === 'function'
|
||||
&& !queueLocked
|
||||
);
|
||||
const isSeriesBatchParentReview = Boolean(
|
||||
mediaInfoReview?.seriesBatchParent
|
||||
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
|
||||
@@ -3672,6 +3778,42 @@ export default function PipelineStatusCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showRipIssueNotice ? (
|
||||
<div className="rip-error-notice" role="alert" aria-live="polite">
|
||||
<div className="rip-error-notice-head">
|
||||
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
|
||||
<strong>Hinweis: Beim Rip sind Fehler aufgetreten.</strong>
|
||||
</div>
|
||||
<small>{ripIssueSummaryLabel}. Bitte vor der Bestätigung von Playlist-/Spurauswahl prüfen.</small>
|
||||
<div className="rip-error-notice-list">
|
||||
{ripIssueVisibleEntries.map((entry, index) => (
|
||||
<div key={`rip-issue-${entry.code ?? 'none'}-${index}`} className="rip-error-notice-item">
|
||||
<span className="rip-error-notice-code">{entry.code !== null ? `MSG:${entry.code}` : 'Fehler'}</span>
|
||||
<span className="rip-error-notice-message">{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<small className="rip-error-notice-more">
|
||||
{hiddenRipIssueCount > 0
|
||||
? `+${hiddenRipIssueCount} weitere Meldung(en) im Job-Log.`
|
||||
: 'Vollständige Details stehen im Job-Log.'}
|
||||
</small>
|
||||
{canTriggerRipRetry ? (
|
||||
<div className="rip-error-notice-actions">
|
||||
<Button
|
||||
label="Retry Rippen"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onRetry?.(retryJobId)}
|
||||
loading={busy}
|
||||
disabled={busy || queueLocked || !retryJobId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rawDecisionRequiredBeforeStart && !queueLocked ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>RAW Entscheidung erforderlich</h3>
|
||||
|
||||
@@ -1581,8 +1581,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: `${deleteEntryOutputShortLabel}-Ordner auswählen`,
|
||||
detail: `Bitte mindestens einen ${deleteEntryOutputShortLabel}-Ordner zum Löschen auswählen.`,
|
||||
summary: `${deleteEntryOutputShortLabel}-Dateien auswählen`,
|
||||
detail: `Bitte mindestens eine ${deleteEntryOutputShortLabel}-Datei zum Löschen auswählen.`,
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
@@ -2471,7 +2471,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h4>{deleteEntryOutputShortLabel}</h4>
|
||||
<h4>{deleteEntryOutputShortLabel} (Dateien)</h4>
|
||||
{previewMoviePaths.length > 0 ? (() => {
|
||||
return (
|
||||
<ul className="history-delete-preview-list">
|
||||
@@ -2502,7 +2502,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
</ul>
|
||||
);
|
||||
})() : (
|
||||
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
|
||||
<small className="history-dv-subtle">Keine Output-Dateien.</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1079,7 +1079,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
||||
? existingContext.tracks
|
||||
: computedContext.tracks,
|
||||
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
|
||||
selectedMetadata: computedContext.selectedMetadata || existingContext.selectedMetadata,
|
||||
canRestartEncodeFromLastSettings:
|
||||
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
|
||||
canRestartReviewFromRaw:
|
||||
@@ -1116,7 +1116,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
|
||||
? liveContext.tracks
|
||||
: computedContext.tracks,
|
||||
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
|
||||
selectedMetadata: computedContext.selectedMetadata || liveContext.selectedMetadata,
|
||||
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
|
||||
}
|
||||
: computedContext;
|
||||
|
||||
@@ -3705,6 +3705,74 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rip-error-notice {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #d48c7c;
|
||||
border-left: 4px solid #b6452c;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
background: #fff1ec;
|
||||
color: #4e2418;
|
||||
}
|
||||
|
||||
.rip-error-notice-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rip-error-notice-head .pi {
|
||||
color: #b6452c;
|
||||
}
|
||||
|
||||
.rip-error-notice-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rip-error-notice-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border: 1px dashed #d8a79b;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff8f6;
|
||||
}
|
||||
|
||||
.rip-error-notice-code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.9rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.3rem;
|
||||
background: #b6452c;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.rip-error-notice-message {
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rip-error-notice-more {
|
||||
color: #7a3a2a;
|
||||
}
|
||||
|
||||
.rip-error-notice-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-decision-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
||||
+2
-1
@@ -645,7 +645,8 @@ apt-get install -y \
|
||||
util-linux udev \
|
||||
ca-certificates gnupg \
|
||||
lsb-release \
|
||||
mkvtoolnix
|
||||
mkvtoolnix \
|
||||
coreutils
|
||||
|
||||
ok "Basispakete installiert"
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.15.0-2",
|
||||
"version": "0.15.0-3",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user