Compare commits

...

5 Commits

19 changed files with 1331 additions and 208 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.13.1-4",
"version": "0.13.1-6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.13.1-4",
"version": "0.13.1-6",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.13.1-4",
"version": "0.13.1-6",
"private": true,
"type": "commonjs",
"scripts": {
+43 -3
View File
@@ -850,7 +850,7 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
{
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}.',
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
validation_json: '{"minLength":1}'
}
];
@@ -925,6 +925,46 @@ async function migrateSettingsSchemaMetadata(db) {
});
}
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
];
const legacyTemplatePlaceholders = legacyDvdSeriesMultiEpisodeTemplateDefaults.map(() => '?').join(', ');
await db.run(
`
UPDATE settings_schema
SET default_value = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'output_template_dvd_series_multi_episode'
AND (
default_value IS NULL
OR TRIM(default_value) = ''
OR default_value IN (${legacyTemplatePlaceholders})
)
`,
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
);
await db.run(
`
INSERT INTO settings_values (key, value, updated_at)
VALUES ('output_template_dvd_series_multi_episode', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO NOTHING
`,
[dvdSeriesMultiEpisodeTemplateDefault]
);
await db.run(
`
UPDATE settings_values
SET value = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'output_template_dvd_series_multi_episode'
AND (
value IS NULL
OR TRIM(value) = ''
OR value IN (${legacyTemplatePlaceholders})
)
`,
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
);
// Migrate raw_dir_cd_owner label
await db.run(
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
@@ -1042,9 +1082,9 @@ async function migrateSettingsSchemaMetadata(db) {
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)`
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}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"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_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
+16 -3
View File
@@ -56,9 +56,22 @@ router.post(
asyncHandler(async (req, res) => {
const rawPath = String(req.body?.rawPath || '').trim();
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
const job = await historyService.importOrphanRawFolder(rawPath);
const uiReset = await pipelineService.resetFrontendState('history_orphan_import');
res.json({ job, uiReset });
const importedJob = await historyService.importOrphanRawFolder(rawPath);
const importedJobId = Number(importedJob?.id || 0);
const activation = (Number.isFinite(importedJobId) && importedJobId > 0)
? await pipelineService.analyzeRawImportJob(importedJobId, {
rawPath: importedJob?.raw_path || rawPath
})
: null;
const refreshedJob = importedJobId > 0
? await historyService.getJobById(importedJobId)
: null;
res.json({
job: refreshedJob || importedJob,
activation
});
})
);
+3
View File
@@ -366,6 +366,7 @@ router.post(
providerId,
tmdbId,
metadataKind,
workflowKind,
seasonNumber,
seasonName,
episodeCount,
@@ -393,6 +394,7 @@ router.post(
metadataProvider,
providerId,
tmdbId,
workflowKind,
seasonNumber,
discNumber
});
@@ -411,6 +413,7 @@ router.post(
providerId,
tmdbId,
metadataKind,
workflowKind,
seasonNumber,
seasonName,
episodeCount,
+88 -127
View File
@@ -1159,6 +1159,20 @@ function normalizePositiveIntegerOrNull(value) {
return Math.trunc(parsed);
}
function normalizeDvdMetadataWorkflowKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode'].includes(raw)) {
return 'series';
}
return null;
}
function resolveSeriesAssignmentEpisodeSpan(assignment = null) {
const source = assignment && typeof assignment === 'object' ? assignment : {};
const start = normalizePositiveIntegerOrNull(
@@ -1313,6 +1327,17 @@ function extractSelectedMetadataFromMakemkvInfo(makemkvInfo = {}) {
function hasSeriesMetadataSignals(selectedMetadata = {}, analyzeContext = {}) {
const metadata = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
const workflowKind = normalizeDvdMetadataWorkflowKind(
metadata.workflowKind
|| analyze.workflowKind
|| null
);
if (workflowKind === 'film') {
return false;
}
if (workflowKind === 'series') {
return true;
}
const metadataKind = String(metadata.metadataKind || analyze.metadataKind || '').trim().toLowerCase();
const seasonNumber = normalizePositiveNumberOrNull(
metadata.seasonNumber ?? analyze?.seriesLookupHint?.seasonNumber ?? null
@@ -4967,114 +4992,6 @@ class HistoryService {
const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object'
? sourceJobContext.selectedMetadata
: {};
const sourceAnalyzeContext = sourceJobContext?.analyzeContext && typeof sourceJobContext.analyzeContext === 'object'
? sourceJobContext.analyzeContext
: {};
const sourceHasSeriesSignals = hasSeriesMetadataSignals(sourceSelectedMetadata, sourceAnalyzeContext);
const seriesImportHints = effectiveDetectedMediaType === 'dvd' && (seriesRawPathHint || sourceHasSeriesSignals)
? buildSeriesImportHints({
folderName: path.basename(finalRawPath),
rawPath: finalRawPath,
metadata,
sourceSelectedMetadata,
sourceAnalyzeContext
})
: null;
let effectiveSeriesImportHints = seriesImportHints;
let inferredSeriesContainer = null;
if (effectiveDetectedMediaType === 'dvd') {
const hintedTmdbId = normalizePositiveIntegerOrNull(
effectiveSeriesImportHints?.selectedMetadata?.tmdbId
?? effectiveSeriesImportHints?.selectedMetadata?.providerId
?? null
);
const hintedSeasonNumber = normalizePositiveIntegerOrNull(
effectiveSeriesImportHints?.selectedMetadata?.seasonNumber
?? effectiveSeriesImportHints?.analyzeContextPatch?.seriesLookupHint?.seasonNumber
?? null
);
if (hintedTmdbId && hintedSeasonNumber) {
inferredSeriesContainer = await this.findSeriesContainerJob(hintedTmdbId, hintedSeasonNumber);
}
if (!inferredSeriesContainer && seriesRawPathHint) {
inferredSeriesContainer = await this.findLikelySeriesContainerJob({
title: sourceSelectedMetadata?.title || sourceSelectedMetadata?.seriesTitle || effectiveTitle || metadata.title || null,
year: sourceSelectedMetadata?.year || sourceJob?.year || metadata.year || null
});
}
if (inferredSeriesContainer) {
const containerInfo = parseJsonSafe(inferredSeriesContainer?.makemkv_info_json, {}) || {};
const containerSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(containerInfo);
const containerAnalyzeContext = containerInfo?.analyzeContext && typeof containerInfo.analyzeContext === 'object'
? containerInfo.analyzeContext
: {};
const containerTmdbId = normalizePositiveIntegerOrNull(
containerSelectedMetadata?.tmdbId
|| containerSelectedMetadata?.providerId
|| null
);
const containerSeasonNumber = normalizePositiveIntegerOrNull(containerSelectedMetadata?.seasonNumber || null);
const discNumberHint = normalizePositiveIntegerOrNull(
effectiveSeriesImportHints?.selectedMetadata?.discNumber
?? extractDiscNumberFromText(`${path.basename(finalRawPath)} ${path.basename(absRawPath)}`)
?? null
);
if (containerTmdbId && containerSeasonNumber) {
const mergedTitle = String(
effectiveSeriesImportHints?.selectedMetadata?.title
|| containerSelectedMetadata?.title
|| effectiveTitle
|| inferredSeriesContainer?.title
|| ''
).trim() || null;
effectiveSeriesImportHints = {
selectedMetadata: {
...containerSelectedMetadata,
...(effectiveSeriesImportHints?.selectedMetadata && typeof effectiveSeriesImportHints.selectedMetadata === 'object'
? effectiveSeriesImportHints.selectedMetadata
: {}),
metadataProvider: 'tmdb',
metadataKind: String(containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series',
tmdbId: containerTmdbId,
seasonNumber: containerSeasonNumber,
title: mergedTitle,
...(discNumberHint ? { discNumber: discNumberHint } : {})
},
analyzeContextPatch: {
...(effectiveSeriesImportHints?.analyzeContextPatch && typeof effectiveSeriesImportHints.analyzeContextPatch === 'object'
? effectiveSeriesImportHints.analyzeContextPatch
: {}),
metadataProvider: 'tmdb',
metadataKind: String(containerAnalyzeContext?.metadataKind || containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series',
seriesLookupHint: {
...(containerAnalyzeContext?.seriesLookupHint && typeof containerAnalyzeContext.seriesLookupHint === 'object'
? containerAnalyzeContext.seriesLookupHint
: {}),
query: mergedTitle,
seasonNumber: containerSeasonNumber,
...(discNumberHint ? { discNumber: discNumberHint } : {})
},
seriesAnalysis: {
...(containerAnalyzeContext?.seriesAnalysis && typeof containerAnalyzeContext.seriesAnalysis === 'object'
? containerAnalyzeContext.seriesAnalysis
: {}),
summary: {
...(containerAnalyzeContext?.seriesAnalysis?.summary && typeof containerAnalyzeContext.seriesAnalysis.summary === 'object'
? containerAnalyzeContext.seriesAnalysis.summary
: {}),
seriesLike: true,
confidence: containerAnalyzeContext?.seriesAnalysis?.summary?.confidence || 'high'
}
}
}
};
}
}
}
const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null);
const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null;
const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
@@ -5083,12 +5000,13 @@ class HistoryService {
requestedRawPath: absRawPath,
sourceFolderJobId: metadata.folderJobId || null,
mediaProfile: effectiveDetectedMediaType,
existingInfo: sourceJobContext?.makemkvInfo || null,
// RAW-Import soll wie "Disk analysieren" starten:
// keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan.
existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null,
recovery: cdRecovery,
selectedMetadata: effectiveSeriesImportHints?.selectedMetadata || null,
analyzeContextPatch: effectiveSeriesImportHints?.analyzeContextPatch || null
selectedMetadata: null,
analyzeContextPatch: null
});
const inferredSeriesContainerId = normalizeJobIdValue(inferredSeriesContainer?.id);
const recoveredPosterUrl = orphanPosterUrl
|| (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate)
|| null;
@@ -5108,11 +5026,7 @@ class HistoryService {
await this.updateJob(created.id, {
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
...(effectiveDetectedMediaType === 'dvd'
? (inferredSeriesContainerId
? { job_kind: 'dvd_series_child', media_type: 'dvd', parent_job_id: inferredSeriesContainerId }
: { job_kind: 'dvd', media_type: 'dvd' })
: {}),
...(effectiveDetectedMediaType === 'dvd' ? { job_kind: 'dvd', media_type: 'dvd', parent_job_id: null } : {}),
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
status: 'FINISHED',
last_state: 'FINISHED',
@@ -5191,13 +5105,6 @@ class HistoryService {
`Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.`
);
}
if (inferredSeriesContainerId) {
await this.appendLog(
created.id,
'SYSTEM',
`Automatisch dem Serien-Container #${inferredSeriesContainerId} zugeordnet.`
);
}
logger.info('job:import-orphan-raw', {
jobId: created.id,
@@ -5236,6 +5143,7 @@ class HistoryService {
};
const requestedProviderRaw = String(payload.metadataProvider || '').trim().toLowerCase();
const requestedTmdbId = parseTmdbId(payload?.tmdbId ?? payload?.providerId ?? null);
const requestedWorkflowKind = normalizeDvdMetadataWorkflowKind(payload?.workflowKind);
const metadataProvider = requestedProviderRaw === 'themoviedb'
? 'tmdb'
: (requestedProviderRaw || (requestedTmdbId !== null ? 'tmdb' : 'omdb'));
@@ -5455,6 +5363,7 @@ class HistoryService {
year,
imdbId,
poster: posterUrl,
workflowKind: 'series',
metadataProvider: 'tmdb',
providerId,
tmdbId: effectiveTmdbId,
@@ -5474,6 +5383,7 @@ class HistoryService {
: {};
const nextAnalyzeContext = {
...analyzeContext,
workflowKind: 'series',
metadataProvider: 'tmdb',
metadataKind,
selectedMetadata: {
@@ -5563,24 +5473,68 @@ class HistoryService {
const imdbId = omdb?.imdbId || imdbIdInput || job.imdb_id || null;
const posterUrl = omdb?.poster || manualPoster || job.poster_url || null;
const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0);
const resolvedJobMediaType = inferMediaType(
job,
makemkvInfo,
job?.mediainfo_info_json,
job?.encode_plan_json,
job?.handbrake_info_json
);
const shouldUseFilmWorkflow = resolvedJobMediaType === 'dvd' && metadataProvider === 'omdb';
const existingWorkflowKind = normalizeDvdMetadataWorkflowKind(
existingSelectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| null
);
const effectiveWorkflowKind = shouldUseFilmWorkflow
? 'film'
: (requestedWorkflowKind || existingWorkflowKind);
const nextSelectedMetadata = {
...(existingSelectedMetadata && typeof existingSelectedMetadata === 'object' ? existingSelectedMetadata : {}),
title,
year,
imdbId,
poster: posterUrl,
metadataProvider: 'omdb'
metadataProvider: 'omdb',
...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}),
...(shouldUseFilmWorkflow
? {
providerId: null,
tmdbId: null,
metadataKind: 'movie',
seasonNumber: null,
seasonName: null,
episodeCount: 0,
episodes: [],
discNumber: null,
tmdbDetails: null
}
: {})
};
const existingAnalyzeSelected = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: {};
const existingSeriesLookupHint = analyzeContext?.seriesLookupHint && typeof analyzeContext.seriesLookupHint === 'object'
? analyzeContext.seriesLookupHint
: {};
const nextAnalyzeContext = {
...analyzeContext,
...(effectiveWorkflowKind ? { workflowKind: effectiveWorkflowKind } : {}),
metadataProvider: 'omdb',
...(shouldUseFilmWorkflow ? { metadataKind: 'movie' } : {}),
selectedMetadata: {
...existingAnalyzeSelected,
...nextSelectedMetadata
}
},
...(shouldUseFilmWorkflow
? {
seriesLookupHint: {
...existingSeriesLookupHint,
seasonNumber: null,
discNumber: null
}
}
: {})
};
const topLevelSelected = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
@@ -5601,7 +5555,14 @@ class HistoryService {
poster_url: posterUrl,
omdb_json: omdb?.raw ? JSON.stringify(omdb.raw) : (job.omdb_json || null),
selected_from_omdb: selectedFromOmdb,
makemkv_info_json: JSON.stringify(nextMakemkvInfo)
makemkv_info_json: JSON.stringify(nextMakemkvInfo),
...(shouldUseFilmWorkflow
? {
parent_job_id: null,
job_kind: 'dvd',
media_type: 'dvd'
}
: {})
});
// Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -468,8 +468,8 @@ VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Ser
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}');
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}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
-- Tools CD
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.13.1-4",
"version": "0.13.1-6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.13.1-4",
"version": "0.13.1-6",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.13.1-4",
"version": "0.13.1-6",
"private": true,
"type": "module",
"scripts": {
@@ -78,7 +78,10 @@ function stripEpisodePartSuffix(value) {
if (!source) {
return '';
}
const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i;
// Output cleanup for multi-episode labels:
// remove "Teil/Part" markers and pure numeric/roman suffixes in
// parentheses like "(1)" / "(2)".
const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i;
let normalized = source;
let changed = false;
for (let i = 0; i < 2; i += 1) {
@@ -4,6 +4,7 @@ import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
export default function MetadataSelectionDialog({
visible,
@@ -13,6 +14,19 @@ export default function MetadataSelectionDialog({
onSearch,
busy
}) {
const normalizeWorkflowKind = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['film', 'movie', 'feature'].includes(raw)) {
return 'film';
}
if (['series', 'tv', 'season', 'episode'].includes(raw)) {
return 'series';
}
return null;
};
const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) {
@@ -36,12 +50,35 @@ export default function MetadataSelectionDialog({
const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
|| null
);
const contextSelectedMetadataProvider = String(
context?.selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const contextMetadataProvider = (
contextWorkflowKind === 'series'
? 'tmdb'
: (contextWorkflowKind === 'film'
? 'omdb'
: (contextSelectedMetadataProvider || String(context?.metadataProvider || 'omdb').trim().toLowerCase()))
) || 'omdb';
const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider);
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
|| ''
).trim().toLowerCase();
const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb';
const providerOptions = mediaProfile === 'dvd'
? [
{ label: 'Film (OMDb)', value: 'omdb' },
{ label: 'Serie (TMDb)', value: 'tmdb' }
]
: [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }];
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
@@ -70,6 +107,7 @@ export default function MetadataSelectionDialog({
setSubmitError('');
setSeasonFilter('');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
}, [visible, context]);
useEffect(() => {
@@ -85,7 +123,7 @@ export default function MetadataSelectionDialog({
if (submitError) {
setSubmitError('');
}
}, [manualDiscNumber, seasonFilter, selected]);
}, [manualDiscNumber, seasonFilter, selected, metadataProvider]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
@@ -186,6 +224,7 @@ export default function MetadataSelectionDialog({
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
fromOmdb: metadataProvider === 'omdb',
metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null,
@@ -203,6 +242,7 @@ export default function MetadataSelectionDialog({
poster: null,
fromOmdb: false,
metadataProvider,
workflowKind: metadataProvider === 'tmdb' ? 'series' : 'film',
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
@@ -240,6 +280,25 @@ export default function MetadataSelectionDialog({
modal
>
<div className="search-row">
{providerOptions.length > 1 ? (
<Dropdown
value={metadataProvider}
options={providerOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb';
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
}}
disabled={searchBusy || busy}
style={{ minWidth: '12.5rem' }}
/>
) : null}
<InputText
value={query}
onChange={(event) => setQuery(event.target.value)}
+132 -41
View File
@@ -810,7 +810,10 @@ function stripEpisodePartSuffix(value) {
if (!source) {
return '';
}
const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i;
// Output cleanup for multi-episode labels:
// remove "Teil/Part" markers and pure numeric/roman suffixes in
// parentheses like "(1)" / "(2)".
const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)|\(\s*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i;
let normalized = source;
let changed = false;
for (let i = 0; i < 2; i += 1) {
@@ -985,10 +988,10 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
).trim();
const multiTemplateRaw = String(
settings?.output_template_dvd_series_multi_episode
|| '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
|| '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'
).trim();
const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}';
const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}';
const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const template = episodeRange.isMulti ? multiTemplate : singleTemplate;
const rendered = renderTemplate(template, {
seriesTitle,
@@ -1936,15 +1939,56 @@ export default function PipelineStatusCard({
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
const handBrakeDecisionMode = String(pipeline?.context?.handBrakeDecisionMode || '').trim().toLowerCase();
const isSeriesPlayAllDecisionMode = handBrakeDecisionMode === 'series_playall_or_double';
const handBrakeDecisionSummary = pipeline?.context?.handBrakeDecisionSummary
&& typeof pipeline.context.handBrakeDecisionSummary === 'object'
? pipeline.context.handBrakeDecisionSummary
: null;
const ambiguousLongestTitleId = normalizeTitleId(handBrakeDecisionSummary?.longestCandidateTitleId);
const handBrakeDecisionCandidateTitleIds = useMemo(() => normalizeTitleIdList(
(Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
? pipeline.context.handBrakeTitleCandidates
: []
).map((item) => item?.handBrakeTitleId)
), [pipeline?.context?.handBrakeTitleCandidates]);
const handBrakeDecisionSelectableTitleIds = useMemo(() => {
const explicit = normalizeTitleIdList(pipeline?.context?.handBrakeDecisionSelectableTitleIds);
if (explicit.length > 0) {
return explicit;
}
if (isSeriesPlayAllDecisionMode && ambiguousLongestTitleId) {
return [ambiguousLongestTitleId];
}
return handBrakeDecisionCandidateTitleIds;
}, [
isSeriesPlayAllDecisionMode,
ambiguousLongestTitleId,
handBrakeDecisionCandidateTitleIds,
pipeline?.context?.handBrakeDecisionSelectableTitleIds
]);
const handBrakeDecisionSelectableTitleIdSet = useMemo(
() => new Set(handBrakeDecisionSelectableTitleIds.map((id) => Number(id))),
[handBrakeDecisionSelectableTitleIds]
);
const waitingHandBrakeTitleRows = useMemo(() => {
const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
const rawCandidates = Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
? pipeline.context.handBrakeTitleCandidates
: [];
return raw
const rawAllTitles = isSeriesPlayAllDecisionMode && Array.isArray(pipeline?.context?.handBrakeAllTitles)
? pipeline.context.handBrakeAllTitles
: [];
const sourceRows = rawAllTitles.length > 0 ? rawAllTitles : rawCandidates;
const candidateIdSet = new Set(
normalizeTitleIdList(rawCandidates.map((item) => item?.handBrakeTitleId)).map((id) => Number(id))
);
return sourceRows
.map((item) => {
const titleId = Number(item?.handBrakeTitleId);
if (!Number.isFinite(titleId) || titleId <= 0) return null;
const titleId = normalizeTitleId(item?.handBrakeTitleId);
if (!titleId) {
return null;
}
const durationSeconds = Number(item?.durationSeconds || 0);
const minutes = Math.floor(durationSeconds / 60);
const seconds = durationSeconds % 60;
@@ -1956,12 +2000,23 @@ export default function PipelineStatusCard({
durationMinutes: Number(item?.durationMinutes || 0),
audioTrackCount: Number(item?.audioTrackCount || 0),
subtitleTrackCount: Number(item?.subtitleTrackCount || 0),
sizeBytes: Number(item?.sizeBytes || 0)
sizeBytes: Number(item?.sizeBytes || 0),
decisionCandidate: candidateIdSet.has(Number(titleId))
};
})
.filter(Boolean)
.sort((a, b) => a.handBrakeTitleId - b.handBrakeTitleId);
}, [pipeline?.context?.handBrakeTitleCandidates]);
}, [
isSeriesPlayAllDecisionMode,
pipeline?.context?.handBrakeAllTitles,
pipeline?.context?.handBrakeTitleCandidates
]);
const visibleWaitingHandBrakeTitleRows = useMemo(() => {
if (!isSeriesPlayAllDecisionMode) {
return waitingHandBrakeTitleRows;
}
return waitingHandBrakeTitleRows.filter((row) => row.decisionCandidate);
}, [isSeriesPlayAllDecisionMode, waitingHandBrakeTitleRows]);
useEffect(() => {
if (!handBrakeTitleDecisionRequired) {
@@ -1984,11 +2039,24 @@ export default function PipelineStatusCard({
setSelectedHandBrakeTitleIdsState([Math.trunc(singleContextId)]);
return;
}
const best = waitingHandBrakeTitleRows[0]?.handBrakeTitleId || null;
setSelectedHandBrakeTitleIdsState(best ? [best] : []);
if (isSeriesPlayAllDecisionMode) {
const lockedDefaultIds = waitingHandBrakeTitleRows
.filter((row) => row.decisionCandidate && !handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId)))
.map((row) => row.handBrakeTitleId);
if (lockedDefaultIds.length > 0) {
setSelectedHandBrakeTitleIdsState(normalizeTitleIdList(lockedDefaultIds));
return;
}
}
const firstSelectable = waitingHandBrakeTitleRows.find((row) => (
!isSeriesPlayAllDecisionMode || handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId))
))?.handBrakeTitleId || null;
setSelectedHandBrakeTitleIdsState(firstSelectable ? [firstSelectable] : []);
}, [
handBrakeTitleDecisionRequired,
isSeriesPlayAllDecisionMode,
retryJobId,
handBrakeDecisionSelectableTitleIdSet,
waitingHandBrakeTitleRows,
pipeline?.context?.selectedHandBrakeTitleId,
pipeline?.context?.selectedHandBrakeTitleIds
@@ -2849,7 +2917,7 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && retryJobId && (
<Button
label="DVD Titel bestätigen"
label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : 'DVD Titel bestätigen'}
icon="pi pi-check"
severity="warning"
outlined
@@ -3077,38 +3145,61 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && !queueLocked ? (
<div className="playlist-decision-block">
<h3>DVD Titel-Auswahl erforderlich</h3>
<h3>{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : 'DVD Titel-Auswahl erforderlich'}</h3>
<small>
Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen.
{isSeriesPlayAllDecisionMode
? 'Min. ein Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte durch Auswahl entscheiden, ob PlayAll Multi-Episode (nicht anhaken) oder Doppelfolge (anhaken).'
: 'Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen.'}
</small>
{waitingHandBrakeTitleRows.length > 0 ? (
{visibleWaitingHandBrakeTitleRows.length > 0 ? (
<div className="playlist-decision-list">
{waitingHandBrakeTitleRows.map((row) => (
<div key={row.handBrakeTitleId} className="playlist-decision-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={selectedHandBrakeTitleIdsState.includes(row.handBrakeTitleId)}
disabled={queueLocked}
onChange={() => {
setSelectedHandBrakeTitleIdsState((prev) => {
const list = Array.isArray(prev) ? prev : [];
if (list.includes(row.handBrakeTitleId)) {
return list.filter((value) => value !== row.handBrakeTitleId);
{visibleWaitingHandBrakeTitleRows.map((row) => {
const isDecisionSelectable = !isSeriesPlayAllDecisionMode
|| handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId));
const isAmbiguousDecisionTitle = isSeriesPlayAllDecisionMode
&& isDecisionSelectable
&& (
Number(row.handBrakeTitleId) === Number(ambiguousLongestTitleId)
|| handBrakeDecisionSelectableTitleIdSet.has(Number(row.handBrakeTitleId))
);
const isRecognizedLocked = isSeriesPlayAllDecisionMode
&& row.decisionCandidate
&& !isDecisionSelectable;
const isOutOfDecisionScope = isSeriesPlayAllDecisionMode
&& !row.decisionCandidate;
return (
<div key={row.handBrakeTitleId} className="playlist-decision-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={selectedHandBrakeTitleIdsState.includes(row.handBrakeTitleId)}
disabled={queueLocked || !isDecisionSelectable}
onChange={() => {
if (!isDecisionSelectable) {
return;
}
return [...list, row.handBrakeTitleId];
});
}}
/>
<span>
{`Titel -t ${row.handBrakeTitleId}`}
{row.durationLabel ? ` | ${row.durationLabel}` : ''}
{row.audioTrackCount > 0 ? ` | Audio: ${row.audioTrackCount}` : ''}
{row.subtitleTrackCount > 0 ? ` | Untertitel: ${row.subtitleTrackCount}` : ''}
</span>
</label>
</div>
))}
setSelectedHandBrakeTitleIdsState((prev) => {
const list = Array.isArray(prev) ? prev : [];
if (list.includes(row.handBrakeTitleId)) {
return list.filter((value) => value !== row.handBrakeTitleId);
}
return [...list, row.handBrakeTitleId];
});
}}
/>
<span>
{isAmbiguousDecisionTitle ? <strong>{`Titel -t ${row.handBrakeTitleId}`}</strong> : `Titel -t ${row.handBrakeTitleId}`}
{isAmbiguousDecisionTitle ? ' | Grenzfalltitel (PlayAll/Doppelfolge)' : ''}
{isRecognizedLocked ? ' | automatisch erkannt (fix)' : ''}
{isOutOfDecisionScope ? ' | nicht entscheidungsrelevant' : ''}
{row.durationLabel ? ` | ${row.durationLabel}` : ''}
{row.audioTrackCount > 0 ? ` | Audio: ${row.audioTrackCount}` : ''}
{row.subtitleTrackCount > 0 ? ` | Untertitel: ${row.subtitleTrackCount}` : ''}
</span>
</label>
</div>
);
})}
</div>
) : (
<small>Keine Kandidaten verfügbar. Bitte Analyse erneut ausführen.</small>
@@ -3144,7 +3235,7 @@ export default function PipelineStatusCard({
</div>
) : null}
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
<div className="mediainfo-review-block">
{!isDvdTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
{state === 'READY_TO_ENCODE' && !queueLocked && !isDvdTitleSelectionRequired ? (
+12 -2
View File
@@ -5,6 +5,7 @@ import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal';
@@ -26,6 +27,7 @@ function formatDetectedMediaType(value) {
}
export default function DatabasePage() {
const navigate = useNavigate();
const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
@@ -63,13 +65,21 @@ export default function DatabasePage() {
try {
const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id;
const activationStarted = Boolean(response?.activation?.started);
toastRef.current?.show({
severity: 'success',
summary: 'Job angelegt',
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.',
summary: activationStarted ? 'Analyse gestartet' : 'Job angelegt',
detail: activationStarted
? (newJobId
? `Job #${newJobId} läuft jetzt im Ripper-View.`
: 'Job läuft jetzt im Ripper-View.')
: (newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.'),
life: 3500
});
await loadOrphans();
if (activationStarted) {
navigate('/ripper');
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
+13 -1
View File
@@ -1112,8 +1112,20 @@ export default function HistoryPage({ refreshToken = 0 }) {
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const workflowKindRaw = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
const workflowKind = ['film', 'movie', 'feature'].includes(workflowKindRaw)
? 'film'
: (['series', 'tv', 'season', 'episode'].includes(workflowKindRaw) ? 'series' : null);
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
const metadataProvider = isSeriesDvd ? 'tmdb' : 'omdb';
const metadataProvider = workflowKind === 'series'
? 'tmdb'
: (workflowKind === 'film'
? 'omdb'
: (String(selectedMetadata?.metadataProvider || analyzeContext?.metadataProvider || (isSeriesDvd ? 'tmdb' : 'omdb')).trim().toLowerCase() || 'omdb'));
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = {
+2 -2
View File
@@ -1352,7 +1352,7 @@ export default function RipperPage({
jobId: normalizedJobId,
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
selectedMetadata,
metadataProvider: context?.metadataProvider || selectedMetadata?.metadataProvider || 'omdb',
metadataProvider: selectedMetadata?.metadataProvider || context?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [],
seriesAnalysis: context?.seriesAnalysis || null,
seriesLookupHint: context?.seriesLookupHint || null,
@@ -1382,7 +1382,7 @@ export default function RipperPage({
seasonNumber: null,
discNumber: null
},
metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb',
metadataProvider: currentContext?.selectedMetadata?.metadataProvider || currentContext?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [],
seriesAnalysis: currentContext?.seriesAnalysis || null,
seriesLookupHint: currentContext?.seriesLookupHint || null,
+11
View File
@@ -217,6 +217,17 @@ export function isSeriesDvdJob(job) {
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {});
const workflowKind = String(
selectedMetadata?.workflowKind
|| analyzeContext?.workflowKind
|| ''
).trim().toLowerCase();
if (['film', 'movie', 'feature'].includes(workflowKind)) {
return false;
}
if (['series', 'tv', 'season', 'episode'].includes(workflowKind)) {
return true;
}
const metadataProvider = String(
analyzeContext?.metadataProvider
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.13.1-4",
"version": "0.13.1-6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.13.1-4",
"version": "0.13.1-6",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.13.1-4",
"version": "0.13.1-6",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",