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:
parent
b4faf67db7
commit
379048f191
@ -2,6 +2,37 @@
|
||||
|
||||
Dated entries from improvement cycles. Newest at top.
|
||||
|
||||
## 2026-05-03 — Cycle 2: release pipeline + defensive parsing
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. `scripts/release_gitea.mjs` skips rebuild when artifacts exist (release pipeline)
|
||||
|
||||
- **File**: `scripts/release_gitea.mjs`.
|
||||
- **Problem**: The script unconditionally ran `npm run dist:win` (full test suite + electron-builder) even when the version's artifacts were already on disk under `release/`. When `npm run test:e2e` was broken (cycle 1 follow-up), the release path was unusable — the previous cycle had to bypass the script with direct API uploads via PowerShell. Every future agent would hit the same wall.
|
||||
- **Fix**: New `--skip-build` flag. The script now also auto-detects whether all 3 required artifacts (`Setup-<v>.exe`, `Setup-<v>.exe.blockmap`, `latest.yml`) exist for the requested version and skips `dist:win` accordingly. The auto-skip is the safe default — explicit `--skip-build` documents intent. Help text updated to describe the new flag and the auto-skip behaviour.
|
||||
|
||||
### 2. `playwright` in `devDependencies` + simplified test scripts (release pipeline)
|
||||
|
||||
- **Files**: `package.json` (+ `package-lock.json`).
|
||||
- **Problem**: `npm exec --yes --package=playwright -- node scripts/smoke-test*.js` failed with `MODULE_NOT_FOUND` in environments where `npm exec` couldn't resolve playwright on the fly (clean caches, locked CI runners). Cycle 1 worked around it with `npm install --no-save playwright`. Result: the documented test path was unreliable.
|
||||
- **Fix**: `playwright ^1.59.1` added to `devDependencies`. `test:e2e`, `test:e2e:guide`, `test:e2e:full` now invoke `node scripts/smoke-test*.js` directly — `require('playwright')` resolves locally. No browser binary install needed because the smoke tests drive Electron via `_electron`, not a browser.
|
||||
|
||||
### 3. Defensive parsing in `loadConfig` and `loadQueue` (server-side correctness)
|
||||
|
||||
- **File**: `src/main.ts` — new `isPlainObject` / `isValidQueueStatus` / `sanitizeCustomClip` / `sanitizeMergeGroup` / `sanitizeQueueItem` helpers; rewritten `loadConfig` and `loadQueue`.
|
||||
- **Problem**: `loadConfig` blindly spread `JSON.parse(data)` over the defaults. If the config file ever held a non-object (corrupt, manually edited to an array, partial write before Cycle 1's fsync landed), the spread either dropped values silently (primitives) or polluted the config object (arrays became numeric keys). `loadQueue` only validated `id`, `url`, `status` are strings — it accepted `customClip` / `mergeGroup` of any shape, never validated `progress` was a finite number, and notably never normalized stale `status: 'downloading'` items. After a hard kill mid-download, those items came back marked as still downloading with no actual download running, and `start-download` only resurrected `paused` items, leaving them stuck.
|
||||
- **Fix**: `loadConfig` checks `isPlainObject(parsed)` before spread; non-objects are logged and ignored, defaults used. `loadQueue` runs every entry through `sanitizeQueueItem` which validates the `status` enum, normalizes `progress` to `[0, 100]`, validates and normalizes `customClip` / `mergeGroup` shapes, and demotes stale `status: 'downloading'` to `pending` with `progress = 0` so the user can actually resume the queue. Invalid items are dropped with a count logged. As a bonus, the previously-unused `CustomClip` and `MergeGroupItem` type imports now have call sites.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||
- `npm run test:e2e:update-logic` — passed.
|
||||
- `npm run test:e2e` — passed via the new direct script path (no `npm exec` workaround), `issues: []`.
|
||||
- `npm run test:e2e:guide` — passed.
|
||||
- `npm run test:merge-split` — passed.
|
||||
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
## 2026-05-03 — Cycle 1: stability & UX polish
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
139
src/main.ts
139
src/main.ts
@ -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 [];
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user