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", "name": "ripster-backend",
"version": "0.13.0", "version": "0.13.0-1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.0", "version": "0.13.0-1",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.0", "version": "0.13.0-1",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+137 -19
View File
@@ -905,11 +905,27 @@ function enrichJobRow(job, settings = null, options = {}) {
const backupSuccess = ripSuccessful; const backupSuccess = ripSuccessful;
const finishedWithOutput = normalizedJobStatus === 'FINISHED' const finishedWithOutput = normalizedJobStatus === 'FINISHED'
&& (includeFsChecks ? Boolean(outputStatus?.exists) : true); && (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 const encodeSuccess = directoryLikeOutput
? finishedWithOutput ? finishedWithOutput
: (mediaType === 'converter' : (
? finishedWithOutput seriesBatchEncodeSuccess
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'); || (mediaType === 'converter'
? finishedWithOutput
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS')
);
return { return {
...job, ...job,
@@ -3629,6 +3645,7 @@ class HistoryService {
}); });
const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean);
let childRows = []; let childRows = [];
let nestedChildRows = [];
let outputFolders = []; let outputFolders = [];
if (containerIds.length > 0) { if (containerIds.length > 0) {
const placeholders = containerIds.map(() => '?').join(', '); const placeholders = containerIds.map(() => '?').join(', ');
@@ -3639,15 +3656,46 @@ class HistoryService {
const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean); const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean);
if (childIds.length > 0) { if (childIds.length > 0) {
const childPlaceholders = childIds.map(() => '?').join(', '); 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( outputFolders = await db.all(
`SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${childPlaceholders})`, `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(); const childOutputMap = new Map();
for (const child of childRows) { for (const child of allContainerChildRows) {
const childId = normalizeJobIdValue(child?.id); const childId = normalizeJobIdValue(child?.id);
if (!childId) { if (!childId) {
continue; continue;
@@ -3701,9 +3749,12 @@ class HistoryService {
let expectedFromPlans = 0; let expectedFromPlans = 0;
const seenOutputs = new Set(); const seenOutputs = new Set();
let existingCount = 0; let existingCount = 0;
let existingCountFromSeriesBatch = 0;
const totalChildren = childIds.length; const totalChildren = childIds.length;
for (const childId of childIds) { 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) { if (childRow) {
const rawPath = String(childRow?.raw_path || '').trim(); const rawPath = String(childRow?.raw_path || '').trim();
if (rawPath) { if (rawPath) {
@@ -3722,32 +3773,87 @@ class HistoryService {
if (ripSuccessful) { if (ripSuccessful) {
backupSuccessCount += 1; backupSuccessCount += 1;
} }
let selectedCount = 0;
const childPlan = parseJsonSafe(childRow?.encode_plan_json, null); const childPlan = parseJsonSafe(childRow?.encode_plan_json, null);
if (childPlan && typeof childPlan === 'object') { if (childPlan && typeof childPlan === 'object') {
const titles = Array.isArray(childPlan?.titles) ? childPlan.titles : []; const titles = Array.isArray(childPlan?.titles) ? childPlan.titles : [];
const selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length;
if (selectedCount > 0) {
expectedFromPlans += selectedCount;
}
} }
const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null); const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null);
const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase(); const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase();
if (hbStatus === 'SUCCESS') { const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object'
encodeSuccessAny = true; ? handbrakeInfo.seriesBatch
encodeSuccessCount += 1; : 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) {
for (const outputPath of outputs) { const relatedRowId = normalizeJobIdValue(relatedRow?.id);
if (!outputPath || seenOutputs.has(outputPath)) { if (!relatedRowId) {
continue; continue;
} }
seenOutputs.add(outputPath); const outputs = Array.from(childOutputMap.get(relatedRowId) || []);
if (!includeFsChecks || fs.existsSync(outputPath)) { for (const outputPath of outputs) {
existingCount += 1; if (!outputPath || seenOutputs.has(outputPath)) {
continue;
}
seenOutputs.add(outputPath);
if (!includeFsChecks || fs.existsSync(outputPath)) {
existingCount += 1;
}
} }
} }
} }
if (existingCountFromSeriesBatch > existingCount) {
existingCount = existingCountFromSeriesBatch;
}
if (!encodeSuccessAny && existingCount > 0) { if (!encodeSuccessAny && existingCount > 0) {
encodeSuccessAny = true; encodeSuccessAny = true;
if (encodeSuccessCount === 0 && totalChildren > 0) { if (encodeSuccessCount === 0 && totalChildren > 0) {
@@ -5102,6 +5208,18 @@ class HistoryService {
?? analyzeContext?.seriesLookupHint?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber
?? null ?? 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 let metadataKind = manualMetadataKind
|| String( || String(
existingSelectedMetadata?.metadataKind existingSelectedMetadata?.metadataKind
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.0", "version": "0.13.0-1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.0", "version": "0.13.0-1",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.0", "version": "0.13.0-1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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 }) { function BoolState({ value }) {
const isTrue = Boolean(value); const isTrue = Boolean(value);
return isTrue ? ( return isTrue ? (
@@ -1113,7 +1128,9 @@ export default function JobDetailDialog({
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; 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 isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const seriesChildSummary = job?.seriesChildSummary || null; const seriesChildSummary = job?.seriesChildSummary || null;
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null; const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
@@ -13,6 +13,17 @@ export default function MetadataSelectionDialog({
onSearch, onSearch,
busy 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 [selected, setSelected] = useState(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [manualTitle, setManualTitle] = useState(''); const [manualTitle, setManualTitle] = useState('');
@@ -23,9 +34,18 @@ export default function MetadataSelectionDialog({
const [searchBusy, setSearchBusy] = useState(false); const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState(''); const [searchError, setSearchError] = useState('');
const [seasonFilter, setSeasonFilter] = useState(''); const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; 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 providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb'; const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
const effectiveDiscNumber = parseDiscNumber(manualDiscNumber);
const hasValidDiscNumber = effectiveDiscNumber !== null;
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
@@ -51,8 +71,18 @@ export default function MetadataSelectionDialog({
setSearchBusy(false); setSearchBusy(false);
setSearchError(''); setSearchError('');
setSeasonFilter(''); setSeasonFilter('');
setDiscValidationError('');
}, [visible, context]); }, [visible, context]);
useEffect(() => {
if (!discValidationError) {
return;
}
if (!requiresDiscNumber || hasValidDiscNumber) {
setDiscValidationError('');
}
}, [discValidationError, requiresDiscNumber, hasValidDiscNumber]);
const rows = useMemo(() => { const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates) const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates ? context.metadataCandidates
@@ -136,10 +166,10 @@ export default function MetadataSelectionDialog({
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
const parsedDiscNumber = Number(manualDiscNumber); if (requiresDiscNumber && !hasValidDiscNumber) {
const effectiveDiscNumber = Number.isFinite(parsedDiscNumber) && parsedDiscNumber > 0 setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
? Math.trunc(parsedDiscNumber) return;
: null; }
const payload = selected const payload = selected
? { ? {
@@ -174,11 +204,19 @@ export default function MetadataSelectionDialog({
await onSubmit(payload); await onSubmit(payload);
}; };
const handleHideRequest = () => {
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
onHide?.();
};
return ( return (
<Dialog <Dialog
header="Metadaten auswählen" header="Metadaten auswählen"
visible={visible} visible={visible}
onHide={onHide} onHide={handleHideRequest}
style={{ width: '52rem', maxWidth: '95vw' }} style={{ width: '52rem', maxWidth: '95vw' }}
className="metadata-selection-dialog" className="metadata-selection-dialog"
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }} breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
@@ -211,16 +249,19 @@ export default function MetadataSelectionDialog({
<InputText <InputText
value={manualDiscNumber} value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)} onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder="Disc-Nr (optional)" placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'}
inputMode="numeric" inputMode="numeric"
disabled={searchBusy || busy} disabled={searchBusy || busy}
/> />
<small className="metadata-filter-subtitle"> <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> </small>
</div> </div>
</div> </div>
{searchError ? <small className="error-text">{searchError}</small> : null} {searchError ? <small className="error-text">{searchError}</small> : null}
{discValidationError ? <small className="error-text">{discValidationError}</small> : null}
<div className="table-scroll-wrap table-scroll-medium"> <div className="table-scroll-wrap table-scroll-medium">
<DataTable <DataTable
@@ -271,13 +312,22 @@ export default function MetadataSelectionDialog({
</div> </div>
<div className="dialog-actions"> <div className="dialog-actions">
<Button label="Abbrechen" severity="secondary" text onClick={onHide} /> <Button
label="Abbrechen"
severity="secondary"
text
onClick={handleHideRequest}
disabled={requiresDiscNumber && !hasValidDiscNumber}
/>
<Button <Button
label="Auswahl übernehmen" label="Auswahl übernehmen"
icon="pi pi-play" icon="pi pi-play"
onClick={handleSubmit} onClick={handleSubmit}
loading={busy} loading={busy}
disabled={!selected && !manualTitle.trim() && !manualImdb.trim()} disabled={
(!selected && !manualTitle.trim() && !manualImdb.trim())
|| (requiresDiscNumber && !hasValidDiscNumber)
}
/> />
</div> </div>
</Dialog> </Dialog>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.13.0", "version": "0.13.0-1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.13.0", "version": "0.13.0-1",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.13.0", "version": "0.13.0-1",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",