fix(cutter): protect recovery and verify hardware encoders
This commit is contained in:
+35
-31
@@ -4,7 +4,6 @@ import * as fs from 'fs';
|
||||
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
||||
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import type { Transform } from 'node:stream';
|
||||
import axios from 'axios';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
||||
import { watchRendererChanges } from './main/dev-reload';
|
||||
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
|
||||
import { createTokenBucketBudget, createTokenBucketTransform } from './main/domain/token-bucket-transform';
|
||||
import { createTokenBucketTransform } from './main/domain/token-bucket-transform';
|
||||
import { decideDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy';
|
||||
import { PartialDownloadRegistry } from './main/domain/partial-download';
|
||||
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
|
||||
@@ -49,6 +48,7 @@ import {
|
||||
CUTTER_EXPORT_PROFILES,
|
||||
getCutterExportProfile,
|
||||
parseCutterHardwareEncoders,
|
||||
probeCutterHardwareEncoders,
|
||||
type CutterExportEncoder,
|
||||
type CutterExportProfile,
|
||||
type CutterHardwareEncoder,
|
||||
@@ -419,11 +419,6 @@ function getStreamlinkStreamArg(): string {
|
||||
return `${choice},best`;
|
||||
}
|
||||
|
||||
function createDownloadThrottleTransform(): Transform | undefined {
|
||||
const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond ?? null;
|
||||
downloadThrottleBudget.setMaxBytesPerSecond(maxBytesPerSecond);
|
||||
return maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond, undefined, downloadThrottleBudget) : undefined;
|
||||
}
|
||||
function normalizeConfigTemplates(input: Config): Config {
|
||||
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
||||
// an unbounded list across years of downloads. Latest entries kept.
|
||||
@@ -815,7 +810,6 @@ const activeDownloads = new Map<string, ActiveDownloadTracking>();
|
||||
const cancelledItemIds = new Set<string>();
|
||||
const queueProcessRegistry = new QueueProcessRegistry();
|
||||
const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry);
|
||||
const downloadThrottleBudget = createTokenBucketBudget(null);
|
||||
let downloadPolicyWakeTimer: NodeJS.Timeout | null = null;
|
||||
let lastDownloadPolicyStatusFingerprint = '';
|
||||
|
||||
@@ -2970,30 +2964,39 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProces
|
||||
});
|
||||
}
|
||||
|
||||
async function runCutterFfmpegProbe(args: string[], captureOutput: boolean): Promise<{ success: boolean; output: string }> {
|
||||
return await new Promise((resolve) => {
|
||||
const proc = spawn(getFFmpegPath(), args, { windowsHide: true });
|
||||
currentCutterProbeProcesses.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let output = '';
|
||||
let settled = false;
|
||||
const finish = (success: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
currentCutterProbeProcesses.delete(proc);
|
||||
resolve({ success, output });
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { }
|
||||
finish(false);
|
||||
}, captureOutput ? 8000 : 5000);
|
||||
if (captureOutput) proc.stdout?.on('data', (chunk) => { output += chunk.toString(); });
|
||||
proc.on('close', (code) => finish(code === 0));
|
||||
proc.on('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function getCutterHardwareEncoders(): Promise<CutterHardwareEncoder[]> {
|
||||
if (cutterHardwareEncoderProbe) return await cutterHardwareEncoderProbe;
|
||||
cutterHardwareEncoderProbe = (async (): Promise<CutterHardwareEncoder[]> => {
|
||||
if (!await ensureFfmpegInstalled()) return [];
|
||||
return await new Promise<CutterHardwareEncoder[]>((resolve) => {
|
||||
const proc = spawn(getFFmpegPath(), ['-hide_banner', '-encoders'], { windowsHide: true });
|
||||
currentCutterProbeProcesses.add(proc);
|
||||
proc.stderr?.resume();
|
||||
let output = '';
|
||||
let settled = false;
|
||||
const finish = (encoders: CutterHardwareEncoder[]): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
currentCutterProbeProcesses.delete(proc);
|
||||
resolve(encoders);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
try { proc.kill(); } catch { }
|
||||
finish([]);
|
||||
}, 8000);
|
||||
proc.stdout?.on('data', (chunk) => { output += chunk.toString(); });
|
||||
proc.on('close', (code) => finish(code === 0 ? parseCutterHardwareEncoders(output) : []));
|
||||
proc.on('error', () => finish([]));
|
||||
const inventory = await runCutterFfmpegProbe(['-hide_banner', '-encoders'], true);
|
||||
if (!inventory.success) return [];
|
||||
return await probeCutterHardwareEncoders(parseCutterHardwareEncoders(inventory.output), async (_encoder, args) => {
|
||||
const probe = await runCutterFfmpegProbe([...args], false);
|
||||
return probe.success;
|
||||
});
|
||||
})();
|
||||
try {
|
||||
@@ -4046,10 +4049,11 @@ function downloadVODPart(
|
||||
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
||||
return;
|
||||
}
|
||||
const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond;
|
||||
const output = createPausableOutput(
|
||||
proc.stdout,
|
||||
outputStream,
|
||||
createDownloadThrottleTransform(),
|
||||
maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined,
|
||||
);
|
||||
const outputFinished = output.finished.then(() => null, (error) => error);
|
||||
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
|
||||
@@ -7521,7 +7525,6 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
||||
}
|
||||
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
||||
config = persistStateChange(config, () => nextConfig, saveConfig);
|
||||
downloadThrottleBudget.setMaxBytesPerSecond(config.download_policy.throttle?.maxBytesPerSecond ?? null);
|
||||
if (JSON.stringify(config.download_policy) !== previousDownloadPolicy && !isDownloading && downloadQueue.some((item) => item.status === 'pending')) {
|
||||
scheduleQueueProcessing();
|
||||
} else {
|
||||
@@ -8172,10 +8175,11 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () =
|
||||
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
||||
return;
|
||||
}
|
||||
const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond;
|
||||
const output = createPausableOutput(
|
||||
proc.stdout,
|
||||
fs.createWriteStream(partialFilename, { flags: 'w' }),
|
||||
createDownloadThrottleTransform(),
|
||||
maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined,
|
||||
);
|
||||
const outputFinished = output.finished.then(() => null, (error) => error);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user