0.13.0-1 Series Flow

This commit is contained in:
2026-04-11 19:11:31 +00:00
parent 974b7bfad3
commit 1f8e6d3ce8
10 changed files with 1138 additions and 163 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.13.0",
"version": "0.13.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.13.0",
"version": "0.13.0-1",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.13.0",
"version": "0.13.0-1",
"private": true,
"type": "commonjs",
"scripts": {
+129 -11
View File
@@ -905,11 +905,27 @@ function enrichJobRow(job, settings = null, options = {}) {
const backupSuccess = ripSuccessful;
const finishedWithOutput = normalizedJobStatus === 'FINISHED'
&& (includeFsChecks ? Boolean(outputStatus?.exists) : true);
const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object'
? handbrakeInfo.seriesBatch
: null;
const seriesBatchMode = String(handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch';
const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0);
const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0);
const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0);
const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0);
const seriesBatchEncodeSuccess = seriesBatchMode
&& seriesBatchTotal > 0
&& seriesBatchFinished >= seriesBatchTotal
&& seriesBatchErrors <= 0
&& seriesBatchCancelled <= 0;
const encodeSuccess = directoryLikeOutput
? finishedWithOutput
: (mediaType === 'converter'
: (
seriesBatchEncodeSuccess
|| (mediaType === 'converter'
? finishedWithOutput
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS');
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
);
return {
...job,
@@ -3629,6 +3645,7 @@ class HistoryService {
});
const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
let childRows = [];
let nestedChildRows = [];
let outputFolders = [];
if (containerIds.length > 0) {
const placeholders = containerIds.map(() => '?').join(', ');
@@ -3639,15 +3656,46 @@ class HistoryService {
const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean);
if (childIds.length > 0) {
const childPlaceholders = childIds.map(() => '?').join(', ');
nestedChildRows = await db.all(
`SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${childPlaceholders})`,
childIds
);
}
const summaryJobIds = Array.from(new Set([
...childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean),
...nestedChildRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean)
]));
if (summaryJobIds.length > 0) {
const childPlaceholders = summaryJobIds.map(() => '?').join(', ');
outputFolders = await db.all(
`SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${childPlaceholders})`,
childIds
summaryJobIds
);
}
}
const allContainerChildRows = [...childRows, ...nestedChildRows];
const directChildRowById = new Map();
for (const row of childRows) {
const childId = normalizeJobIdValue(row?.id);
if (childId) {
directChildRowById.set(childId, row);
}
}
const nestedRowsByParentId = new Map();
for (const row of nestedChildRows) {
const parentId = normalizeJobIdValue(row?.parent_job_id);
if (!parentId) {
continue;
}
if (!nestedRowsByParentId.has(parentId)) {
nestedRowsByParentId.set(parentId, []);
}
nestedRowsByParentId.get(parentId).push(row);
}
const childOutputMap = new Map();
for (const child of childRows) {
for (const child of allContainerChildRows) {
const childId = normalizeJobIdValue(child?.id);
if (!childId) {
continue;
@@ -3701,9 +3749,12 @@ class HistoryService {
let expectedFromPlans = 0;
const seenOutputs = new Set();
let existingCount = 0;
let existingCountFromSeriesBatch = 0;
const totalChildren = childIds.length;
for (const childId of childIds) {
const childRow = childRows.find((row) => normalizeJobIdValue(row?.id) === childId) || null;
const childRow = directChildRowById.get(childId) || null;
const descendantRows = nestedRowsByParentId.get(childId) || [];
const relatedRows = [childRow, ...descendantRows].filter(Boolean);
if (childRow) {
const rawPath = String(childRow?.raw_path || '').trim();
if (rawPath) {
@@ -3722,22 +3773,73 @@ class HistoryService {
if (ripSuccessful) {
backupSuccessCount += 1;
}
let selectedCount = 0;
const childPlan = parseJsonSafe(childRow?.encode_plan_json, null);
if (childPlan && typeof childPlan === 'object') {
const titles = Array.isArray(childPlan?.titles) ? childPlan.titles : [];
const selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length;
if (selectedCount > 0) {
expectedFromPlans += selectedCount;
}
selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length;
}
const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null);
const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase();
if (hbStatus === 'SUCCESS') {
const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object'
? handbrakeInfo.seriesBatch
: null;
const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0);
const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0);
const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0);
const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0);
const expectedFromNested = descendantRows.length > 0 ? descendantRows.length : 0;
const expectedFromChild = seriesBatchTotal > 0
? seriesBatchTotal
: (selectedCount > 0 ? selectedCount : expectedFromNested);
if (expectedFromChild > 0) {
expectedFromPlans += expectedFromChild;
}
if (seriesBatchFinished > 0) {
existingCountFromSeriesBatch += seriesBatchFinished;
}
let childEncodeSuccess = hbStatus === 'SUCCESS';
if (
!childEncodeSuccess
&& seriesBatchTotal > 0
&& seriesBatchFinished >= seriesBatchTotal
&& seriesBatchErrors <= 0
&& seriesBatchCancelled <= 0
) {
childEncodeSuccess = true;
}
if (!childEncodeSuccess && descendantRows.length > 0) {
let finishedDescendants = 0;
let errorDescendants = 0;
let cancelledDescendants = 0;
for (const row of descendantRows) {
const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase();
if (rowState === 'FINISHED') {
finishedDescendants += 1;
} else if (rowState === 'ERROR') {
errorDescendants += 1;
} else if (rowState === 'CANCELLED') {
cancelledDescendants += 1;
}
}
childEncodeSuccess = finishedDescendants >= descendantRows.length
&& errorDescendants === 0
&& cancelledDescendants === 0;
}
if (childEncodeSuccess) {
encodeSuccessAny = true;
encodeSuccessCount += 1;
}
} else if (descendantRows.length > 0) {
expectedFromPlans += descendantRows.length;
}
const outputs = Array.from(childOutputMap.get(childId) || []);
for (const relatedRow of relatedRows) {
const relatedRowId = normalizeJobIdValue(relatedRow?.id);
if (!relatedRowId) {
continue;
}
const outputs = Array.from(childOutputMap.get(relatedRowId) || []);
for (const outputPath of outputs) {
if (!outputPath || seenOutputs.has(outputPath)) {
continue;
@@ -3748,6 +3850,10 @@ class HistoryService {
}
}
}
}
if (existingCountFromSeriesBatch > existingCount) {
existingCount = existingCountFromSeriesBatch;
}
if (!encodeSuccessAny && existingCount > 0) {
encodeSuccessAny = true;
if (encodeSuccessCount === 0 && totalChildren > 0) {
@@ -5102,6 +5208,18 @@ class HistoryService {
?? analyzeContext?.seriesLookupHint?.discNumber
?? null
);
const resolvedJobMediaType = inferMediaType(
job,
makemkvInfo,
job?.mediainfo_info_json,
job?.encode_plan_json,
job?.handbrake_info_json
);
if (resolvedJobMediaType === 'dvd' && discNumber === null) {
const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).');
error.statusCode = 400;
throw error;
}
let metadataKind = manualMetadataKind
|| String(
existingSelectedMetadata?.metadataKind
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.13.0",
"version": "0.13.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.13.0",
"version": "0.13.0-1",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.13.0",
"version": "0.13.0-1",
"private": true,
"type": "module",
"scripts": {
+18 -1
View File
@@ -739,6 +739,21 @@ function resolveSeriesDiscNumber(job) {
);
}
function compareSeriesChildJobsByDisc(leftJob, rightJob) {
const leftDisc = resolveSeriesDiscNumber(leftJob);
const rightDisc = resolveSeriesDiscNumber(rightJob);
if (leftDisc !== null && rightDisc !== null && leftDisc !== rightDisc) {
return leftDisc - rightDisc;
}
if (leftDisc !== null && rightDisc === null) {
return -1;
}
if (leftDisc === null && rightDisc !== null) {
return 1;
}
return Number(leftJob?.id || 0) - Number(rightJob?.id || 0);
}
function BoolState({ value }) {
const isTrue = Boolean(value);
return isTrue ? (
@@ -1113,7 +1128,9 @@ export default function JobDetailDialog({
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const childJobs = Array.isArray(job?.children) ? job.children : [];
const childJobs = Array.isArray(job?.children)
? [...job.children].sort(compareSeriesChildJobsByDisc)
: [];
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const seriesChildSummary = job?.seriesChildSummary || null;
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
@@ -13,6 +13,17 @@ export default function MetadataSelectionDialog({
onSearch,
busy
}) {
const parseDiscNumber = (rawValue) => {
const text = String(rawValue ?? '').trim();
if (!/^\d+$/.test(text)) {
return null;
}
const parsed = Number(text);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
};
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const [manualTitle, setManualTitle] = useState('');
@@ -23,9 +34,18 @@ export default function MetadataSelectionDialog({
const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const mediaProfile = String(
context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile
|| ''
).trim().toLowerCase();
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
useEffect(() => {
if (!visible) {
@@ -51,8 +71,18 @@ export default function MetadataSelectionDialog({
setSearchBusy(false);
setSearchError('');
setSeasonFilter('');
setDiscValidationError('');
}, [visible, context]);
useEffect(() => {
if (!discValidationError) {
return;
}
if (!requiresDiscNumber || hasValidDiscNumber) {
setDiscValidationError('');
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
@@ -136,10 +166,10 @@ export default function MetadataSelectionDialog({
};
const handleSubmit = async () => {
const parsedDiscNumber = Number(manualDiscNumber);
const effectiveDiscNumber = Number.isFinite(parsedDiscNumber) && parsedDiscNumber > 0
? Math.trunc(parsedDiscNumber)
: null;
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
const payload = selected
? {
@@ -174,11 +204,19 @@ export default function MetadataSelectionDialog({
await onSubmit(payload);
};
const handleHideRequest = () => {
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
onHide?.();
};
return (
<Dialog
header="Metadaten auswählen"
visible={visible}
onHide={onHide}
onHide={handleHideRequest}
style={{ width: '52rem', maxWidth: '95vw' }}
className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
@@ -211,16 +249,19 @@ export default function MetadataSelectionDialog({
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder="Disc-Nr (optional)"
placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric"
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.
{requiresDiscNumber
? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht verlassen werden.'
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
</small>
</div>
</div>
{searchError ? <small className="error-text">{searchError}</small> : null}
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
@@ -271,13 +312,22 @@ export default function MetadataSelectionDialog({
</div>
<div className="dialog-actions">
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
<Button
label="Abbrechen"
severity="secondary"
text
onClick={handleHideRequest}
disabled={requiresDiscNumber && !hasValidDiscNumber}
/>
<Button
label="Auswahl übernehmen"
icon="pi pi-play"
onClick={handleSubmit}
loading={busy}
disabled={!selected && !manualTitle.trim() && !manualImdb.trim()}
disabled={
(!selected && !manualTitle.trim() && !manualImdb.trim())
|| (requiresDiscNumber && !hasValidDiscNumber)
}
/>
</div>
</Dialog>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.13.0",
"version": "0.13.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.13.0",
"version": "0.13.0-1",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.13.0",
"version": "0.13.0-1",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",