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:
co-authored by
Claude Opus 4.7
parent
b4faf67db7
commit
379048f191
+131
-8
@@ -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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user