fix: clip time validation, cutter 0-byte check, pagination guard, atomic config write

H1: Add NaN/negative/zero-duration validation to clip dialog before IPC call
H2: Reject cut video output <= 256 bytes as effectively empty
H3: Add paginated VOD fetching with MAX_VOD_PAGES=50 safety guard
H4: Atomic write (tmp+rename) for config and queue persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-03-21 15:33:31 +01:00
parent 39fa5065d2
commit 5f2e85e455
2 changed files with 79 additions and 26 deletions

View File

@ -269,8 +269,16 @@ function loadConfig(): Config {
} }
function saveConfig(config: Config): void { function saveConfig(config: Config): void {
const tmpPath = CONFIG_FILE + '.tmp';
try { try {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2));
try {
fs.renameSync(tmpPath, CONFIG_FILE);
} catch {
// On Windows, rename can fail if target exists in some edge cases
fs.copyFileSync(tmpPath, CONFIG_FILE);
try { fs.unlinkSync(tmpPath); } catch { }
}
} catch (e) { } catch (e) {
console.error('Error saving config:', e); console.error('Error saving config:', e);
} }
@ -321,8 +329,16 @@ function writeQueueToDisk(queue: QueueItem[]): void {
return; return;
} }
const tmpPath = QUEUE_FILE + '.tmp';
try { try {
fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2)); fs.writeFileSync(tmpPath, JSON.stringify(queue, null, 2));
try {
fs.renameSync(tmpPath, QUEUE_FILE);
} catch {
// On Windows, rename can fail if target exists in some edge cases
fs.copyFileSync(tmpPath, QUEUE_FILE);
try { fs.unlinkSync(tmpPath); } catch { }
}
} catch (e) { } catch (e) {
console.error('Error saving queue:', e); console.error('Error saving queue:', e);
} }
@ -1633,13 +1649,18 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi(); if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
const fetchVods = async () => { const MAX_VOD_PAGES = 50; // 50 pages x 100 per page = 5000 VODs max
const fetchVodsPage = async (cursor?: string) => {
const params: Record<string, string | number> = {
user_id: userId,
type: 'archive',
first: 100
};
if (cursor) params.after = cursor;
return await axios.get('https://api.twitch.tv/helix/videos', { return await axios.get('https://api.twitch.tv/helix/videos', {
params: { params,
user_id: userId,
type: 'archive',
first: 100
},
headers: { headers: {
'Client-ID': config.client_id, 'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}` 'Authorization': `Bearer ${accessToken}`
@ -1648,26 +1669,38 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
}); });
}; };
try { const fetchAllVodPages = async (): Promise<VOD[]> => {
const response = await fetchVods(); const allVods: VOD[] = [];
const vods = response.data.data || []; let cursor: string | undefined;
const login = vods[0]?.user_login; let pageCount = 0;
if (login) {
userIdLoginCache.set(userId, normalizeLogin(login));
}
do {
const response = await fetchVodsPage(cursor);
const pageVods = response.data.data || [];
allVods.push(...pageVods);
if (pageCount === 0) {
const login = pageVods[0]?.user_login;
if (login) {
userIdLoginCache.set(userId, normalizeLogin(login));
}
}
cursor = response.data.pagination?.cursor;
pageCount++;
} while (cursor && pageCount < MAX_VOD_PAGES);
return allVods;
};
try {
const vods = await fetchAllVodPages();
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
return vods; return vods;
} catch (e) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try { try {
const retryResponse = await fetchVods(); const vods = await fetchAllVodPages();
const vods = retryResponse.data.data || [];
const login = vods[0]?.user_login;
if (login) {
userIdLoginCache.set(userId, normalizeLogin(login));
}
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
return vods; return vods;
} catch (retryError) { } catch (retryError) {
@ -1906,7 +1939,17 @@ async function cutVideo(
proc.on('close', (code) => { proc.on('close', (code) => {
currentProcess = null; currentProcess = null;
resolve(code === 0 && fs.existsSync(outputFile)); if (code === 0 && fs.existsSync(outputFile)) {
const stats = fs.statSync(outputFile);
if (stats.size <= 256) {
appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size });
resolve(false);
return;
}
resolve(true);
} else {
resolve(false);
}
}); });
proc.on('error', () => { proc.on('error', () => {

View File

@ -805,17 +805,28 @@ async function confirmClipDialog(): Promise<void> {
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value); const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value); const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
const durationSec = endSec - startSec;
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim(); const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1; const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
const filenameFormat = getSelectedFilenameFormat(); const filenameFormat = getSelectedFilenameFormat();
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim(); const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim();
if (endSec <= startSec) { if (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) {
alert('Invalid time values');
return;
}
if (startSec < 0) {
alert(UI_TEXT.clips.outOfRange);
return;
}
if (durationSec <= 0) {
alert(UI_TEXT.clips.endBeforeStart); alert(UI_TEXT.clips.endBeforeStart);
return; return;
} }
if (startSec < 0 || endSec > clipTotalSeconds) { if (endSec > clipTotalSeconds) {
alert(UI_TEXT.clips.outOfRange); alert(UI_TEXT.clips.outOfRange);
return; return;
} }
@ -833,7 +844,6 @@ async function confirmClipDialog(): Promise<void> {
} }
} }
const durationSec = endSec - startSec;
const customClip: CustomClip = { const customClip: CustomClip = {
startSec, startSec,
durationSec, durationSec,