harden: defensive parsing for config + queue, normalize stale downloading

- loadConfig now checks isPlainObject(parsed) before spreading over
  defaults. Non-object JSON (array, primitive, null) is logged and the
  app falls back to defaults instead of silently polluting the config
  with array indices or dropping values.

- loadQueue runs every entry through sanitizeQueueItem which validates
  the status enum, clamps progress to [0, 100], validates customClip
  and mergeGroup shapes (with sanitizeCustomClip / sanitizeMergeGroup
  helpers), and demotes stale status="downloading" entries to "pending"
  with progress=0 on cold start. The previous filter only checked
  typeof id/url/status === "string" and let through whatever shape
  customClip / mergeGroup happened to have.

- The stale-downloading normalisation fixes a real user trap: after a
  hard kill mid-download, the queue persisted status="downloading", but
  no download was running on next launch and start-download only resumed
  paused items, leaving "downloading" entries stuck.

- Bonus: CustomClip and MergeGroupItem imports now have call sites
  (previously unused-import warnings).

docs/IMPROVEMENT_LOG.md gains a Cycle 2 dated section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE
2026-05-03 15:29:28 +02:00
co-authored by Claude Opus 4.7
parent b4faf67db7
commit 379048f191
2 changed files with 162 additions and 8 deletions
+131 -8
View File
@@ -256,11 +256,20 @@ function normalizeConfigTemplates(input: Config): Config {
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function loadConfig(): Config {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
return normalizeConfigTemplates({ ...defaultConfig, ...JSON.parse(data) });
const parsed = JSON.parse(data);
if (!isPlainObject(parsed)) {
console.error('Config file is not a JSON object — using defaults');
return normalizeConfigTemplates(defaultConfig);
}
return normalizeConfigTemplates({ ...defaultConfig, ...parsed });
}
} catch (e) {
console.error('Error loading config:', e);
@@ -303,6 +312,111 @@ function saveConfig(config: Config): void {
// ==========================================
// QUEUE MANAGEMENT
// ==========================================
const VALID_QUEUE_STATUSES: ReadonlyArray<QueueItem['status']> = ['pending', 'downloading', 'paused', 'completed', 'error'];
const VALID_MERGE_PHASES: ReadonlyArray<MergeGroup['mergePhase']> = ['downloading', 'merging', 'splitting', 'cleanup', 'done'];
function isValidQueueStatus(status: unknown): status is QueueItem['status'] {
return typeof status === 'string' && (VALID_QUEUE_STATUSES as readonly string[]).includes(status);
}
function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined {
if (!isPlainObject(raw)) return undefined;
if (!Array.isArray(raw.items) || raw.items.length < 2) return undefined;
const items: MergeGroupItem[] = [];
for (const mi of raw.items) {
if (!isPlainObject(mi)) continue;
if (typeof mi.url !== 'string' || typeof mi.title !== 'string'
|| typeof mi.date !== 'string' || typeof mi.streamer !== 'string'
|| typeof mi.duration_str !== 'string') continue;
items.push({ url: mi.url, title: mi.title, date: mi.date, streamer: mi.streamer, duration_str: mi.duration_str });
}
if (items.length < 2) return undefined;
const phase: MergeGroup['mergePhase'] = (VALID_MERGE_PHASES as readonly string[]).includes(String(raw.mergePhase))
? raw.mergePhase as MergeGroup['mergePhase']
: 'downloading';
const downloadedFiles: Record<number, string> = {};
if (isPlainObject(raw.downloadedFiles)) {
for (const [k, v] of Object.entries(raw.downloadedFiles)) {
const idx = Number(k);
if (Number.isFinite(idx) && typeof v === 'string') downloadedFiles[idx] = v;
}
}
return {
items,
mergePhase: phase,
currentItemIndex: typeof raw.currentItemIndex === 'number' && Number.isFinite(raw.currentItemIndex) ? raw.currentItemIndex : 0,
downloadedFiles,
mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined,
splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined,
totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined
};
}
function sanitizeCustomClip(raw: unknown): CustomClip | undefined {
if (!isPlainObject(raw)) return undefined;
const startSec = Number(raw.startSec);
const durationSec = Number(raw.durationSec);
const startPart = Number(raw.startPart);
if (!Number.isFinite(startSec) || !Number.isFinite(durationSec) || durationSec <= 0 || !Number.isFinite(startPart)) return undefined;
const filenameFormat = raw.filenameFormat;
if (filenameFormat !== 'simple' && filenameFormat !== 'timestamp' && filenameFormat !== 'template') return undefined;
return {
startSec: Math.max(0, startSec),
durationSec: Math.max(1, durationSec),
startPart: Math.max(1, Math.floor(startPart)),
filenameFormat,
filenameTemplate: typeof raw.filenameTemplate === 'string' ? raw.filenameTemplate : undefined
};
}
function sanitizeQueueItem(raw: unknown): QueueItem | null {
if (!isPlainObject(raw)) return null;
if (typeof raw.id !== 'string' || !raw.id) return null;
if (typeof raw.url !== 'string' || !raw.url) return null;
if (!isValidQueueStatus(raw.status)) return null;
// 'downloading' on cold start is stale — no download is actually running
// and the user expects to resume from start, so map it back to 'pending'
const isStaleDownloading = raw.status === 'downloading';
const finalStatus: QueueItem['status'] = isStaleDownloading ? 'pending' : raw.status;
const progressNum = Number(raw.progress);
const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0;
const item: QueueItem = {
id: raw.id,
url: raw.url,
title: typeof raw.title === 'string' ? raw.title : '',
date: typeof raw.date === 'string' ? raw.date : '',
streamer: typeof raw.streamer === 'string' ? raw.streamer : '',
duration_str: typeof raw.duration_str === 'string' ? raw.duration_str : '0s',
status: finalStatus,
progress: isStaleDownloading ? 0 : safeProgress
};
if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart;
if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts;
if (typeof raw.speed === 'string') item.speed = raw.speed;
if (typeof raw.eta === 'string') item.eta = raw.eta;
if (typeof raw.last_error === 'string') item.last_error = raw.last_error;
if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes;
if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes;
const customClip = sanitizeCustomClip(raw.customClip);
if (customClip) item.customClip = customClip;
const mergeGroup = sanitizeMergeGroup(raw.mergeGroup);
if (mergeGroup) item.mergeGroup = mergeGroup;
return item;
}
function loadQueue(): QueueItem[] {
if (config.persist_queue_on_restart === false) {
return [];
@@ -312,16 +426,25 @@ function loadQueue(): QueueItem[] {
if (fs.existsSync(QUEUE_FILE)) {
const data = fs.readFileSync(QUEUE_FILE, 'utf-8');
const parsed = JSON.parse(data);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item: any) =>
item && typeof item.id === 'string' &&
typeof item.url === 'string' &&
typeof item.status === 'string'
);
if (!Array.isArray(parsed)) {
console.error('Queue file is not a JSON array — ignoring');
return [];
}
const items: QueueItem[] = [];
let droppedCount = 0;
for (const raw of parsed) {
const sanitized = sanitizeQueueItem(raw);
if (sanitized) items.push(sanitized);
else droppedCount++;
}
if (droppedCount > 0) {
console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`);
}
return items;
}
} catch (e) {
console.error('Error loading queue:', e);
console.error('queue-load-error', { error: String(e) });
}
return [];
}