Windows CI / verify (push) Failing after 3m49s
A fresh installation only provisioned Streamlink and FFmpeg when the first download started, which delayed that download by the full tool download and extraction time. Packaged builds now schedule a background provisioning pass fifteen seconds after startup so the tools are typically ready before the first download begins, reusing the existing ensure functions with their in-flight deduplication so a download that starts earlier simply joins the running installation. The timer is tracked and cleared during shutdown, and development and test launches keep the previous deferred behavior so isolated environments never trigger network provisioning.
9255 lines
374 KiB
TypeScript
9255 lines
374 KiB
TypeScript
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification, type IpcMainInvokeEvent } from 'electron';
|
|
import * as path from 'path';
|
|
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, createUpdateCheckCoordinator, normalizeUpdateVersion, UpdateLifecycle } from './main/updates';
|
|
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
|
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
|
import {
|
|
sanitizeFilenamePart,
|
|
formatDateWithPattern,
|
|
getMergeGroupPhaseText as getMergeGroupPhaseTextCore,
|
|
} from './main/infra/format-helpers';
|
|
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 { decideDownloadStart, decideStandaloneDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy';
|
|
import { PartialDownloadRegistry } from './main/domain/partial-download';
|
|
import {
|
|
applyQueueSnapshotPreservingActiveItems,
|
|
commitQueueAddition,
|
|
commitQueueMutation,
|
|
createPhaseBoundaryProcessResource,
|
|
createRendererQueueItem,
|
|
getMergeGroupCleanupPaths,
|
|
persistStateChange,
|
|
QueueProcessRegistry,
|
|
QueueRunLifecycle,
|
|
recoverInterruptedMergeArtifacts,
|
|
resolveMergeArtifactRoot,
|
|
waitForChildProcessExit,
|
|
waitForPhaseBoundary,
|
|
type QueueAdditionResult,
|
|
} from './main/queue';
|
|
import {
|
|
normalizeLogin,
|
|
normalizeAutoRecordPollSeconds,
|
|
normalizeAutoRecordList,
|
|
normalizeStreamlinkQuality,
|
|
normalizeFilenameTemplate,
|
|
normalizeMetadataCacheMinutes,
|
|
normalizePerformanceMode,
|
|
isPlainObject,
|
|
VALID_STREAMLINK_QUALITIES,
|
|
DEFAULT_METADATA_CACHE_MINUTES,
|
|
DEFAULT_PERFORMANCE_MODE,
|
|
type PerformanceMode,
|
|
} from './main/domain/config-normalize';
|
|
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
|
import {
|
|
buildVodPreviewFrameUrls,
|
|
parseGraphqlUser,
|
|
refreshTwitchProviderData,
|
|
requestPublicTwitchGraphql,
|
|
requestPublicTwitchVodsByLogin,
|
|
requestTwitchAppAccessToken,
|
|
requestTwitchHelixUsers,
|
|
requestTwitchHelixVideos,
|
|
TwitchAppTokenService,
|
|
type RefreshOutcome,
|
|
type TwitchHelixUser,
|
|
type TwitchVod,
|
|
} from './main/twitch';
|
|
import { createWindowsTaskbarDetails, getWindowsAppIdentity, resolveWindowsAppIconPath } from './main/domain/app-identity';
|
|
import {
|
|
addCutAt,
|
|
calculateCutterExportProgress,
|
|
createCutterExportPlan,
|
|
createCutterProjectAutosaveStore,
|
|
createVideoEditorState,
|
|
CUTTER_EXPORT_PROFILES,
|
|
getPlayableSegments,
|
|
getCutterExportProfile,
|
|
parseCutterHardwareEncoders,
|
|
probeCutterHardwareEncoders,
|
|
setTrimRange,
|
|
type CutterExportEncoder,
|
|
type CutterExportProfile,
|
|
type CutterHardwareEncoder,
|
|
type CutterProject,
|
|
type CutterProjectSource,
|
|
type EditorCut,
|
|
} from './main/cutter';
|
|
import {
|
|
CUTTER_SESSION_CAPABILITY_TTL_MS,
|
|
FileCapabilityStore,
|
|
isTrustedFileIpcSender,
|
|
publishCapabilityOutput,
|
|
type FileCapabilityPurpose,
|
|
type FileCapabilityReference,
|
|
} from './main/domain/file-capability';
|
|
import { registerTrustedIpcHandler } from './main/domain/privileged-ipc';
|
|
import {
|
|
createAppStateStore,
|
|
createElectronSecureStorage,
|
|
createExportableConfig,
|
|
createSecretStore,
|
|
migrateJsonToSqlite,
|
|
normalizeStreamerLogins,
|
|
openDatabase,
|
|
resolveSecretInputUpdate,
|
|
sanitizeConfigInput,
|
|
sanitizeImportedConfig,
|
|
type AppStateStore,
|
|
type DbHandle,
|
|
type SecretStore,
|
|
} from './main/storage';
|
|
import { projectExternalError, sanitizeLogDetails } from './main/domain/external-error';
|
|
import { LastGoodCache } from './main/domain/last-good-cache';
|
|
import { readChatFile } from './main/domain/chat-reader';
|
|
import {
|
|
canonicalQueueItemIdentity,
|
|
applyQueueTransferState,
|
|
clearQueueTransferState,
|
|
getQueueCreatedAtMs,
|
|
isValidPersistedQueueId,
|
|
mergeQueueProgressState,
|
|
prepareQueueRetryProgress,
|
|
} from './main/domain/queue-runtime';
|
|
import { createManagedToolExecutionTracker, readSecretSafely, runResilientSteps, secureImportedConfigTransition, type ManagedToolExecutionKind } from './main/domain/runtime-safety';
|
|
import {
|
|
setDebugLogFn, initToolDirs,
|
|
getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
|
refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled,
|
|
getManagedToolStatuses, repairManagedTools, resetManagedTools,
|
|
canExecute, canExecuteCommand,
|
|
cacheVerifiedStreamlinkCommand,
|
|
cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands,
|
|
invalidateVerifiedToolCaches, setManagedToolExecutionObserver
|
|
} from './tools';
|
|
|
|
// ==========================================
|
|
// CONFIG & CONSTANTS
|
|
// ==========================================
|
|
const APP_VERSION = app.getVersion();
|
|
const IS_HOT_DEVELOPMENT = process.env.TWITCH_VOD_MANAGER_DEV === '1';
|
|
const WINDOWS_APP_IDENTITY = getWindowsAppIdentity(IS_HOT_DEVELOPMENT);
|
|
app.setName(WINDOWS_APP_IDENTITY.name);
|
|
app.setAppUserModelId(WINDOWS_APP_IDENTITY.appUserModelId);
|
|
const WINDOWS_APP_ICON_PATH = process.platform === 'win32'
|
|
? resolveWindowsAppIconPath({
|
|
isPackaged: app.isPackaged,
|
|
appPath: app.getAppPath(),
|
|
resourcesPath: process.resourcesPath,
|
|
version: APP_VERSION,
|
|
})
|
|
: null;
|
|
const GITHUB_REPO_OWNER = 'Sucukdeluxe';
|
|
const GITHUB_REPO_NAME = 'Twitch-VOD-Manager';
|
|
const GITHUB_RELEASES_API_LATEST_URL = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest';
|
|
const GITHUB_RELEASES_DOWNLOAD_BASE_URL = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download';
|
|
|
|
// Paths
|
|
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
|
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
|
|
const PARTIAL_DOWNLOADS_FILE = path.join(APPDATA_DIR, 'partial-downloads.json');
|
|
const CUTTER_PROJECT_AUTOSAVE_FILE = path.join(APPDATA_DIR, 'cutter-projects.json');
|
|
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
|
|
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
|
|
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
|
|
const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
|
|
const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
|
|
const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
|
|
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
|
|
// DEFAULT_METADATA_CACHE_MINUTES + DEFAULT_PERFORMANCE_MODE kommen aus
|
|
// ./main/domain/config-normalize (Single-Source-Of-Truth, vermeidet
|
|
// Drift wenn man eine der Defaults aendert).
|
|
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
|
const STARTUP_TOOLS_PROVISION_DELAY_MS = 15 * 1000;
|
|
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
|
|
const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000;
|
|
const DEBUG_LOG_BUFFER_FLUSH_LINES = 48;
|
|
const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024;
|
|
const DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
|
const DEBUG_LOG_TRIM_TO_BYTES = 4 * 1024 * 1024;
|
|
const AUTO_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
|
const AUTO_UPDATE_STARTUP_CHECK_DELAY_MS = 5000;
|
|
const AUTO_UPDATE_MIN_CHECK_GAP_MS = 45 * 1000;
|
|
const AUTO_UPDATE_AUTO_DOWNLOAD = false;
|
|
const AUTO_UPDATE_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096;
|
|
const MAX_VOD_LIST_CACHE_ENTRIES = 512;
|
|
const MAX_CLIP_INFO_CACHE_ENTRIES = 4096;
|
|
const MAX_CONFIG_IMPORT_BYTES = 1024 * 1024;
|
|
|
|
// Timeouts
|
|
const API_TIMEOUT = 10000;
|
|
const DEFAULT_RETRY_DELAY_SECONDS = 5;
|
|
const MIN_FILE_BYTES = 256 * 1024;
|
|
const managedToolExecutionTracker = createManagedToolExecutionTracker(Boolean(process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT));
|
|
|
|
function managedToolKindFromCommand(command: string): ManagedToolExecutionKind | null {
|
|
const executable = path.basename(command).toLowerCase();
|
|
if (executable.startsWith('ffmpeg')) return 'ffmpeg';
|
|
if (executable.startsWith('ffprobe')) return 'ffprobe';
|
|
if (executable.startsWith('streamlink')) return 'streamlink';
|
|
return null;
|
|
}
|
|
|
|
function recordManagedToolExecution(kind: ManagedToolExecutionKind, command: string): void {
|
|
managedToolExecutionTracker.record(kind, command);
|
|
}
|
|
|
|
setManagedToolExecutionObserver((command) => {
|
|
const kind = managedToolKindFromCommand(command);
|
|
if (kind) recordManagedToolExecution(kind, command);
|
|
});
|
|
|
|
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
|
type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
|
type UpdateDownloadSource = 'auto' | 'manual';
|
|
|
|
function getMergeGroupPhaseText(phase: string): string {
|
|
return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de');
|
|
}
|
|
|
|
// ==========================================
|
|
// BACKEND I18N
|
|
// ==========================================
|
|
// Backend-Messages sind in src/main/domain/i18n-backend.ts.
|
|
// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language
|
|
// als 3. Parameter, der hier aus config.language injected wird.
|
|
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
|
return tBackendCore(key, params, config?.language ?? 'de');
|
|
}
|
|
|
|
// Ensure directories exist
|
|
if (!fs.existsSync(APPDATA_DIR)) {
|
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
|
}
|
|
const partialDownloadRegistry = new PartialDownloadRegistry(PARTIAL_DOWNLOADS_FILE);
|
|
const cutterProjectAutosaves = createCutterProjectAutosaveStore(CUTTER_PROJECT_AUTOSAVE_FILE);
|
|
|
|
// ==========================================
|
|
// INTERFACES
|
|
// ==========================================
|
|
interface Config {
|
|
client_id: string;
|
|
download_path: string;
|
|
streamers: string[];
|
|
streamer_display_names: Record<string, string>;
|
|
theme: string;
|
|
download_mode: 'parts' | 'full';
|
|
part_minutes: number;
|
|
language: 'de' | 'en';
|
|
sidebar_split_view: boolean;
|
|
filename_template_vod: string;
|
|
filename_template_parts: string;
|
|
filename_template_clip: string;
|
|
smart_queue_scheduler: boolean;
|
|
performance_mode: PerformanceMode;
|
|
prevent_duplicate_downloads: boolean;
|
|
persist_queue_on_restart: boolean;
|
|
metadata_cache_minutes: number;
|
|
parallel_downloads: number;
|
|
auto_resume_queue_on_startup: boolean;
|
|
downloaded_vod_ids: string[];
|
|
streamlink_quality: string;
|
|
notify_on_each_completion: boolean;
|
|
streamlink_disable_ads: boolean;
|
|
download_policy: DownloadPolicy;
|
|
auto_record_streamers: string[];
|
|
auto_record_poll_seconds: number;
|
|
download_chat_replay: boolean;
|
|
capture_live_chat: boolean;
|
|
discord_notify_live_start: boolean;
|
|
discord_notify_live_end: boolean;
|
|
discord_notify_vod_complete: boolean;
|
|
discord_notify_vod_auto_queued: boolean;
|
|
auto_cleanup_enabled: boolean;
|
|
auto_cleanup_days: number;
|
|
auto_cleanup_target: 'live_only' | 'all';
|
|
auto_cleanup_action: 'delete' | 'archive';
|
|
log_stream_events: boolean;
|
|
auto_vod_download_streamers: string[];
|
|
auto_vod_download_poll_minutes: number;
|
|
auto_vod_max_age_hours: number;
|
|
auto_resume_live_recording: boolean;
|
|
auto_merge_resumed_parts: boolean;
|
|
delete_parts_after_merge: boolean;
|
|
}
|
|
|
|
interface DownloadPolicyStatus {
|
|
waiting: boolean;
|
|
reason: 'outside-window' | null;
|
|
nextStart: string | null;
|
|
}
|
|
|
|
interface RuntimeMetrics {
|
|
cacheHits: number;
|
|
cacheMisses: number;
|
|
duplicateSkips: number;
|
|
retriesScheduled: number;
|
|
retriesExhausted: number;
|
|
integrityFailures: number;
|
|
downloadsStarted: number;
|
|
downloadsCompleted: number;
|
|
downloadsFailed: number;
|
|
downloadedBytesTotal: number;
|
|
lastSpeedBytesPerSec: number;
|
|
avgSpeedBytesPerSec: number;
|
|
activeItemId: string | null;
|
|
activeItemTitle: string | null;
|
|
lastErrorClass: RetryErrorClass | null;
|
|
lastRetryDelaySeconds: number;
|
|
}
|
|
|
|
interface RuntimeMetricsSnapshot extends RuntimeMetrics {
|
|
timestamp: string;
|
|
queue: {
|
|
pending: number;
|
|
downloading: number;
|
|
paused: number;
|
|
completed: number;
|
|
error: number;
|
|
total: number;
|
|
};
|
|
caches: {
|
|
loginToUserId: number;
|
|
vodList: number;
|
|
clipInfo: number;
|
|
};
|
|
config: {
|
|
performanceMode: PerformanceMode;
|
|
smartScheduler: boolean;
|
|
metadataCacheMinutes: number;
|
|
duplicatePrevention: boolean;
|
|
};
|
|
}
|
|
|
|
interface CacheEntry<T> {
|
|
value: T;
|
|
expiresAt: number;
|
|
}
|
|
|
|
type VOD = TwitchVod;
|
|
|
|
interface PreflightChecks {
|
|
internet: boolean;
|
|
streamlink: boolean;
|
|
ffmpeg: boolean;
|
|
ffprobe: boolean;
|
|
downloadPathWritable: boolean;
|
|
}
|
|
|
|
interface PreflightResult {
|
|
ok: boolean;
|
|
autoFixApplied: boolean;
|
|
checks: PreflightChecks;
|
|
messages: string[];
|
|
timestamp: string;
|
|
}
|
|
|
|
interface VideoInfo {
|
|
duration: number;
|
|
width: number;
|
|
height: number;
|
|
fps: number;
|
|
hasAudio: boolean;
|
|
videoCodec: string;
|
|
audioCodec: string | null;
|
|
previewCompatible: boolean;
|
|
variableFrameRate: boolean;
|
|
rotation: 0 | 90 | 180 | 270;
|
|
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
|
|
}
|
|
|
|
interface VideoEditorMedia {
|
|
sourceUrl: string;
|
|
info: VideoInfo;
|
|
jobId: number;
|
|
thumbnails: string[];
|
|
waveform: string | null;
|
|
}
|
|
|
|
interface VideoEditorAssets {
|
|
jobId: number;
|
|
thumbnails: string[];
|
|
thumbnailSprite: string | null;
|
|
thumbnailCount: number;
|
|
pixelWidth: number;
|
|
pixelHeight: number;
|
|
}
|
|
|
|
interface VideoEditorWaveform {
|
|
jobId: number;
|
|
waveform: string | null;
|
|
pixelWidth: number;
|
|
pixelHeight: number;
|
|
}
|
|
|
|
interface VideoEditorAssetProfile {
|
|
timelineWidth: number;
|
|
trackHeight: number;
|
|
pixelRatio: number;
|
|
}
|
|
|
|
interface VideoEditExportRequest {
|
|
inputFile: string;
|
|
outputFile: string;
|
|
trimStart: number;
|
|
trimEnd: number;
|
|
cuts: EditorCut[];
|
|
profile: CutterExportProfile;
|
|
encoder: CutterExportEncoder;
|
|
audioStreamIndex: number;
|
|
}
|
|
|
|
interface RendererVideoEditExportRequest {
|
|
inputCapability: string;
|
|
outputName?: string;
|
|
trimStart: number;
|
|
trimEnd: number;
|
|
cuts: EditorCut[];
|
|
profile?: CutterExportProfile;
|
|
encoder?: CutterExportEncoder;
|
|
audioStreamIndex?: number;
|
|
}
|
|
|
|
interface ReleaseUpdateInfo {
|
|
tagName?: string;
|
|
version?: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
}
|
|
|
|
// ==========================================
|
|
// CONFIG MANAGEMENT
|
|
// ==========================================
|
|
const defaultConfig: Config = {
|
|
client_id: '',
|
|
download_path: DEFAULT_DOWNLOAD_PATH,
|
|
streamers: [],
|
|
streamer_display_names: {},
|
|
theme: 'twitch',
|
|
download_mode: 'full',
|
|
part_minutes: 120,
|
|
language: 'en',
|
|
sidebar_split_view: true,
|
|
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
|
|
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
|
|
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
|
|
smart_queue_scheduler: true,
|
|
performance_mode: DEFAULT_PERFORMANCE_MODE,
|
|
prevent_duplicate_downloads: true,
|
|
persist_queue_on_restart: true,
|
|
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
|
|
parallel_downloads: 1,
|
|
auto_resume_queue_on_startup: false,
|
|
downloaded_vod_ids: [],
|
|
streamlink_quality: 'best',
|
|
notify_on_each_completion: false,
|
|
streamlink_disable_ads: true,
|
|
download_policy: { throttle: null, windows: [] },
|
|
auto_record_streamers: [],
|
|
auto_record_poll_seconds: 90,
|
|
download_chat_replay: false,
|
|
capture_live_chat: false,
|
|
discord_notify_live_start: false,
|
|
discord_notify_live_end: false,
|
|
discord_notify_vod_complete: false,
|
|
discord_notify_vod_auto_queued: false,
|
|
auto_cleanup_enabled: false,
|
|
auto_cleanup_days: 30,
|
|
auto_cleanup_target: 'live_only',
|
|
auto_cleanup_action: 'archive',
|
|
log_stream_events: true,
|
|
auto_vod_download_streamers: [],
|
|
auto_vod_download_poll_minutes: 15,
|
|
auto_vod_max_age_hours: 24,
|
|
auto_resume_live_recording: true,
|
|
auto_merge_resumed_parts: false,
|
|
delete_parts_after_merge: false
|
|
};
|
|
|
|
// normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin
|
|
// kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt
|
|
// hier, da es config liest.
|
|
function getStreamlinkStreamArg(): string {
|
|
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
|
|
if (choice === 'best') return 'best';
|
|
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.
|
|
const DOWNLOADED_IDS_MAX = 4096;
|
|
const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : [];
|
|
const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX
|
|
? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX)
|
|
: cleanIds;
|
|
const displayNames: Record<string, string> = {};
|
|
if (isPlainObject(input.streamer_display_names)) {
|
|
for (const [login, displayName] of Object.entries(input.streamer_display_names)) {
|
|
const normalizedLogin = normalizeLogin(login);
|
|
const normalizedDisplayName = typeof displayName === 'string' ? displayName.trim() : '';
|
|
if (normalizedLogin && normalizedDisplayName) displayNames[normalizedLogin] = normalizedDisplayName;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...input,
|
|
streamers: normalizeStreamerLogins(input.streamers) ?? [],
|
|
streamer_display_names: displayNames,
|
|
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
|
|
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
|
|
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
|
|
sidebar_split_view: input.sidebar_split_view !== false,
|
|
smart_queue_scheduler: input.smart_queue_scheduler !== false,
|
|
performance_mode: normalizePerformanceMode(input.performance_mode),
|
|
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
|
|
persist_queue_on_restart: input.persist_queue_on_restart !== false,
|
|
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
|
|
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
|
|
downloaded_vod_ids: trimmedIds,
|
|
streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality),
|
|
notify_on_each_completion: input.notify_on_each_completion === true,
|
|
// Default-true on first launch (most users hit this), but respect
|
|
// an explicit `false` from the loaded config.
|
|
streamlink_disable_ads: input.streamlink_disable_ads !== false,
|
|
download_policy: normalizeDownloadPolicy(input.download_policy),
|
|
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
|
|
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
|
download_chat_replay: input.download_chat_replay === true,
|
|
capture_live_chat: input.capture_live_chat === true,
|
|
discord_notify_live_start: input.discord_notify_live_start === true,
|
|
discord_notify_live_end: input.discord_notify_live_end === true,
|
|
discord_notify_vod_complete: input.discord_notify_vod_complete === true,
|
|
discord_notify_vod_auto_queued: input.discord_notify_vod_auto_queued === true,
|
|
auto_cleanup_enabled: input.auto_cleanup_enabled === true,
|
|
auto_cleanup_days: (() => {
|
|
const n = Number(input.auto_cleanup_days);
|
|
if (!Number.isFinite(n) || n < 1) return 30;
|
|
return Math.min(3650, Math.floor(n));
|
|
})(),
|
|
auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only',
|
|
auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
|
|
log_stream_events: input.log_stream_events !== false,
|
|
auto_vod_download_streamers: normalizeAutoRecordList(input.auto_vod_download_streamers),
|
|
auto_vod_download_poll_minutes: (() => {
|
|
const n = Number(input.auto_vod_download_poll_minutes);
|
|
if (!Number.isFinite(n)) return 15;
|
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
|
})(),
|
|
auto_vod_max_age_hours: (() => {
|
|
const n = Number(input.auto_vod_max_age_hours);
|
|
if (!Number.isFinite(n)) return 24;
|
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
|
})(),
|
|
auto_resume_live_recording: input.auto_resume_live_recording !== false,
|
|
auto_merge_resumed_parts: input.auto_merge_resumed_parts === true,
|
|
delete_parts_after_merge: input.delete_parts_after_merge === true
|
|
};
|
|
}
|
|
|
|
function recordDownloadedVodId(vodId: string): void {
|
|
if (!vodId) return;
|
|
const downloadedVodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
|
if (downloadedVodIds.includes(vodId)) return;
|
|
const DOWNLOADED_IDS_MAX = 4096;
|
|
const nextDownloadedVodIds = [...downloadedVodIds, vodId].slice(-DOWNLOADED_IDS_MAX);
|
|
config = persistStateChange(config, (current) => ({ ...current, downloaded_vod_ids: nextDownloadedVodIds }), saveConfig);
|
|
}
|
|
|
|
function cloneConfig(value: Config): Config {
|
|
return JSON.parse(JSON.stringify(value)) as Config;
|
|
}
|
|
|
|
function loadConfig(): Config {
|
|
try {
|
|
const persisted = appStateStore?.loadConfig();
|
|
if (persisted && isPlainObject(persisted)) {
|
|
return normalizeConfigTemplates({ ...defaultConfig, ...persisted } as Config);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading config:', e);
|
|
}
|
|
return normalizeConfigTemplates(defaultConfig);
|
|
}
|
|
|
|
function saveConfig(nextConfig: Config): void {
|
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
|
try {
|
|
appStateStore.saveConfig(nextConfig);
|
|
lastPersistedConfig = cloneConfig(nextConfig);
|
|
} catch (error) {
|
|
if (nextConfig === config) config = cloneConfig(lastPersistedConfig);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// 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,
|
|
splitTempFiles: Array.isArray(raw.splitTempFiles) ? raw.splitTempFiles.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' && filenameFormat !== 'parts') 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.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 id = isValidPersistedQueueId(raw.id) ? raw.id : generateQueueItemId();
|
|
const createdAtMs = getQueueCreatedAtMs({
|
|
id,
|
|
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : undefined,
|
|
}, Date.now());
|
|
const item: QueueItem = {
|
|
id,
|
|
createdAt: new Date(createdAtMs).toISOString(),
|
|
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 (finalStatus === 'downloading' || finalStatus === 'paused') {
|
|
if (typeof raw.speed === 'string') item.speed = raw.speed;
|
|
if (typeof raw.eta === 'string') item.eta = raw.eta;
|
|
if (typeof raw.progressStatus === 'string') item.progressStatus = raw.progressStatus;
|
|
if (raw.recordingHealth === 'ok' || raw.recordingHealth === 'stale' || raw.recordingHealth === 'unknown') {
|
|
item.recordingHealth = raw.recordingHealth;
|
|
}
|
|
}
|
|
if (typeof raw.last_error === 'string') item.last_error = raw.last_error;
|
|
if (finalStatus === 'paused') {
|
|
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;
|
|
}
|
|
|
|
if (Array.isArray(raw.outputFiles)) {
|
|
const files = raw.outputFiles.filter((f): f is string => typeof f === 'string' && f.length > 0);
|
|
if (files.length > 0) item.outputFiles = files;
|
|
}
|
|
|
|
if (raw.isLive === true) {
|
|
item.isLive = true;
|
|
}
|
|
|
|
const customClip = sanitizeCustomClip(raw.customClip);
|
|
if (customClip) item.customClip = customClip;
|
|
|
|
const mergeGroup = sanitizeMergeGroup(raw.mergeGroup);
|
|
if (mergeGroup) item.mergeGroup = mergeGroup;
|
|
if (raw.mergeRecoveryBlocked === true) item.mergeRecoveryBlocked = true;
|
|
if (typeof raw.artifactRoot === 'string' && path.isAbsolute(raw.artifactRoot)) item.artifactRoot = path.resolve(raw.artifactRoot);
|
|
|
|
return item;
|
|
}
|
|
|
|
interface QueueLoadResult {
|
|
queue: QueueItem[];
|
|
interruptedMergeItemIds: Set<string>;
|
|
}
|
|
|
|
function loadQueue(): QueueLoadResult {
|
|
if (config.persist_queue_on_restart === false) {
|
|
return { queue: [], interruptedMergeItemIds: new Set() };
|
|
}
|
|
|
|
try {
|
|
const parsed = appStateStore?.loadQueue<Record<string, unknown>>() ?? [];
|
|
const interruptedMergeItemIds = new Set<string>();
|
|
const items: QueueItem[] = [];
|
|
const loadedIds = new Set<string>();
|
|
let droppedCount = 0;
|
|
for (const raw of parsed) {
|
|
const wasInterruptedMerge = isPlainObject(raw) && raw.status === 'downloading' && isPlainObject(raw.mergeGroup);
|
|
const sanitized = sanitizeQueueItem(raw);
|
|
if (sanitized) {
|
|
if (loadedIds.has(sanitized.id)) {
|
|
do {
|
|
sanitized.id = generateQueueItemId();
|
|
} while (loadedIds.has(sanitized.id));
|
|
sanitized.createdAt = new Date().toISOString();
|
|
}
|
|
loadedIds.add(sanitized.id);
|
|
if (wasInterruptedMerge) interruptedMergeItemIds.add(sanitized.id);
|
|
items.push(sanitized);
|
|
}
|
|
else droppedCount++;
|
|
}
|
|
if (droppedCount > 0) {
|
|
console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`);
|
|
}
|
|
return { queue: items, interruptedMergeItemIds };
|
|
} catch (e) {
|
|
console.error('Error loading queue:', e);
|
|
}
|
|
return { queue: [], interruptedMergeItemIds: new Set() };
|
|
}
|
|
|
|
let queueSaveTimer: NodeJS.Timeout | null = null;
|
|
let pendingQueueSnapshot: QueueItem[] | null = null;
|
|
|
|
function cloneQueue(queue: QueueItem[]): QueueItem[] {
|
|
return JSON.parse(JSON.stringify(queue)) as QueueItem[];
|
|
}
|
|
|
|
function clearQueueFileFromDisk(): void {
|
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
|
appStateStore.saveQueue([]);
|
|
lastPersistedQueueSnapshot = [];
|
|
}
|
|
|
|
function writeQueueToDisk(queue: QueueItem[]): void {
|
|
if (config.persist_queue_on_restart === false) {
|
|
clearQueueFileFromDisk();
|
|
return;
|
|
}
|
|
|
|
if (!appStateStore) throw new Error('Application state store is unavailable');
|
|
appStateStore.saveQueue(queue);
|
|
lastPersistedQueueSnapshot = cloneQueue(queue);
|
|
}
|
|
|
|
function saveQueue(queue: QueueItem[], force = false): void {
|
|
const snapshot = cloneQueue(queue);
|
|
pendingQueueSnapshot = snapshot;
|
|
|
|
if (appShutdownStarted && !force) {
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
|
|
try {
|
|
writeQueueToDisk(snapshot);
|
|
pendingQueueSnapshot = null;
|
|
} catch (error) {
|
|
pendingQueueSnapshot = null;
|
|
if (queue === downloadQueue) downloadQueue = cloneQueue(lastPersistedQueueSnapshot);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function flushQueueSave(): void {
|
|
if (pendingQueueSnapshot) {
|
|
saveQueue(pendingQueueSnapshot, true);
|
|
} else {
|
|
saveQueue(downloadQueue, true);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// GLOBAL STATE
|
|
// ==========================================
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let stopDevelopmentReload: (() => void) | null = null;
|
|
|
|
function startDevelopmentReload(): void {
|
|
if (process.env.TWITCH_VOD_MANAGER_DEV !== '1' || stopDevelopmentReload) return;
|
|
stopDevelopmentReload = watchRendererChanges(
|
|
__dirname,
|
|
path.join(__dirname, '../src'),
|
|
() => mainWindow?.webContents.reloadIgnoringCache(),
|
|
);
|
|
app.once('before-quit', () => {
|
|
stopDevelopmentReload?.();
|
|
stopDevelopmentReload = null;
|
|
});
|
|
}
|
|
let appStateStore: AppStateStore | null = null;
|
|
let appSecretStore: SecretStore | null = null;
|
|
let config = normalizeConfigTemplates(defaultConfig);
|
|
let lastPersistedConfig = cloneConfig(config);
|
|
let twitchClientSecret = '';
|
|
let discordWebhookUrl = '';
|
|
const twitchAppTokenService = new TwitchAppTokenService(
|
|
(credentials) => requestTwitchAppAccessToken(axios, credentials, API_TIMEOUT),
|
|
(error) => console.error('Login error:', error),
|
|
);
|
|
let downloadQueue: QueueItem[] = [];
|
|
let lastPersistedQueueSnapshot: QueueItem[] = [];
|
|
let queueIdCounter = 0;
|
|
let lastQueueBroadcastFingerprint = '';
|
|
let isDownloading = false;
|
|
let queuePaused = false;
|
|
const currentEditorProcesses = new Set<ChildProcess>();
|
|
let currentCutterProcess: ChildProcess | null = null;
|
|
let currentCutterPartialFile: string | null = null;
|
|
let cutterExportActive = false;
|
|
let cutterExportCancelled = false;
|
|
let cutterHardwareEncoderProbe: Promise<CutterHardwareEncoder[]> | null = null;
|
|
let cutterPreparedInput: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null = null;
|
|
let cutterMediaGeneration = 0;
|
|
let cutterMediaRequestGeneration = 0;
|
|
let cutterAssetRunGeneration = 0;
|
|
let cutterWaveformGeneration = 0;
|
|
let cutterMediaJob: {
|
|
jobId: number;
|
|
path: string;
|
|
identity: { path: string; size: number; mtimeMs: number; dev: number; ino: number };
|
|
info: VideoInfo;
|
|
waveform: VideoEditorWaveform | null;
|
|
waveformPromise: Promise<VideoEditorWaveform | null> | null;
|
|
previewDirectory: string | null;
|
|
} | null = null;
|
|
let appShutdownStarted = false;
|
|
const currentCutterMediaProcesses = new Set<ChildProcess>();
|
|
const currentCutterWaveformProcesses = new Set<ChildProcess>();
|
|
const currentCutterProbeProcesses = new Set<ChildProcess>();
|
|
const currentCutterInfoProcesses = new Set<ChildProcess>();
|
|
const currentCutterExportProcesses = new Set<ChildProcess>();
|
|
const currentCutterPreviewProcesses = new Set<ChildProcess>();
|
|
const currentCutterFrameProcesses = new Set<ChildProcess>();
|
|
const currentCutterFrameFiles = new Set<string>();
|
|
// Per-item cancellation lives in `cancelledItemIds`. The previous global
|
|
// `currentDownloadCancelled` flag was redundant once pause/cancel/remove
|
|
// started iterating activeDownloads and adding each item to that Set; it
|
|
// was removed in the 4.5.27 cleanup.
|
|
let activeQueueItemId: string | null = null;
|
|
let downloadStartTime = 0;
|
|
let downloadedBytes = 0;
|
|
// Per-item tracking for parallel downloads
|
|
interface ActiveDownloadTracking {
|
|
process: ChildProcess | null;
|
|
cancelled: boolean;
|
|
startTime: number;
|
|
bytes: number;
|
|
output: PausableOutput;
|
|
partialFilename: string;
|
|
}
|
|
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 = '';
|
|
|
|
function registerQueuePartialFile(itemId: string, filePath: string): void {
|
|
queueProcessRegistry.register(itemId, 'post-processing', {
|
|
cleanup: () => { try { fs.rmSync(filePath, { force: true }); } catch { } },
|
|
});
|
|
}
|
|
|
|
async function waitForQueuePhaseBoundary(itemId: string | null): Promise<boolean> {
|
|
return await waitForPhaseBoundary(itemId, queueProcessRegistry, {
|
|
onPaused: () => {
|
|
if (!itemId) return;
|
|
const item = downloadQueue.find((candidate) => candidate.id === itemId);
|
|
if (!item) return;
|
|
item.status = 'paused';
|
|
item.speed = '';
|
|
item.eta = '';
|
|
item.progressStatus = tBackend('downloadPaused');
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated(true);
|
|
},
|
|
onResumed: () => {
|
|
if (!itemId) return;
|
|
const item = downloadQueue.find((candidate) => candidate.id === itemId);
|
|
if (!item) return;
|
|
item.status = 'downloading';
|
|
delete item.speed;
|
|
delete item.eta;
|
|
delete item.progressStatus;
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated(true);
|
|
},
|
|
});
|
|
}
|
|
// userId -> login reverse map. Bounded via Map insertion-order eviction so
|
|
// a long-running session doesn't grow it unbounded across thousands of
|
|
// streamer lookups. Values are short (~20 char each) but accumulate.
|
|
const USER_ID_LOGIN_CACHE_MAX = 4096;
|
|
const userIdLoginCache = new Map<string, string>();
|
|
function setUserIdLogin(userId: string, login: string): void {
|
|
if (!userId || !login) return;
|
|
if (userIdLoginCache.has(userId)) {
|
|
userIdLoginCache.delete(userId);
|
|
}
|
|
userIdLoginCache.set(userId, login);
|
|
while (userIdLoginCache.size > USER_ID_LOGIN_CACHE_MAX) {
|
|
const oldest = userIdLoginCache.keys().next().value as string | undefined;
|
|
if (!oldest) break;
|
|
userIdLoginCache.delete(oldest);
|
|
}
|
|
}
|
|
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
|
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
|
const vodListLastGood = new LastGoodCache<VOD[]>(MAX_VOD_LIST_CACHE_ENTRIES);
|
|
const clipInfoCache = new Map<string, CacheEntry<any>>();
|
|
const inFlightUserIdRequests = new Map<string, Promise<string | null>>();
|
|
const inFlightVodRequests = new Map<string, Promise<VOD[]>>();
|
|
const inFlightClipRequests = new Map<string, Promise<any | null>>();
|
|
let cacheCleanupTimer: NodeJS.Timeout | null = null;
|
|
const runtimeMetrics: RuntimeMetrics = {
|
|
cacheHits: 0,
|
|
cacheMisses: 0,
|
|
duplicateSkips: 0,
|
|
retriesScheduled: 0,
|
|
retriesExhausted: 0,
|
|
integrityFailures: 0,
|
|
downloadsStarted: 0,
|
|
downloadsCompleted: 0,
|
|
downloadsFailed: 0,
|
|
downloadedBytesTotal: 0,
|
|
lastSpeedBytesPerSec: 0,
|
|
avgSpeedBytesPerSec: 0,
|
|
activeItemId: null,
|
|
activeItemTitle: null,
|
|
lastErrorClass: null,
|
|
lastRetryDelaySeconds: 0
|
|
};
|
|
let debugLogFlushTimer: NodeJS.Timeout | null = null;
|
|
let pendingDebugLogLines: string[] = [];
|
|
let autoUpdaterInitialized = false;
|
|
let autoUpdateCheckTimer: NodeJS.Timeout | null = null;
|
|
let autoUpdateStartupTimer: NodeJS.Timeout | null = null;
|
|
let autoUpdaterSetupTimer: NodeJS.Timeout | null = null;
|
|
const autoUpdateCheckCoordinator = createUpdateCheckCoordinator();
|
|
const autoUpdateLifecycle = new UpdateLifecycle();
|
|
let lastAutoUpdateCheckAt = 0;
|
|
let latestKnownUpdateVersion: string | null = null;
|
|
let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null;
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function isDownloadPathWritable(targetPath: string): boolean {
|
|
try {
|
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
const probeFile = path.join(targetPath, `.write_test_${Date.now()}.tmp`);
|
|
fs.writeFileSync(probeFile, 'ok');
|
|
fs.unlinkSync(probeFile);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function hasInternetConnection(): Promise<boolean> {
|
|
try {
|
|
const res = await axios.get('https://id.twitch.tv/oauth2/validate', {
|
|
timeout: 5000,
|
|
validateStatus: () => true
|
|
});
|
|
return res.status > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runPreflight(autoFix = false): Promise<PreflightResult> {
|
|
appendDebugLog('preflight-start', { autoFix });
|
|
|
|
refreshBundledToolPaths();
|
|
|
|
const checks: PreflightChecks = {
|
|
internet: await hasInternetConnection(),
|
|
streamlink: false,
|
|
ffmpeg: false,
|
|
ffprobe: false,
|
|
downloadPathWritable: isDownloadPathWritable(config.download_path)
|
|
};
|
|
|
|
if (autoFix) {
|
|
await ensureStreamlinkInstalled();
|
|
await ensureFfmpegInstalled();
|
|
refreshBundledToolPaths(true);
|
|
}
|
|
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
|
|
if (checks.streamlink) {
|
|
cacheVerifiedStreamlinkCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
|
|
}
|
|
|
|
const ffmpegPath = getFFmpegPath();
|
|
const ffprobePath = getFFprobePath();
|
|
checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']);
|
|
checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']);
|
|
if (checks.ffmpeg && checks.ffprobe) {
|
|
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
|
|
}
|
|
|
|
const messages: string[] = [];
|
|
if (!checks.internet) messages.push(tBackend('preflightNoInternet'));
|
|
if (!checks.streamlink) messages.push(tBackend('preflightStreamlinkMissing'));
|
|
if (!checks.ffmpeg) messages.push(tBackend('preflightFfmpegMissing'));
|
|
if (!checks.ffprobe) messages.push(tBackend('preflightFfprobeMissing'));
|
|
if (!checks.downloadPathWritable) messages.push(tBackend('preflightDownloadPathNotWritable'));
|
|
|
|
const result: PreflightResult = {
|
|
ok: messages.length === 0,
|
|
autoFixApplied: autoFix,
|
|
checks,
|
|
messages,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
appendDebugLog('preflight-finished', result);
|
|
return result;
|
|
}
|
|
|
|
function flushPendingDebugLogLines(): void {
|
|
if (!pendingDebugLogLines.length) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = pendingDebugLogLines.join('');
|
|
pendingDebugLogLines = [];
|
|
fs.appendFileSync(DEBUG_LOG_FILE, payload);
|
|
trimDebugLogFileIfNeeded();
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
function trimDebugLogFileIfNeeded(): void {
|
|
try {
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
return;
|
|
}
|
|
|
|
const stats = fs.statSync(DEBUG_LOG_FILE);
|
|
if (stats.size <= DEBUG_LOG_MAX_BYTES) {
|
|
return;
|
|
}
|
|
|
|
const bytesToKeep = Math.min(DEBUG_LOG_TRIM_TO_BYTES, stats.size);
|
|
const startOffset = Math.max(0, stats.size - bytesToKeep);
|
|
const buffer = Buffer.allocUnsafe(bytesToKeep);
|
|
|
|
let fileHandle: number | null = null;
|
|
try {
|
|
fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r');
|
|
fs.readSync(fileHandle, buffer, 0, bytesToKeep, startOffset);
|
|
} finally {
|
|
if (fileHandle !== null) {
|
|
fs.closeSync(fileHandle);
|
|
}
|
|
}
|
|
|
|
const firstLineBreak = buffer.indexOf(0x0a);
|
|
const trimmed = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length
|
|
? buffer.subarray(firstLineBreak + 1)
|
|
: buffer;
|
|
|
|
fs.writeFileSync(DEBUG_LOG_FILE, trimmed);
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
function readDebugLogTailFromDisk(): string {
|
|
const stats = fs.statSync(DEBUG_LOG_FILE);
|
|
if (stats.size <= 0) {
|
|
return '';
|
|
}
|
|
|
|
const bytesToRead = Math.min(stats.size, DEBUG_LOG_READ_TAIL_BYTES);
|
|
if (bytesToRead === stats.size) {
|
|
return fs.readFileSync(DEBUG_LOG_FILE, 'utf-8');
|
|
}
|
|
|
|
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
let fileHandle: number | null = null;
|
|
try {
|
|
fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r');
|
|
fs.readSync(fileHandle, buffer, 0, bytesToRead, stats.size - bytesToRead);
|
|
} finally {
|
|
if (fileHandle !== null) {
|
|
fs.closeSync(fileHandle);
|
|
}
|
|
}
|
|
|
|
const firstLineBreak = buffer.indexOf(0x0a);
|
|
const slice = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length
|
|
? buffer.subarray(firstLineBreak + 1)
|
|
: buffer;
|
|
|
|
return slice.toString('utf-8');
|
|
}
|
|
|
|
function startDebugLogFlushTimer(): void {
|
|
if (debugLogFlushTimer) {
|
|
return;
|
|
}
|
|
|
|
debugLogFlushTimer = setInterval(() => {
|
|
flushPendingDebugLogLines();
|
|
}, DEBUG_LOG_FLUSH_INTERVAL_MS);
|
|
|
|
debugLogFlushTimer.unref?.();
|
|
}
|
|
|
|
function stopDebugLogFlushTimer(flush = true): void {
|
|
if (debugLogFlushTimer) {
|
|
clearInterval(debugLogFlushTimer);
|
|
debugLogFlushTimer = null;
|
|
}
|
|
|
|
if (flush) {
|
|
flushPendingDebugLogLines();
|
|
}
|
|
}
|
|
|
|
function readDebugLog(lines = 200): string {
|
|
try {
|
|
flushPendingDebugLogLines();
|
|
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
return 'Debug-Log ist leer.';
|
|
}
|
|
|
|
const text = readDebugLogTailFromDisk();
|
|
const rows = text.split(/\r?\n/).filter(Boolean);
|
|
return rows.slice(-lines).join('\n') || 'Debug-Log ist leer.';
|
|
} catch (e) {
|
|
return `Debug-Log konnte nicht gelesen werden: ${String(e)}`;
|
|
}
|
|
}
|
|
|
|
function appendDebugLog(message: string, details?: unknown): void {
|
|
try {
|
|
const ts = new Date().toISOString();
|
|
const sanitizedDetails = sanitizeLogDetails(details);
|
|
const payload = details === undefined
|
|
? ''
|
|
: ` | ${typeof sanitizedDetails === 'string' ? sanitizedDetails : JSON.stringify(sanitizedDetails)}`;
|
|
|
|
pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`);
|
|
|
|
if (pendingDebugLogLines.length >= DEBUG_LOG_BUFFER_FLUSH_LINES) {
|
|
flushPendingDebugLogLines();
|
|
} else {
|
|
startDebugLogFlushTimer();
|
|
}
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
// Wire up tools module with debug logging and directory paths
|
|
setDebugLogFn(appendDebugLog);
|
|
initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp'));
|
|
|
|
const claimedFilenames = new Set<string>();
|
|
const itemClaimedFilenames = new Map<string, Set<string>>();
|
|
|
|
function ensureUniqueFilename(filePath: string, itemId: string | null = null): string {
|
|
const dir = path.dirname(filePath);
|
|
const ext = path.extname(filePath);
|
|
const base = path.basename(filePath, ext);
|
|
let candidate = filePath;
|
|
let counter = 0;
|
|
while (fs.existsSync(candidate) || claimedFilenames.has(candidate)) {
|
|
counter++;
|
|
candidate = path.join(dir, `${base}_${counter}${ext}`);
|
|
}
|
|
claimedFilenames.add(candidate);
|
|
if (itemId) {
|
|
let perItem = itemClaimedFilenames.get(itemId);
|
|
if (!perItem) {
|
|
perItem = new Set();
|
|
itemClaimedFilenames.set(itemId, perItem);
|
|
}
|
|
perItem.add(candidate);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function releaseClaimedFilenamesForItem(itemId: string): void {
|
|
const perItem = itemClaimedFilenames.get(itemId);
|
|
if (!perItem) return;
|
|
for (const f of perItem) claimedFilenames.delete(f);
|
|
itemClaimedFilenames.delete(itemId);
|
|
}
|
|
|
|
|
|
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
|
const hours = Math.floor(safe / 3600);
|
|
const minutes = Math.floor((safe % 3600) / 60);
|
|
const seconds = safe % 60;
|
|
|
|
const tokenMap: Record<string, string> = {
|
|
HH: hours.toString().padStart(2, '0'),
|
|
H: hours.toString(),
|
|
hh: hours.toString().padStart(2, '0'),
|
|
h: hours.toString(),
|
|
mm: minutes.toString().padStart(2, '0'),
|
|
m: minutes.toString(),
|
|
ss: seconds.toString().padStart(2, '0'),
|
|
s: seconds.toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function parseVodId(url: string): string {
|
|
const match = url.match(/videos\/(\d+)/i);
|
|
return match?.[1] || '';
|
|
}
|
|
|
|
function isLikelyVodUrl(url: string): boolean {
|
|
return /twitch\.tv\/videos\/\d+/i.test(url || '');
|
|
}
|
|
|
|
function parseFrameRate(rawFrameRate: string | undefined): number {
|
|
const fallback = 30;
|
|
const value = (rawFrameRate || '').trim();
|
|
if (!value) return fallback;
|
|
|
|
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
}
|
|
|
|
const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
|
|
if (!ratio) return fallback;
|
|
|
|
const numerator = Number(ratio[1]);
|
|
const denominator = Number(ratio[2]);
|
|
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
|
return fallback;
|
|
}
|
|
|
|
const fps = numerator / denominator;
|
|
return Number.isFinite(fps) && fps > 0 ? fps : fallback;
|
|
}
|
|
|
|
interface ClipTemplateContext {
|
|
template: string;
|
|
title: string;
|
|
vodId: string;
|
|
channel: string;
|
|
date: Date;
|
|
part: number;
|
|
partPadded: string;
|
|
trimStartSec: number;
|
|
trimEndSec: number;
|
|
trimLengthSec: number;
|
|
fullLengthSec: number;
|
|
}
|
|
|
|
function renderClipFilenameTemplate(context: ClipTemplateContext): string {
|
|
const baseDate = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
|
|
let rendered = context.template
|
|
.replace(/\{title\}/g, sanitizeFilenamePart(context.title, 'untitled'))
|
|
.replace(/\{id\}/g, sanitizeFilenamePart(context.vodId, 'unknown'))
|
|
.replace(/\{channel\}/g, sanitizeFilenamePart(context.channel, 'unknown'))
|
|
.replace(/\{channel_id\}/g, '')
|
|
.replace(/\{date\}/g, baseDate)
|
|
.replace(/\{part\}/g, String(context.part))
|
|
.replace(/\{part_padded\}/g, context.partPadded)
|
|
.replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec))
|
|
.replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec))
|
|
.replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec))
|
|
.replace(/\{length\}/g, formatDurationDashed(context.fullLengthSec))
|
|
.replace(/\{ext\}/g, 'mp4')
|
|
.replace(/\{random_string\}/g, Math.random().toString(36).slice(2, 10));
|
|
|
|
rendered = rendered.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatDateWithPattern(context.date, pattern), 'date');
|
|
});
|
|
rendered = rendered.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimStartSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimEndSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimLengthSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.fullLengthSec, pattern), '00-00-00');
|
|
});
|
|
|
|
const parts = rendered
|
|
.split(/[\\/]+/)
|
|
.map((segment) => sanitizeFilenamePart(segment, 'unnamed'))
|
|
.filter((segment) => segment !== '.' && segment !== '..');
|
|
|
|
if (parts.length === 0) {
|
|
return 'clip.mp4';
|
|
}
|
|
|
|
const lastIdx = parts.length - 1;
|
|
if (!/\.[A-Za-z0-9]{1,8}$/.test(parts[lastIdx])) {
|
|
parts[lastIdx] = `${parts[lastIdx]}.mp4`;
|
|
}
|
|
|
|
return path.join(...parts);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function formatSpeed(bytesPerSec: number): string {
|
|
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s';
|
|
if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + ' KB/s';
|
|
return (bytesPerSec / (1024 * 1024)).toFixed(1) + ' MB/s';
|
|
}
|
|
|
|
function formatETA(seconds: number): string {
|
|
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
return `${h}h ${m}m`;
|
|
}
|
|
|
|
function getFreeDiskBytes(targetPath: string): number | null {
|
|
try {
|
|
const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync;
|
|
if (!statfsSync) {
|
|
return null;
|
|
}
|
|
|
|
const info = statfsSync(targetPath);
|
|
const blockSize = Number(info?.bsize || info?.frsize || 0);
|
|
const availableBlocks = Number(info?.bavail || 0);
|
|
if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.floor(blockSize * availableBlocks);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function estimateRequiredDownloadBytes(item: QueueItem): number {
|
|
const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s'));
|
|
|
|
const bytesPerSecondByMode: Record<PerformanceMode, number> = {
|
|
stability: 900 * 1024,
|
|
balanced: 700 * 1024,
|
|
speed: 550 * 1024
|
|
};
|
|
|
|
const mode = normalizePerformanceMode(config.performance_mode);
|
|
const baseEstimate = durationSeconds * bytesPerSecondByMode[mode];
|
|
const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35));
|
|
|
|
return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024));
|
|
}
|
|
|
|
function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult {
|
|
const freeBytes = getFreeDiskBytes(targetPath);
|
|
if (freeBytes === null) {
|
|
appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context });
|
|
return { success: true };
|
|
}
|
|
|
|
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
|
const message = tBackend('diskSpaceShortFor', { context, free: formatBytes(freeBytes), required: formatBytes(requiredBytes) });
|
|
appendDebugLog('disk-space-check-failed', {
|
|
targetPath,
|
|
requiredBytes,
|
|
freeBytes,
|
|
context
|
|
});
|
|
return { success: false, error: message };
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
function getMetadataCacheTtlMs(): number {
|
|
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
|
|
}
|
|
|
|
function getCachedValue<T>(cache: Map<string, CacheEntry<T>>, key: string): T | undefined {
|
|
const cached = cache.get(key);
|
|
if (!cached) {
|
|
return undefined;
|
|
}
|
|
|
|
if (cached.expiresAt <= Date.now()) {
|
|
cache.delete(key);
|
|
return undefined;
|
|
}
|
|
|
|
cache.delete(key);
|
|
cache.set(key, cached);
|
|
return cached.value;
|
|
}
|
|
|
|
function pruneExpiredCacheEntries<T>(cache: Map<string, CacheEntry<T>>): number {
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
|
|
for (const [key, entry] of cache.entries()) {
|
|
if (entry.expiresAt <= now) {
|
|
cache.delete(key);
|
|
removed += 1;
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function enforceCacheEntryLimit<T>(cache: Map<string, CacheEntry<T>>, maxEntries: number): number {
|
|
if (maxEntries <= 0) {
|
|
const removed = cache.size;
|
|
cache.clear();
|
|
return removed;
|
|
}
|
|
|
|
let removed = 0;
|
|
while (cache.size > maxEntries) {
|
|
const oldest = cache.keys().next().value as string | undefined;
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
cache.delete(oldest);
|
|
removed += 1;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function setCachedValue<T>(
|
|
cache: Map<string, CacheEntry<T>>,
|
|
key: string,
|
|
value: T,
|
|
maxEntries: number
|
|
): void {
|
|
cache.set(key, {
|
|
value,
|
|
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
|
});
|
|
|
|
if (cache.size > maxEntries) {
|
|
pruneExpiredCacheEntries(cache);
|
|
enforceCacheEntryLimit(cache, maxEntries);
|
|
}
|
|
}
|
|
|
|
function cleanupMetadataCaches(reason: 'interval' | 'manual' | 'shutdown'): void {
|
|
const before = {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
};
|
|
|
|
const expired = {
|
|
loginToUserId: pruneExpiredCacheEntries(loginToUserIdCache),
|
|
vodList: pruneExpiredCacheEntries(vodListCache),
|
|
clipInfo: pruneExpiredCacheEntries(clipInfoCache)
|
|
};
|
|
|
|
const evicted = {
|
|
loginToUserId: enforceCacheEntryLimit(loginToUserIdCache, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES),
|
|
vodList: enforceCacheEntryLimit(vodListCache, MAX_VOD_LIST_CACHE_ENTRIES),
|
|
clipInfo: enforceCacheEntryLimit(clipInfoCache, MAX_CLIP_INFO_CACHE_ENTRIES)
|
|
};
|
|
|
|
const removedTotal =
|
|
expired.loginToUserId + expired.vodList + expired.clipInfo +
|
|
evicted.loginToUserId + evicted.vodList + evicted.clipInfo;
|
|
|
|
if (removedTotal > 0) {
|
|
appendDebugLog('metadata-cache-cleanup', {
|
|
reason,
|
|
before,
|
|
after: {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
},
|
|
expired,
|
|
evicted,
|
|
removedTotal
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearMetadataCaches(): void {
|
|
loginToUserIdCache.clear();
|
|
vodListCache.clear();
|
|
clipInfoCache.clear();
|
|
}
|
|
|
|
function startMetadataCacheCleanup(): void {
|
|
if (cacheCleanupTimer) {
|
|
return;
|
|
}
|
|
|
|
cacheCleanupTimer = setInterval(() => {
|
|
cleanupMetadataCaches('interval');
|
|
}, CACHE_CLEANUP_INTERVAL_MS);
|
|
|
|
cacheCleanupTimer.unref?.();
|
|
}
|
|
|
|
function stopMetadataCacheCleanup(): void {
|
|
if (!cacheCleanupTimer) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(cacheCleanupTimer);
|
|
cacheCleanupTimer = null;
|
|
}
|
|
|
|
function withInFlightDedup<T>(
|
|
store: Map<string, Promise<T>>,
|
|
key: string,
|
|
factory: () => Promise<T>
|
|
): Promise<T> {
|
|
const existing = store.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const requestPromise: Promise<T> = factory().finally(() => {
|
|
if (store.get(key) === requestPromise) {
|
|
store.delete(key);
|
|
}
|
|
});
|
|
|
|
store.set(key, requestPromise);
|
|
return requestPromise;
|
|
}
|
|
|
|
function getRetryAttemptLimit(): number {
|
|
switch (normalizePerformanceMode(config.performance_mode)) {
|
|
case 'stability':
|
|
return 5;
|
|
case 'speed':
|
|
return 2;
|
|
case 'balanced':
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
function classifyDownloadError(errorMessage: string): RetryErrorClass {
|
|
const text = (errorMessage || '').toLowerCase();
|
|
if (!text) return 'unknown';
|
|
|
|
if (text.includes('ungültige vod-url') || text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
|
|
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
|
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth';
|
|
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network';
|
|
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
|
if (text.includes('integrität') || text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
|
|
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
|
|
// Twitch-spezifische streamlink errors:
|
|
// "error: No playable streams found on this URL" — VOD weg / private / sub-only
|
|
// "error: Could not find any kind of stream" — gleich
|
|
// "error: Unable to validate session token" — Twitch-API rejected
|
|
// "error: Unable to fetch access token" — Auth pre-flight failed
|
|
if (text.includes('no playable streams') || text.includes('could not find any kind of stream')) return 'validation';
|
|
if (text.includes('access token') || text.includes('session token') || text.includes('signature') || text.includes('integrity token')) return 'auth';
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): number {
|
|
const jitter = Math.floor(Math.random() * 3);
|
|
|
|
switch (errorClass) {
|
|
case 'rate_limit':
|
|
return Math.min(45, 10 + attempt * 6 + jitter);
|
|
case 'network':
|
|
return Math.min(30, 4 * attempt + jitter);
|
|
case 'auth':
|
|
return Math.min(40, 8 + attempt * 5 + jitter);
|
|
case 'integrity':
|
|
return Math.min(20, 3 + attempt * 2 + jitter);
|
|
case 'io':
|
|
return Math.min(25, 5 + attempt * 3 + jitter);
|
|
case 'tooling':
|
|
return DEFAULT_RETRY_DELAY_SECONDS;
|
|
case 'validation':
|
|
return 0;
|
|
case 'unknown':
|
|
default:
|
|
return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter);
|
|
}
|
|
}
|
|
|
|
function getQueueCounts(queueData: QueueItem[] = downloadQueue): RuntimeMetricsSnapshot['queue'] {
|
|
const counts = {
|
|
pending: 0,
|
|
downloading: 0,
|
|
paused: 0,
|
|
completed: 0,
|
|
error: 0,
|
|
total: queueData.length
|
|
};
|
|
|
|
for (const item of queueData) {
|
|
if (item.status === 'pending') counts.pending += 1;
|
|
else if (item.status === 'downloading') counts.downloading += 1;
|
|
else if (item.status === 'paused') counts.paused += 1;
|
|
else if (item.status === 'completed') counts.completed += 1;
|
|
else if (item.status === 'error') counts.error += 1;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
function generateQueueItemId(): string {
|
|
queueIdCounter = (queueIdCounter + 1) % 1000;
|
|
return `${Date.now()}-${queueIdCounter}`;
|
|
}
|
|
|
|
function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): string {
|
|
return queueData.map((item) => [
|
|
item.id,
|
|
item.status,
|
|
Math.round((Number(item.progress) || 0) * 10),
|
|
item.currentPart || 0,
|
|
item.totalParts || 0,
|
|
item.speed || '',
|
|
item.eta || '',
|
|
item.last_error || '',
|
|
item.recordingHealth || ''
|
|
].join(':')).join('|');
|
|
}
|
|
|
|
function emitQueueUpdated(force = false): void {
|
|
if (!downloadQueue.some((item) => item.status === 'pending')) clearDownloadPolicyWakeTimer();
|
|
emitDownloadPolicyStatus();
|
|
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
|
|
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
|
|
return;
|
|
}
|
|
|
|
lastQueueBroadcastFingerprint = nextFingerprint;
|
|
rememberQueueFilePaths(downloadQueue);
|
|
mainWindow?.webContents.send('queue-updated', downloadQueue);
|
|
updateTaskbarProgress();
|
|
}
|
|
|
|
// Per-item taskbar progress is tracked here because main's downloadQueue
|
|
// items don't update their .progress field mid-download (only the renderer
|
|
// gets a stream of progress events). Map is cleared in processOneQueueItem.finally.
|
|
const activeDownloadProgress = new Map<string, number>();
|
|
|
|
function recordDownloadProgress(progress: DownloadProgress): void {
|
|
if (queuePaused) return;
|
|
const p = Number(progress.progress);
|
|
const item = downloadQueue.find((candidate) => candidate.id === progress.id);
|
|
if (item) mergeQueueProgressState(item, progress, false);
|
|
const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3;
|
|
activeDownloadProgress.set(progress.id, fraction);
|
|
updateTaskbarProgress();
|
|
}
|
|
|
|
function clearDownloadProgress(itemId: string): void {
|
|
activeDownloadProgress.delete(itemId);
|
|
updateTaskbarProgress();
|
|
}
|
|
|
|
// Aggregate progress across all currently-downloading items, mapped to the
|
|
// Windows taskbar progress indicator (-1 = no progress, 0..1 = fraction).
|
|
// Visible whenever the user has minimised / collapsed the window. Indeterminate
|
|
// downloads (no percentage yet) report a 30% bar so the taskbar still shows
|
|
// activity instead of going cold.
|
|
function updateTaskbarProgress(): void {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
const entries = Array.from(activeDownloadProgress.values());
|
|
if (entries.length === 0) {
|
|
try { mainWindow.setProgressBar(-1); } catch { /* unsupported on some platforms */ }
|
|
return;
|
|
}
|
|
const avg = entries.reduce((s, v) => s + v, 0) / entries.length;
|
|
try { mainWindow.setProgressBar(Math.max(0, Math.min(1, avg))); } catch { /* ignore */ }
|
|
}
|
|
|
|
function hasQueueItemId(id: string): boolean {
|
|
return downloadQueue.some((item) => item.id === id);
|
|
}
|
|
|
|
function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot {
|
|
return {
|
|
...runtimeMetrics,
|
|
timestamp: new Date().toISOString(),
|
|
queue: getQueueCounts(downloadQueue),
|
|
caches: {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
},
|
|
config: {
|
|
performanceMode: normalizePerformanceMode(config.performance_mode),
|
|
smartScheduler: config.smart_queue_scheduler !== false,
|
|
metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes),
|
|
duplicatePrevention: config.prevent_duplicate_downloads !== false
|
|
}
|
|
};
|
|
}
|
|
|
|
function getQueueItemFingerprint(item: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): string {
|
|
return canonicalQueueItemIdentity(item);
|
|
}
|
|
|
|
function isQueueItemActive(item: QueueItem): boolean {
|
|
return item.status === 'pending' || item.status === 'downloading' || item.status === 'paused';
|
|
}
|
|
|
|
function hasActiveDuplicate(candidate: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): boolean {
|
|
const candidateFingerprint = getQueueItemFingerprint(candidate);
|
|
|
|
return downloadQueue.some((existing) => {
|
|
if (!isQueueItemActive(existing)) return false;
|
|
return getQueueItemFingerprint(existing) === candidateFingerprint;
|
|
});
|
|
}
|
|
|
|
function getQueuePriorityScore(item: QueueItem): number {
|
|
const now = Date.now();
|
|
const createdMs = getQueueCreatedAtMs(item, now);
|
|
const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000));
|
|
const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s'));
|
|
const clipBoost = item.customClip ? 1500 : 0;
|
|
const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5;
|
|
const ageBoost = Math.min(waitSeconds, 1800) / 2;
|
|
|
|
return clipBoost + shortJobBoost + ageBoost;
|
|
}
|
|
|
|
function pickNextPendingQueueItem(): QueueItem | null {
|
|
const pendingItems = downloadQueue.filter((item) => item.status === 'pending');
|
|
if (!pendingItems.length) return null;
|
|
|
|
if (!config.smart_queue_scheduler) {
|
|
return pendingItems[0];
|
|
}
|
|
|
|
let best = pendingItems[0];
|
|
let bestScore = getQueuePriorityScore(best);
|
|
|
|
for (let i = 1; i < pendingItems.length; i += 1) {
|
|
const candidate = pendingItems[i];
|
|
const score = getQueuePriorityScore(candidate);
|
|
if (score > bestScore) {
|
|
best = candidate;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
function parseClockDurationSeconds(duration: string | null): number | null {
|
|
if (!duration) return null;
|
|
const parts = duration.split(':').map((part) => Number(part));
|
|
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
|
|
return null;
|
|
}
|
|
|
|
return Math.max(0, Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2]));
|
|
}
|
|
|
|
function probeMediaFile(filePath: string): { durationSeconds: number; hasVideo: boolean } | null {
|
|
try {
|
|
const ffprobePath = getFFprobePath();
|
|
if (!canExecuteCommand(ffprobePath, ['-version'])) {
|
|
return null;
|
|
}
|
|
|
|
recordManagedToolExecution('ffprobe', ffprobePath);
|
|
const res = spawnSync(ffprobePath, [
|
|
'-v', 'error',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
filePath
|
|
], {
|
|
windowsHide: true,
|
|
encoding: 'utf-8'
|
|
});
|
|
|
|
if (res.status !== 0 || !res.stdout) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(res.stdout) as {
|
|
format?: { duration?: string };
|
|
streams?: Array<{ codec_type?: string }>;
|
|
};
|
|
|
|
const durationSeconds = Number(parsed?.format?.duration || 0);
|
|
const hasVideo = Boolean(parsed?.streams?.some((stream) => stream.codec_type === 'video'));
|
|
|
|
return {
|
|
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
|
|
hasVideo
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function validateDownloadedFileIntegrity(filePath: string, expectedDurationSeconds: number | null): DownloadResult {
|
|
const probed = probeMediaFile(filePath);
|
|
if (!probed) {
|
|
appendDebugLog('integrity-probe-skipped', { filePath });
|
|
return { success: true };
|
|
}
|
|
|
|
if (!probed.hasVideo) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return { success: false, error: tBackend('integrityNoVideo') };
|
|
}
|
|
|
|
if (probed.durationSeconds <= 1) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return { success: false, error: tBackend('integrityTooShort', { duration: probed.durationSeconds.toFixed(2) }) };
|
|
}
|
|
|
|
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
|
|
const minExpected = Math.max(2, expectedDurationSeconds * 0.45);
|
|
if (probed.durationSeconds < minExpected) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return {
|
|
success: false,
|
|
error: tBackend('integrityDurationMismatch', { actual: probed.durationSeconds.toFixed(1), expected: String(expectedDurationSeconds) })
|
|
};
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ==========================================
|
|
// TWITCH API
|
|
// ==========================================
|
|
async function ensureTwitchAuth(forceRefresh = false): Promise<string | null> {
|
|
return await twitchAppTokenService.ensure({
|
|
clientId: config.client_id,
|
|
clientSecret: twitchClientSecret,
|
|
}, forceRefresh);
|
|
}
|
|
|
|
const TWITCH_GQL_RETRY_ATTEMPTS = 3;
|
|
|
|
async function fetchPublicTwitchGqlOutcome<T>(query: string, variables: Record<string, unknown>): Promise<RefreshOutcome<T>> {
|
|
const outcome = await requestPublicTwitchGraphql<T>(
|
|
axios,
|
|
query,
|
|
variables,
|
|
API_TIMEOUT,
|
|
TWITCH_GQL_RETRY_ATTEMPTS,
|
|
);
|
|
if (outcome.status === 'unavailable') appendDebugLog('public-gql-failed');
|
|
return outcome;
|
|
}
|
|
|
|
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
|
|
const outcome = await fetchPublicTwitchGqlOutcome<T>(query, variables);
|
|
return outcome.status === 'success' ? outcome.value : null;
|
|
}
|
|
|
|
async function getPublicUserId(username: string): Promise<string | null> {
|
|
const login = normalizeLogin(username);
|
|
if (!login) return null;
|
|
|
|
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (cachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedUserId;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
type UserQueryResult = { user: { id: string; login: string } | null };
|
|
const data = await fetchPublicTwitchGql<UserQueryResult>(
|
|
'query($login:String!){ user(login:$login){ id login } }',
|
|
{ login }
|
|
);
|
|
|
|
const user = data?.user;
|
|
if (!user?.id) return null;
|
|
|
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
|
setUserIdLogin(user.id, user.login || login);
|
|
return user.id;
|
|
}
|
|
|
|
async function getPublicVODsByLogin(loginName: string): Promise<RefreshOutcome<VOD[]>> {
|
|
const login = normalizeLogin(loginName);
|
|
if (!login) return { status: 'not-found' };
|
|
return await requestPublicTwitchVodsByLogin(axios, login, 100, API_TIMEOUT, TWITCH_GQL_RETRY_ATTEMPTS);
|
|
}
|
|
|
|
async function getUserId(username: string): Promise<string | null> {
|
|
const login = normalizeLogin(username);
|
|
if (!login) return null;
|
|
|
|
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (cachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedUserId;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightUserIdRequests, login, async () => {
|
|
const refreshedCachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (refreshedCachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedUserId;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
const getUserViaPublicApi = async () => {
|
|
return await getPublicUserId(login);
|
|
};
|
|
|
|
let twitchAccessToken = await ensureTwitchAuth();
|
|
if (!twitchAccessToken) return await getUserViaPublicApi();
|
|
let outcome = await requestTwitchHelixUsers(axios, login, {
|
|
clientId: config.client_id,
|
|
accessToken: twitchAccessToken,
|
|
}, API_TIMEOUT);
|
|
if (outcome.status === 'unauthorized') {
|
|
twitchAccessToken = await ensureTwitchAuth(true);
|
|
if (twitchAccessToken) {
|
|
outcome = await requestTwitchHelixUsers(axios, login, {
|
|
clientId: config.client_id,
|
|
accessToken: twitchAccessToken,
|
|
}, API_TIMEOUT);
|
|
}
|
|
}
|
|
if (outcome.status !== 'success' || !outcome.value[0]) return await getUserViaPublicApi();
|
|
const user = outcome.value[0];
|
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
|
setUserIdLogin(user.id, user.login || login);
|
|
return user.id;
|
|
});
|
|
}
|
|
|
|
async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
|
|
const cacheKey = `user:${userId}`;
|
|
if (!forceRefresh) {
|
|
const cachedVods = getCachedValue(vodListCache, cacheKey);
|
|
if (cachedVods !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedVods;
|
|
}
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightVodRequests, cacheKey, async () => {
|
|
if (!forceRefresh) {
|
|
const refreshedCachedVods = getCachedValue(vodListCache, cacheKey);
|
|
if (refreshedCachedVods !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedVods;
|
|
}
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
let twitchAccessToken = await ensureTwitchAuth();
|
|
const refreshed = await refreshTwitchProviderData(
|
|
userId,
|
|
vodListLastGood.get(cacheKey),
|
|
{
|
|
requestHelix: async () => {
|
|
if (!twitchAccessToken) return { status: 'unavailable' };
|
|
return await requestTwitchHelixVideos(axios, userId, {
|
|
clientId: config.client_id,
|
|
accessToken: twitchAccessToken,
|
|
}, API_TIMEOUT);
|
|
},
|
|
refreshToken: async () => {
|
|
twitchAccessToken = await ensureTwitchAuth(true);
|
|
return twitchAccessToken !== null;
|
|
},
|
|
requestPublic: async () => {
|
|
const login = userIdLoginCache.get(userId);
|
|
return login ? await getPublicVODsByLogin(login) : { status: 'unavailable' };
|
|
},
|
|
},
|
|
);
|
|
if (refreshed.source === 'helix' || refreshed.source === 'public') {
|
|
const vods = refreshed.value ?? [];
|
|
const login = vods[0]?.user_login;
|
|
if (login) setUserIdLogin(userId, normalizeLogin(login));
|
|
vodListLastGood.set(cacheKey, vods);
|
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
|
return vods;
|
|
}
|
|
if (refreshed.source === 'not-found') {
|
|
vodListLastGood.delete(cacheKey);
|
|
vodListCache.delete(cacheKey);
|
|
return [];
|
|
}
|
|
if (refreshed.source === 'last-good') appendDebugLog('vod-refresh-kept-last-good', { userId });
|
|
return refreshed.value ?? [];
|
|
});
|
|
}
|
|
|
|
interface LiveStreamInfo {
|
|
isLive: boolean;
|
|
title?: string;
|
|
gameName?: string;
|
|
}
|
|
|
|
// Returns whether the streamer is currently live + a little metadata if
|
|
// available. Tries Helix first (better data), falls back to public GQL when
|
|
// the user has no client_id/secret configured. A `null` return means we
|
|
// couldn't determine — caller should treat as "best-effort".
|
|
async function getLiveStreamInfo(login: string): Promise<LiveStreamInfo | null> {
|
|
const normalized = normalizeLogin(login);
|
|
if (!normalized) return null;
|
|
|
|
const twitchAccessToken = await ensureTwitchAuth();
|
|
if (twitchAccessToken) {
|
|
try {
|
|
const response = await axios.get('https://api.twitch.tv/helix/streams', {
|
|
params: { user_login: normalized, first: 1 },
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${twitchAccessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
const entries = response.data?.data || [];
|
|
if (entries.length === 0) return { isLive: false };
|
|
const e = entries[0];
|
|
return {
|
|
isLive: e.type === 'live',
|
|
title: typeof e.title === 'string' ? e.title : undefined,
|
|
gameName: typeof e.game_name === 'string' ? e.game_name : undefined
|
|
};
|
|
} catch (e) {
|
|
appendDebugLog('helix-streams-failed', { login: normalized, error: projectExternalError('twitch-helix-streams', e) });
|
|
// fall through to public GQL
|
|
}
|
|
}
|
|
|
|
type StreamQueryResult = {
|
|
user: {
|
|
stream: { id: string; type: string; title?: string; game?: { name?: string } } | null;
|
|
} | null;
|
|
};
|
|
const data = await fetchPublicTwitchGql<StreamQueryResult>(
|
|
'query($login:String!){ user(login:$login){ stream{ id type title game{ name } } } }',
|
|
{ login: normalized }
|
|
);
|
|
if (!data) return null;
|
|
const stream = data.user?.stream;
|
|
if (!stream) return { isLive: false };
|
|
return {
|
|
isLive: stream.type === 'live',
|
|
title: stream.title,
|
|
gameName: stream.game?.name
|
|
};
|
|
}
|
|
|
|
// ==========================================
|
|
// STREAMER PROFILE — display-name, avatar, follower count, etc.
|
|
// ==========================================
|
|
// User-facing channel header data. Combines Helix /users (display name,
|
|
// avatar, bio, broadcaster type), public GQL (follower total — Helix
|
|
// requires moderator scope we don't have), the already-cached VOD list
|
|
// (vodCount + lastStreamAt come for free), and the live-status cache
|
|
// (isLive + currentTitle + currentGame). Cached for 30 min per login.
|
|
interface StreamerProfile {
|
|
login: string;
|
|
displayName: string;
|
|
avatarUrl: string;
|
|
bannerUrl: string;
|
|
description: string;
|
|
broadcasterType: '' | 'partner' | 'affiliate';
|
|
followerCount: number | null;
|
|
vodCount: number;
|
|
lastStreamAt: string | null;
|
|
isLive: boolean;
|
|
currentTitle: string | null;
|
|
currentGame: string | null;
|
|
currentStreamPreviewUrl: string;
|
|
currentStreamViewers: number | null;
|
|
twitchUrl: string;
|
|
fetchedAt: number;
|
|
}
|
|
|
|
const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512;
|
|
const streamerProfileCache = new Map<string, CacheEntry<StreamerProfile>>();
|
|
const streamerProfileLastGood = new LastGoodCache<StreamerProfile>(MAX_STREAMER_PROFILE_CACHE_ENTRIES);
|
|
const inFlightProfileRequests = new Map<string, Promise<StreamerProfile | null>>();
|
|
|
|
// Avatar bytes get embedded as data URLs in the profile so the renderer
|
|
// doesn't have to do its own HTTPS fetch (Electron's renderer img loader
|
|
// has a habit of failing silently against the Twitch CDN — undocumented,
|
|
// but reproducibly: the same URL works in DevTools but not in the live
|
|
// page). Cached by source URL so a single avatar change across multiple
|
|
// streamer entries only downloads once.
|
|
const avatarDataUrlCache = new Map<string, string>();
|
|
const MAX_AVATAR_DATA_URL_CACHE = 256;
|
|
|
|
async function fetchAvatarAsDataUrl(url: string): Promise<string> {
|
|
if (!url) return '';
|
|
const cached = avatarDataUrlCache.get(url);
|
|
if (cached !== undefined) return cached;
|
|
try {
|
|
const response = await axios.get<ArrayBuffer>(url, {
|
|
responseType: 'arraybuffer',
|
|
timeout: 8000,
|
|
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
|
|
});
|
|
const buf = Buffer.from(response.data);
|
|
// Twitch CDN almost always serves PNG or JPEG. Detect from the
|
|
// response content-type when available, fall back to PNG which is
|
|
// the default for profile_image_url.
|
|
const contentType = (response.headers['content-type'] as string | undefined)?.split(';')[0]?.trim() || 'image/png';
|
|
const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`;
|
|
avatarDataUrlCache.set(url, dataUrl);
|
|
if (avatarDataUrlCache.size > MAX_AVATAR_DATA_URL_CACHE) {
|
|
// FIFO eviction — Map preserves insertion order.
|
|
const firstKey = avatarDataUrlCache.keys().next().value as string | undefined;
|
|
if (firstKey) avatarDataUrlCache.delete(firstKey);
|
|
}
|
|
return dataUrl;
|
|
} catch (e) {
|
|
appendDebugLog('avatar-fetch-failed', { url, error: String(e) });
|
|
return '';
|
|
}
|
|
}
|
|
|
|
type HelixUser = TwitchHelixUser;
|
|
|
|
async function fetchHelixUserInfo(login: string): Promise<RefreshOutcome<HelixUser>> {
|
|
let twitchAccessToken = await ensureTwitchAuth();
|
|
if (!twitchAccessToken) return { status: 'unavailable' };
|
|
let outcome = await requestTwitchHelixUsers(axios, login, {
|
|
clientId: config.client_id,
|
|
accessToken: twitchAccessToken,
|
|
}, API_TIMEOUT);
|
|
if (outcome.status === 'unauthorized') {
|
|
twitchAccessToken = await ensureTwitchAuth(true);
|
|
if (!twitchAccessToken) return { status: 'unavailable' };
|
|
outcome = await requestTwitchHelixUsers(axios, login, {
|
|
clientId: config.client_id,
|
|
accessToken: twitchAccessToken,
|
|
}, API_TIMEOUT);
|
|
}
|
|
if (outcome.status === 'unauthorized' || outcome.status === 'unavailable') return { status: 'unavailable' };
|
|
if (outcome.status === 'not-found') return outcome;
|
|
return { status: 'success', value: outcome.value[0] };
|
|
}
|
|
|
|
interface PublicProfileQueryResult {
|
|
user: {
|
|
id: string;
|
|
login: string;
|
|
displayName: string;
|
|
description: string | null;
|
|
profileImageURL: string | null;
|
|
bannerImageURL: string | null;
|
|
roles?: { isPartner: boolean; isAffiliate: boolean } | null;
|
|
followers?: { totalCount: number } | null;
|
|
stream?: {
|
|
id: string;
|
|
type: string;
|
|
title: string | null;
|
|
viewersCount: number | null;
|
|
previewImageURL: string | null;
|
|
game: { name: string } | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
interface PublicStreamerProfileResult {
|
|
displayName: string;
|
|
avatarUrl: string;
|
|
bannerUrl: string;
|
|
description: string;
|
|
broadcasterType: '' | 'partner' | 'affiliate';
|
|
followerCount: number | null;
|
|
stream: PublicStreamInfo | null;
|
|
}
|
|
|
|
function getDownloadPolicyStatus(): DownloadPolicyStatus {
|
|
const hasPendingItems = downloadQueue.some((item) => item.status === 'pending');
|
|
const decision = decideDownloadStart(config.download_policy, new Date());
|
|
const waiting = hasPendingItems && !isDownloading && !queuePaused && !decision.allowed;
|
|
return {
|
|
waiting,
|
|
reason: waiting ? 'outside-window' : null,
|
|
nextStart: waiting ? decision.nextStart?.toISOString() ?? null : null,
|
|
};
|
|
}
|
|
|
|
function emitDownloadPolicyStatus(force = false): void {
|
|
const status = getDownloadPolicyStatus();
|
|
const fingerprint = JSON.stringify(status);
|
|
if (!force && fingerprint === lastDownloadPolicyStatusFingerprint) return;
|
|
lastDownloadPolicyStatusFingerprint = fingerprint;
|
|
mainWindow?.webContents.send('download-policy-status', status);
|
|
}
|
|
|
|
function clearDownloadPolicyWakeTimer(): void {
|
|
if (!downloadPolicyWakeTimer) return;
|
|
clearTimeout(downloadPolicyWakeTimer);
|
|
downloadPolicyWakeTimer = null;
|
|
}
|
|
|
|
function scheduleDownloadPolicyWake(nextStart: Date | null): void {
|
|
clearDownloadPolicyWakeTimer();
|
|
if (!nextStart || appShutdownStarted) return;
|
|
const delayMs = Math.max(0, nextStart.getTime() - Date.now());
|
|
downloadPolicyWakeTimer = setTimeout(() => {
|
|
downloadPolicyWakeTimer = null;
|
|
scheduleQueueProcessing();
|
|
}, delayMs);
|
|
}
|
|
|
|
function canStartDownloadQueue(manualOverride: boolean): boolean {
|
|
const decision = decideDownloadStart(config.download_policy, new Date(), manualOverride);
|
|
if (!decision.allowed) {
|
|
scheduleDownloadPolicyWake(decision.nextStart);
|
|
emitDownloadPolicyStatus(true);
|
|
return false;
|
|
}
|
|
clearDownloadPolicyWakeTimer();
|
|
emitDownloadPolicyStatus(true);
|
|
return true;
|
|
}
|
|
|
|
interface PublicDisplayNameQueryResult {
|
|
user: {
|
|
login: string;
|
|
displayName: string;
|
|
} | null;
|
|
}
|
|
|
|
async function getStreamerDisplayNames(logins: string[]): Promise<Record<string, string>> {
|
|
const normalizedLogins = [...new Set(logins.map((login) => normalizeLogin(login)).filter(Boolean))];
|
|
const displayNames = { ...config.streamer_display_names };
|
|
const missing = normalizedLogins;
|
|
let changed = false;
|
|
let nextIndex = 0;
|
|
|
|
const resolveNext = async (): Promise<void> => {
|
|
while (nextIndex < missing.length) {
|
|
const login = missing[nextIndex++];
|
|
const data = await fetchPublicTwitchGql<PublicDisplayNameQueryResult>(
|
|
'query($login: String!) { user(login: $login) { login displayName } }',
|
|
{ login }
|
|
);
|
|
const result = data?.user;
|
|
const resolvedLogin = result?.login ? normalizeLogin(result.login) : login;
|
|
const displayName = result?.displayName?.trim();
|
|
if (displayName && displayNames[resolvedLogin] !== displayName) {
|
|
displayNames[resolvedLogin] = displayName;
|
|
changed = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
await Promise.all(Array.from({ length: Math.min(4, missing.length) }, () => resolveNext()));
|
|
if (changed) {
|
|
config.streamer_display_names = displayNames;
|
|
saveConfig(config);
|
|
}
|
|
|
|
return normalizedLogins.reduce<Record<string, string>>((resolved, login) => {
|
|
const displayName = displayNames[login];
|
|
if (displayName) resolved[login] = displayName;
|
|
return resolved;
|
|
}, {});
|
|
}
|
|
|
|
interface PublicStreamInfo {
|
|
previewUrl: string;
|
|
viewers: number | null;
|
|
title: string | null;
|
|
game: string | null;
|
|
}
|
|
|
|
async function fetchPublicStreamerProfile(login: string): Promise<RefreshOutcome<PublicStreamerProfileResult>> {
|
|
// Same query also pulls bannerImageURL and the current stream's
|
|
// preview + viewer count when live — saves a separate roundtrip.
|
|
const outcome = await fetchPublicTwitchGqlOutcome<PublicProfileQueryResult>(
|
|
`query($login: String!) {
|
|
user(login: $login) {
|
|
id
|
|
login
|
|
displayName
|
|
description
|
|
profileImageURL(width: 150)
|
|
bannerImageURL
|
|
roles { isPartner isAffiliate }
|
|
followers { totalCount }
|
|
stream {
|
|
id
|
|
type
|
|
title
|
|
viewersCount
|
|
previewImageURL(width: 640, height: 360)
|
|
game { name }
|
|
}
|
|
}
|
|
}`,
|
|
{ login }
|
|
);
|
|
if (outcome.status !== 'success') return outcome;
|
|
const userOutcome = parseGraphqlUser(outcome.value);
|
|
if (userOutcome.status !== 'success') return userOutcome;
|
|
const user = userOutcome.value;
|
|
if (typeof user.id !== 'string'
|
|
|| typeof user.login !== 'string'
|
|
|| typeof user.displayName !== 'string'
|
|
|| (user.description !== null && typeof user.description !== 'string')
|
|
|| (user.profileImageURL !== null && typeof user.profileImageURL !== 'string')
|
|
|| (user.bannerImageURL !== null && typeof user.bannerImageURL !== 'string')) return { status: 'unavailable' };
|
|
const roles = isPlainObject(user.roles) ? user.roles : null;
|
|
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
|
|
? 'partner'
|
|
: (roles?.isAffiliate ? 'affiliate' : '');
|
|
const s = isPlainObject(user.stream) ? user.stream : null;
|
|
const game = s && isPlainObject(s.game) ? s.game : null;
|
|
const stream = (s && s.type === 'live') ? {
|
|
previewUrl: typeof s.previewImageURL === 'string' ? s.previewImageURL : '',
|
|
viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null,
|
|
title: typeof s.title === 'string' ? s.title : null,
|
|
game: typeof game?.name === 'string' ? game.name : null
|
|
} : null;
|
|
const followers = isPlainObject(user.followers) ? user.followers : null;
|
|
return { status: 'success', value: {
|
|
displayName: user.displayName || login,
|
|
avatarUrl: typeof user.profileImageURL === 'string' ? user.profileImageURL : '',
|
|
bannerUrl: typeof user.bannerImageURL === 'string' ? user.bannerImageURL : '',
|
|
description: typeof user.description === 'string' ? user.description : '',
|
|
broadcasterType,
|
|
followerCount: typeof followers?.totalCount === 'number' ? followers.totalCount : null,
|
|
stream
|
|
} };
|
|
}
|
|
|
|
async function getStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
|
|
const normalized = normalizeLogin(login);
|
|
if (!normalized) return null;
|
|
const previousProfile = streamerProfileLastGood.get(normalized);
|
|
|
|
if (!forceRefresh) {
|
|
const cached = getCachedValue(streamerProfileCache, normalized);
|
|
if (cached !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
// Public GQL is now the SOURCE for everything except some of the
|
|
// core text fields when Helix is authenticated — because public
|
|
// GQL is the only route that gives us the banner image + current
|
|
// stream preview in one shot, and skipping it would mean two
|
|
// extra roundtrips. Helix takes precedence for displayName /
|
|
// description (those fields are sometimes richer there).
|
|
let displayName = previousProfile?.displayName ?? normalized;
|
|
let avatarUrl = previousProfile?.avatarUrl ?? '';
|
|
let bannerUrl = previousProfile?.bannerUrl ?? '';
|
|
let description = previousProfile?.description ?? '';
|
|
let broadcasterType: '' | 'partner' | 'affiliate' = previousProfile?.broadcasterType ?? '';
|
|
let followerCount = previousProfile?.followerCount ?? null;
|
|
let vodCount = previousProfile?.vodCount ?? 0;
|
|
let lastStreamAt = previousProfile?.lastStreamAt ?? null;
|
|
let isLive = previousProfile?.isLive ?? false;
|
|
let currentTitle = previousProfile?.currentTitle ?? null;
|
|
let currentGame = previousProfile?.currentGame ?? null;
|
|
let currentStreamPreviewRemoteUrl = previousProfile?.currentStreamPreviewUrl ?? '';
|
|
let currentStreamViewers = previousProfile?.currentStreamViewers ?? null;
|
|
let publicLiveResolved = false;
|
|
let hasFreshSource = false;
|
|
|
|
const publicOutcome = await fetchPublicStreamerProfile(normalized);
|
|
if (publicOutcome.status === 'success') {
|
|
const publicProfile = publicOutcome.value;
|
|
hasFreshSource = true;
|
|
displayName = publicProfile.displayName;
|
|
avatarUrl = publicProfile.avatarUrl;
|
|
bannerUrl = publicProfile.bannerUrl;
|
|
description = publicProfile.description;
|
|
broadcasterType = publicProfile.broadcasterType;
|
|
followerCount = publicProfile.followerCount;
|
|
publicLiveResolved = true;
|
|
if (publicProfile.stream) {
|
|
isLive = true;
|
|
currentTitle = publicProfile.stream.title;
|
|
currentGame = publicProfile.stream.game;
|
|
currentStreamPreviewRemoteUrl = publicProfile.stream.previewUrl;
|
|
currentStreamViewers = publicProfile.stream.viewers;
|
|
} else {
|
|
isLive = false;
|
|
currentTitle = null;
|
|
currentGame = null;
|
|
currentStreamPreviewRemoteUrl = '';
|
|
currentStreamViewers = null;
|
|
}
|
|
}
|
|
|
|
const helixOutcome = await fetchHelixUserInfo(normalized);
|
|
if (helixOutcome.status === 'success') {
|
|
const helixUser = helixOutcome.value;
|
|
hasFreshSource = true;
|
|
displayName = helixUser.display_name || displayName;
|
|
if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
|
|
if (helixUser.description) description = helixUser.description;
|
|
const bt = (helixUser.broadcaster_type || '').toLowerCase();
|
|
if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt;
|
|
}
|
|
|
|
if (!hasFreshSource) {
|
|
if (publicOutcome.status === 'unavailable' && helixOutcome.status === 'unavailable' && previousProfile) {
|
|
appendDebugLog('profile-refresh-kept-last-good', { login: normalized });
|
|
return previousProfile;
|
|
}
|
|
if (publicOutcome.status === 'not-found' || helixOutcome.status === 'not-found') {
|
|
streamerProfileCache.delete(normalized);
|
|
streamerProfileLastGood.delete(normalized);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Derive vod count + last stream from the already-cached VOD list
|
|
// when we have an id. No extra network hit.
|
|
const userId = await getUserId(normalized);
|
|
if (userId) {
|
|
try {
|
|
const vods = await getVODs(userId);
|
|
vodCount = vods.length;
|
|
// VOD list is sorted by Twitch newest-first; pick element 0.
|
|
const newest = vods[0];
|
|
if (newest?.created_at) lastStreamAt = newest.created_at;
|
|
} catch (e) {
|
|
appendDebugLog('profile-vod-derive-failed', { login: normalized, error: String(e) });
|
|
}
|
|
}
|
|
|
|
if (!publicLiveResolved) {
|
|
try {
|
|
const live = await getLiveStreamInfo(normalized);
|
|
if (live) {
|
|
isLive = live.isLive;
|
|
currentTitle = live.title || null;
|
|
currentGame = live.gameName || null;
|
|
}
|
|
} catch (_) { /* best-effort */ }
|
|
}
|
|
|
|
// Embed the avatar AND banner bytes as data URLs in parallel.
|
|
// Renderer can't reliably fetch Twitch CDN images directly from
|
|
// an Electron renderer process, plus the data URL approach skips
|
|
// any CSP/referer/CORS quirks. Live preview also goes through
|
|
// this path — adds a cache-busting query string so a returning
|
|
// user gets a fresh frame each time the profile refreshes.
|
|
const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl
|
|
? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}`
|
|
: '';
|
|
const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([
|
|
/^https?:\/\//i.test(avatarUrl) ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''),
|
|
/^https?:\/\//i.test(bannerUrl) ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''),
|
|
/^https?:\/\//i.test(livePreviewUrlForFetch) ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('')
|
|
]);
|
|
|
|
const profile: StreamerProfile = {
|
|
login: normalized,
|
|
displayName,
|
|
avatarUrl: avatarDataUrl || avatarUrl,
|
|
bannerUrl: bannerDataUrl || bannerUrl,
|
|
description,
|
|
broadcasterType,
|
|
followerCount,
|
|
vodCount,
|
|
lastStreamAt,
|
|
isLive,
|
|
currentTitle,
|
|
currentGame,
|
|
currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl,
|
|
currentStreamViewers,
|
|
twitchUrl: `https://www.twitch.tv/${normalized}`,
|
|
fetchedAt: Date.now()
|
|
};
|
|
|
|
setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES);
|
|
streamerProfileLastGood.set(normalized, profile);
|
|
return profile;
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VOD STORYBOARD — animated hover preview
|
|
// ==========================================
|
|
// Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs
|
|
// containing N preview thumbnails covering the full length of the
|
|
// recording. We pull the JSON via public GQL (seekPreviewsURL), then
|
|
// hand the renderer the first high-quality sprite as a data URL plus
|
|
// the grid metadata. The renderer animates background-position across
|
|
// 4 cells to produce a scrub-preview effect on hover, twitch.tv-style.
|
|
interface VodStoryboard {
|
|
vodId: string;
|
|
spriteDataUrl: string;
|
|
frameDataUrls: string[];
|
|
frameWidth: number;
|
|
frameHeight: number;
|
|
cols: number;
|
|
rows: number;
|
|
cellWidth: number;
|
|
cellHeight: number;
|
|
framesInSprite: number;
|
|
}
|
|
|
|
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 12;
|
|
const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>();
|
|
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
|
|
|
|
interface StoryboardManifestEntry {
|
|
count: number;
|
|
width: number;
|
|
height: number;
|
|
cols: number;
|
|
rows: number;
|
|
images: string[];
|
|
quality: string;
|
|
interval: number;
|
|
}
|
|
|
|
async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
|
|
if (!vodId) return null;
|
|
|
|
const cached = getCachedValue(vodStoryboardCache, vodId);
|
|
if (cached !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cached;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => {
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
// Step 1: GQL gives us the seekPreviewsURL pointing at a JSON
|
|
// manifest. The manifest lists sprite images at multiple quality
|
|
// levels; we pick the high-quality first sprite (covers the
|
|
// beginning of the VOD with the most detail).
|
|
const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null; previewThumbnailURL: string | null } | null }>(
|
|
`query($id: ID!) { video(id: $id) { seekPreviewsURL previewThumbnailURL(width: 1920, height: 1080) } }`,
|
|
{ id: vodId }
|
|
);
|
|
const manifestUrl = data?.video?.seekPreviewsURL;
|
|
if (!manifestUrl) {
|
|
// Cache the negative result so a VOD without a storyboard
|
|
// (private/unlisted/expired) doesn't get re-queried on every
|
|
// subsequent hover.
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
let manifest: StoryboardManifestEntry[];
|
|
try {
|
|
const manifestResp = await axios.get<StoryboardManifestEntry[]>(manifestUrl, {
|
|
timeout: 6000,
|
|
responseType: 'json',
|
|
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
|
|
});
|
|
manifest = manifestResp.data;
|
|
} catch (e) {
|
|
appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) });
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
if (!Array.isArray(manifest) || manifest.length === 0) {
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
// Prefer the "high" quality entry — Twitch ships both "low" and
|
|
// "high" alongside each other. Falls back to whichever is present.
|
|
const entry = manifest.find((m) => m.quality === 'high') || manifest[0];
|
|
if (!entry?.images?.length) {
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
// The manifest URL points at e.g. .../storyboards/{vodId}-info.json
|
|
// and sprite filenames are relative (e.g. "{vodId}-high-0.jpg").
|
|
// Strip the JSON filename to get the base, then append the sprite.
|
|
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
|
|
const frameUrls = buildVodPreviewFrameUrls(data?.video?.previewThumbnailURL || '');
|
|
const frameDataUrls = (await Promise.all(frameUrls.map(async (url) => {
|
|
try {
|
|
const response = await axios.get<ArrayBuffer>(url, {
|
|
responseType: 'arraybuffer',
|
|
timeout: 8000,
|
|
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
|
|
});
|
|
const contentType = String(response.headers['content-type'] || 'image/jpeg').split(';')[0];
|
|
return `data:${contentType};base64,${Buffer.from(response.data).toString('base64')}`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}))).filter((url) => url.length > 0);
|
|
const firstSpriteUrl = baseUrl + entry.images[0];
|
|
const spriteDataUrl = frameDataUrls.length >= 2 ? '' : await fetchAvatarAsDataUrl(firstSpriteUrl);
|
|
if (frameDataUrls.length < 2 && !spriteDataUrl) {
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
const storyboard: VodStoryboard = {
|
|
vodId,
|
|
spriteDataUrl,
|
|
frameDataUrls: frameDataUrls.length >= 2 ? frameDataUrls : [],
|
|
frameWidth: frameDataUrls.length >= 2 ? 1920 : 0,
|
|
frameHeight: frameDataUrls.length >= 2 ? 1080 : 0,
|
|
cols: entry.cols,
|
|
rows: entry.rows,
|
|
cellWidth: entry.width,
|
|
cellHeight: entry.height,
|
|
framesInSprite: entry.cols * entry.rows
|
|
};
|
|
setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return storyboard;
|
|
});
|
|
}
|
|
|
|
async function getClipInfo(clipId: string): Promise<any | null> {
|
|
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (cachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedClip;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightClipRequests, clipId, async () => {
|
|
const refreshedCachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (refreshedCachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedClip;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
let twitchAccessToken = await ensureTwitchAuth();
|
|
if (!twitchAccessToken) return null;
|
|
|
|
const fetchClip = async () => {
|
|
return await axios.get('https://api.twitch.tv/helix/clips', {
|
|
params: { id: clipId },
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${twitchAccessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
};
|
|
|
|
try {
|
|
const response = await fetchClip();
|
|
const clip = response.data.data[0] || null;
|
|
if (clip) {
|
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
|
}
|
|
return clip;
|
|
} catch (e) {
|
|
const refreshedToken = axios.isAxiosError(e) && e.response?.status === 401
|
|
? await ensureTwitchAuth(true)
|
|
: null;
|
|
if (refreshedToken) {
|
|
twitchAccessToken = refreshedToken;
|
|
try {
|
|
const retryResponse = await fetchClip();
|
|
const clip = retryResponse.data.data[0] || null;
|
|
if (clip) {
|
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
|
}
|
|
return clip;
|
|
} catch (retryError) {
|
|
console.error('Error getting clip after relogin:', projectExternalError('twitch-helix-clips', retryError));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
console.error('Error getting clip:', projectExternalError('twitch-helix-clips', e));
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VIDEO INFO (for cutter)
|
|
// ==========================================
|
|
function isVideoEditorPreviewCompatible(filePath: string, videoCodec: string, audioCodec: string | null): boolean {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
const audioCompatible = !audioCodec || ['aac', 'mp3', 'opus', 'vorbis'].includes(audioCodec);
|
|
if (['.mp4', '.m4v', '.mov'].includes(extension)) return ['h264', 'av1', 'vp9'].includes(videoCodec) && audioCompatible;
|
|
if (extension === '.webm') return ['vp8', 'vp9', 'av1'].includes(videoCodec) && audioCompatible;
|
|
if (extension === '.mkv') return ['h264', 'av1', 'vp8', 'vp9'].includes(videoCodec) && audioCompatible;
|
|
return false;
|
|
}
|
|
|
|
function isSupportedVideoEditorInput(filePath: string): boolean {
|
|
return ['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi'].includes(path.extname(filePath).toLowerCase());
|
|
}
|
|
|
|
function normalizeCutterRotation(value: unknown): 0 | 90 | 180 | 270 {
|
|
const numeric = Number(value);
|
|
const normalized = ((numeric % 360) + 360) % 360;
|
|
return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
|
|
}
|
|
|
|
async function getVideoInfo(filePath: string, trackedProcesses?: Set<ChildProcess>, timeoutMs = 30000): Promise<VideoInfo | null> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('get-video-info-missing-ffmpeg');
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const ffprobe = getFFprobePath();
|
|
const args = [
|
|
'-v', 'quiet',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
filePath
|
|
];
|
|
|
|
recordManagedToolExecution('ffprobe', ffprobe);
|
|
const proc = spawn(ffprobe, args, { windowsHide: true });
|
|
trackedProcesses?.add(proc);
|
|
proc.stderr?.resume();
|
|
let output = '';
|
|
let resolved = false;
|
|
let forceResolveTimer: NodeJS.Timeout | null = null;
|
|
|
|
const resolveOnce = (value: VideoInfo | null): void => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
resolve(value);
|
|
};
|
|
|
|
const finish = (value: VideoInfo | null): void => {
|
|
clearTimeout(timeoutTimer);
|
|
if (forceResolveTimer) clearTimeout(forceResolveTimer);
|
|
trackedProcesses?.delete(proc);
|
|
resolveOnce(value);
|
|
};
|
|
|
|
const timeoutTimer = setTimeout(() => {
|
|
try { proc.kill(); } catch { }
|
|
forceResolveTimer = setTimeout(() => resolveOnce(null), 2000);
|
|
}, timeoutMs);
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
finish(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const info = JSON.parse(output);
|
|
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
|
|
const audioStreams = (Array.isArray(info.streams) ? info.streams : [])
|
|
.filter((stream: any) => stream?.codec_type === 'audio')
|
|
.map((stream: any, index: number) => ({
|
|
index,
|
|
codec: String(stream.codec_name || '').toLowerCase(),
|
|
channels: Number.isFinite(stream.channels) ? Number(stream.channels) : 0,
|
|
language: typeof stream.tags?.language === 'string' ? stream.tags.language : null,
|
|
}));
|
|
const audioStream = audioStreams[0] ?? null;
|
|
const duration = parseFloat(info.format?.duration || videoStream?.duration || '0');
|
|
const averageFps = parseFrameRate(videoStream?.avg_frame_rate);
|
|
const realFps = parseFrameRate(videoStream?.r_frame_rate);
|
|
const fps = averageFps > 0 ? averageFps : realFps;
|
|
|
|
if (!videoStream || !Number.isFinite(duration) || duration <= 0 || !videoStream.width || !videoStream.height || !Number.isFinite(fps) || fps <= 0) {
|
|
finish(null);
|
|
return;
|
|
}
|
|
|
|
const videoCodec = String(videoStream.codec_name || '').toLowerCase();
|
|
const audioCodec = audioStream?.codec ?? null;
|
|
const variableFrameRate = averageFps > 0 && realFps > 0 && Math.abs(averageFps - realFps) / Math.max(averageFps, realFps) > 0.005;
|
|
const rotationData = Array.isArray(videoStream.side_data_list)
|
|
? videoStream.side_data_list.find((entry: any) => Number.isFinite(Number(entry?.rotation)))?.rotation
|
|
: undefined;
|
|
finish({
|
|
duration,
|
|
width: videoStream.width,
|
|
height: videoStream.height,
|
|
fps,
|
|
hasAudio: Boolean(audioStream),
|
|
videoCodec,
|
|
audioCodec,
|
|
previewCompatible: isVideoEditorPreviewCompatible(filePath, videoCodec, audioCodec),
|
|
variableFrameRate,
|
|
rotation: normalizeCutterRotation(rotationData ?? videoStream.tags?.rotate),
|
|
audioStreams,
|
|
});
|
|
} catch {
|
|
finish(null);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => finish(null));
|
|
});
|
|
}
|
|
|
|
async function runCutterFfmpegProbe(args: string[], captureOutput: boolean): Promise<{ success: boolean; output: string }> {
|
|
if (appShutdownStarted) return { success: false, output: '' };
|
|
return await new Promise((resolve) => {
|
|
const ffmpegPath = getFFmpegPath();
|
|
recordManagedToolExecution('ffmpeg', ffmpegPath);
|
|
const proc = spawn(ffmpegPath, 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 [];
|
|
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 {
|
|
return await cutterHardwareEncoderProbe;
|
|
} catch {
|
|
cutterHardwareEncoderProbe = null;
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function cancelCutterMediaPreparation(): void {
|
|
cutterAssetRunGeneration += 1;
|
|
for (const process of currentCutterMediaProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}
|
|
|
|
function cancelCutterMetadataPreparation(): void {
|
|
for (const process of currentCutterProbeProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}
|
|
|
|
function cancelCutterPreviewPreparation(): void {
|
|
for (const process of currentCutterPreviewProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}
|
|
|
|
function cancelCutterWaveformPreparation(): void {
|
|
cutterWaveformGeneration += 1;
|
|
for (const process of currentCutterWaveformProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}
|
|
|
|
function runEditorMediaProcess(args: string[], runGeneration: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (runGeneration !== cutterAssetRunGeneration || appShutdownStarted) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const ffmpegPath = getFFmpegPath();
|
|
recordManagedToolExecution('ffmpeg', ffmpegPath);
|
|
const proc = spawn(ffmpegPath, args, { windowsHide: true });
|
|
currentCutterMediaProcesses.add(proc);
|
|
proc.stderr?.resume();
|
|
let settled = false;
|
|
const finish = (success: boolean): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
currentCutterMediaProcesses.delete(proc);
|
|
resolve(success && runGeneration === cutterAssetRunGeneration && !appShutdownStarted);
|
|
};
|
|
proc.on('close', (code) => finish(code === 0));
|
|
proc.on('error', () => finish(false));
|
|
});
|
|
}
|
|
|
|
function runEditorWaveformProcess(args: string[], runGeneration: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (runGeneration !== cutterWaveformGeneration || appShutdownStarted) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const ffmpegPath = getFFmpegPath();
|
|
recordManagedToolExecution('ffmpeg', ffmpegPath);
|
|
const proc = spawn(ffmpegPath, args, { windowsHide: true });
|
|
currentCutterWaveformProcesses.add(proc);
|
|
proc.stderr?.resume();
|
|
let settled = false;
|
|
const finish = (success: boolean): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
currentCutterWaveformProcesses.delete(proc);
|
|
resolve(success && runGeneration === cutterWaveformGeneration && !appShutdownStarted);
|
|
};
|
|
proc.on('close', (code) => finish(code === 0));
|
|
proc.on('error', () => finish(false));
|
|
});
|
|
}
|
|
|
|
function runEditorPreviewProcess(args: string[], requestGeneration: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (requestGeneration !== cutterMediaRequestGeneration || appShutdownStarted) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const ffmpegPath = getFFmpegPath();
|
|
recordManagedToolExecution('ffmpeg', ffmpegPath);
|
|
const proc = spawn(ffmpegPath, args, { windowsHide: true });
|
|
currentCutterPreviewProcesses.add(proc);
|
|
proc.stderr?.resume();
|
|
let settled = false;
|
|
const finish = (success: boolean): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
currentCutterPreviewProcesses.delete(proc);
|
|
resolve(success && requestGeneration === cutterMediaRequestGeneration && !appShutdownStarted);
|
|
};
|
|
proc.on('close', (code) => finish(code === 0));
|
|
proc.on('error', () => finish(false));
|
|
});
|
|
}
|
|
|
|
function removeCutterPreviewDirectory(directory: string | null): void {
|
|
if (!directory) return;
|
|
try { fs.rmSync(directory, { recursive: true, force: true }); } catch { }
|
|
}
|
|
|
|
function createVideoEditorPreview(filePath: string, info: VideoInfo, requestGeneration: number): Promise<{ sourceUrl: string; directory: string } | null> {
|
|
const directory = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-preview-${process.pid}-`));
|
|
const previewFile = path.join(directory, 'preview.mp4');
|
|
const copyVideo = ['h264', 'av1', 'vp9'].includes(info.videoCodec);
|
|
const copyAudio = !info.audioCodec || ['aac', 'mp3'].includes(info.audioCodec);
|
|
const args = ['-fflags', '+genpts', '-i', filePath, '-map', '0:v:0', '-map', '0:a:0?'];
|
|
if (copyVideo) args.push('-c:v', 'copy');
|
|
else args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '20', '-pix_fmt', 'yuv420p');
|
|
if (info.hasAudio) {
|
|
if (copyAudio) args.push('-c:a', 'copy');
|
|
else args.push('-c:a', 'aac', '-b:a', '160k');
|
|
} else {
|
|
args.push('-an');
|
|
}
|
|
args.push('-movflags', '+faststart', '-avoid_negative_ts', 'make_zero', '-y', previewFile);
|
|
return runEditorPreviewProcess(args, requestGeneration).then((success) => {
|
|
if (!success || !fs.existsSync(previewFile) || fs.statSync(previewFile).size <= 256) {
|
|
removeCutterPreviewDirectory(directory);
|
|
return null;
|
|
}
|
|
return { sourceUrl: pathToFileURL(previewFile).href, directory };
|
|
});
|
|
}
|
|
|
|
function readImageDataUrl(filePath: string): string | null {
|
|
if (!fs.existsSync(filePath)) return null;
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
const mediaType = extension === '.png' ? 'image/png' : 'image/jpeg';
|
|
return `data:${mediaType};base64,${fs.readFileSync(filePath).toString('base64')}`;
|
|
}
|
|
|
|
async function prepareVideoEditorMedia(filePath: string): Promise<VideoEditorMedia | null> {
|
|
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !isSupportedVideoEditorInput(filePath) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null;
|
|
const requestGeneration = ++cutterMediaRequestGeneration;
|
|
cancelCutterPreviewPreparation();
|
|
cancelCutterMetadataPreparation();
|
|
const identityBefore = getCutterInputIdentity(filePath);
|
|
if (!identityBefore) return null;
|
|
const info = await getVideoInfo(filePath, currentCutterProbeProcesses);
|
|
const identityAfter = getCutterInputIdentity(filePath);
|
|
if (!info || requestGeneration !== cutterMediaRequestGeneration || !cutterInputIdentitiesMatch(identityBefore, identityAfter) || appShutdownStarted) return null;
|
|
const preview = info.previewCompatible
|
|
? { sourceUrl: pathToFileURL(filePath).href, directory: null }
|
|
: await createVideoEditorPreview(filePath, info, requestGeneration);
|
|
if (!preview || requestGeneration !== cutterMediaRequestGeneration || !cutterInputIdentitiesMatch(identityBefore, getCutterInputIdentity(filePath)) || appShutdownStarted) {
|
|
removeCutterPreviewDirectory(preview?.directory || null);
|
|
return null;
|
|
}
|
|
cancelCutterMediaPreparation();
|
|
cancelCutterWaveformPreparation();
|
|
removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null);
|
|
const jobId = ++cutterMediaGeneration;
|
|
cutterMediaJob = { jobId, path: identityAfter!.path, identity: identityAfter!, info, waveform: null, waveformPromise: null, previewDirectory: preview.directory };
|
|
return {
|
|
sourceUrl: preview.sourceUrl,
|
|
info,
|
|
jobId,
|
|
thumbnails: [],
|
|
waveform: null,
|
|
};
|
|
}
|
|
|
|
async function prepareVideoEditorWaveform(filePath: string, jobId: number): Promise<VideoEditorWaveform | null> {
|
|
const job = cutterMediaJob;
|
|
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !Number.isInteger(jobId) || !job || jobId !== job.jobId || normalizeComparablePath(filePath) !== job.path || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), job.identity)) return null;
|
|
if (!job.info.hasAudio) return { jobId, waveform: null, pixelWidth: 32000, pixelHeight: 240 };
|
|
if (job.waveform) return job.waveform;
|
|
if (job.waveformPromise) return await job.waveformPromise;
|
|
const runGeneration = cutterWaveformGeneration;
|
|
const promise = (async (): Promise<VideoEditorWaveform | null> => {
|
|
const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-waveform-${process.pid}-`));
|
|
const waveformFile = path.join(tempDir, 'waveform.png');
|
|
try {
|
|
const success = await runEditorWaveformProcess([
|
|
'-i', filePath,
|
|
'-filter_complex', 'aformat=channel_layouts=mono,showwavespic=s=32000x240:colors=white',
|
|
'-frames:v', '1',
|
|
'-y', waveformFile,
|
|
], runGeneration);
|
|
if (!success || runGeneration !== cutterWaveformGeneration || cutterMediaJob !== job || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), job.identity) || appShutdownStarted) return null;
|
|
const waveform = readImageDataUrl(waveformFile);
|
|
if (!waveform) return null;
|
|
const result = { jobId, waveform, pixelWidth: 32000, pixelHeight: 240 };
|
|
job.waveform = result;
|
|
return result;
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})();
|
|
job.waveformPromise = promise;
|
|
const result = await promise;
|
|
if (cutterMediaJob === job && job.waveformPromise === promise) job.waveformPromise = null;
|
|
return result;
|
|
}
|
|
|
|
async function prepareVideoEditorAssets(filePath: string, jobId: number, profile: VideoEditorAssetProfile): Promise<VideoEditorAssets | null> {
|
|
if (appShutdownStarted || typeof filePath !== 'string' || !path.isAbsolute(filePath) || !Number.isInteger(jobId) || !profile || !Number.isFinite(profile.timelineWidth) || profile.timelineWidth <= 0 || !Number.isFinite(profile.trackHeight) || profile.trackHeight <= 0 || !Number.isFinite(profile.pixelRatio) || profile.pixelRatio <= 0 || !cutterMediaJob || jobId !== cutterMediaJob.jobId || normalizeComparablePath(filePath) !== cutterMediaJob.path || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity)) return null;
|
|
cancelCutterMediaPreparation();
|
|
const runGeneration = cutterAssetRunGeneration;
|
|
const info = cutterMediaJob.info;
|
|
const tempDir = fs.mkdtempSync(path.join(app.getPath('temp'), `tvm-editor-media-${process.pid}-`));
|
|
const pixelRatio = Math.min(3, Math.max(1, profile.pixelRatio));
|
|
const pixelWidth = info.duration <= 120 ? 32000 : Math.round(Math.min(32000, Math.max(1800, Math.ceil(profile.timelineWidth * pixelRatio))));
|
|
const thumbnailCount = info.duration <= 120 ? 200 : Math.round(Math.min(100, Math.max(30, Math.ceil(pixelWidth / 320))));
|
|
const thumbnailTileWidth = Math.max(428, Math.ceil(pixelWidth / thumbnailCount / 2) * 2);
|
|
const thumbnailTileHeight = 240;
|
|
try {
|
|
const thumbnailsReady = await runEditorMediaProcess([
|
|
'-i', filePath,
|
|
'-vf', `fps=${thumbnailCount / info.duration},scale=${thumbnailTileWidth}:${thumbnailTileHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${thumbnailTileWidth}:${thumbnailTileHeight}`,
|
|
'-frames:v', String(thumbnailCount),
|
|
'-q:v', '2',
|
|
'-start_number', '1',
|
|
'-y', path.join(tempDir, 'thumb-%03d.jpg'),
|
|
], runGeneration);
|
|
if (runGeneration !== cutterAssetRunGeneration || !cutterMediaJob || jobId !== cutterMediaJob.jobId || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity) || appShutdownStarted) return null;
|
|
if (!thumbnailsReady) return null;
|
|
const thumbnails = fs.readdirSync(tempDir)
|
|
.filter((name) => /^thumb-\d+\.jpg$/i.test(name))
|
|
.sort()
|
|
.map((name) => readImageDataUrl(path.join(tempDir, name)))
|
|
.filter((value): value is string => Boolean(value));
|
|
if (thumbnails.length < Math.floor(thumbnailCount * 0.95)) return null;
|
|
return {
|
|
jobId,
|
|
thumbnails,
|
|
thumbnailSprite: null,
|
|
thumbnailCount: thumbnails.length,
|
|
pixelWidth,
|
|
pixelHeight: thumbnailTileHeight,
|
|
};
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function getCutterInputIdentity(filePath: string): { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null {
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile()) return null;
|
|
return { path: normalizeComparablePath(filePath), size: stat.size, mtimeMs: stat.mtimeMs, dev: stat.dev, ino: stat.ino };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getCutterProjectSource(filePath: string): CutterProjectSource | null {
|
|
const identity = getCutterInputIdentity(filePath);
|
|
return identity ? { path: identity.path, size: identity.size, mtimeMs: identity.mtimeMs } : null;
|
|
}
|
|
|
|
function isCutterExportProfile(value: unknown): value is CutterExportProfile {
|
|
return value === 'quality' || value === 'balanced' || value === 'fast' || value === 'archive';
|
|
}
|
|
|
|
function isCutterExportEncoder(value: unknown): value is CutterExportEncoder {
|
|
return value === 'software' || value === 'h264_nvenc' || value === 'h264_qsv' || value === 'h264_amf';
|
|
}
|
|
|
|
function createCutterProject(filePath: string, info: VideoInfo, value: unknown): CutterProject | null {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const project = value as Record<string, unknown>;
|
|
const profile = project.profile;
|
|
const encoder = project.encoder;
|
|
const audioStreamIndex = project.audioStreamIndex;
|
|
const trimStart = project.trimStart;
|
|
const trimEnd = project.trimEnd;
|
|
const cuts = project.cuts;
|
|
const source = getCutterProjectSource(filePath);
|
|
if (!source
|
|
|| !isCutterExportProfile(profile)
|
|
|| !isCutterExportEncoder(encoder)
|
|
|| typeof audioStreamIndex !== 'number' || !Number.isInteger(audioStreamIndex) || audioStreamIndex < 0
|
|
|| typeof trimStart !== 'number' || !Number.isFinite(trimStart)
|
|
|| typeof trimEnd !== 'number' || !Number.isFinite(trimEnd)
|
|
|| !Array.isArray(cuts) || cuts.length > 64
|
|
|| cuts.some((cut) => !isPlainObject(cut)
|
|
|| typeof cut.id !== 'string'
|
|
|| typeof cut.start !== 'number' || !Number.isFinite(cut.start)
|
|
|| typeof cut.end !== 'number' || !Number.isFinite(cut.end))) {
|
|
return null;
|
|
}
|
|
const hasSelectedAudio = info.audioStreams.some((stream) => stream.index === audioStreamIndex);
|
|
if ((info.hasAudio && !hasSelectedAudio) || (!info.hasAudio && audioStreamIndex !== 0)) return null;
|
|
let state = setTrimRange(createVideoEditorState(info.duration, info.fps), trimStart, trimEnd);
|
|
if (Math.abs(state.trimStart - trimStart) > 1 / info.fps || Math.abs(state.trimEnd - trimEnd) > 1 / info.fps) return null;
|
|
try {
|
|
for (const cut of cuts) {
|
|
state = addCutAt(state, cut.start, cut.end - cut.start).state;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return {
|
|
source,
|
|
duration: info.duration,
|
|
fps: info.fps,
|
|
trimStart: state.trimStart,
|
|
trimEnd: state.trimEnd,
|
|
cuts: state.cuts.map((cut) => ({ ...cut })),
|
|
profile,
|
|
encoder,
|
|
audioStreamIndex,
|
|
};
|
|
}
|
|
|
|
function cutterInputIdentityMatches(filePath: string): boolean {
|
|
const current = getCutterInputIdentity(filePath);
|
|
return cutterInputIdentitiesMatch(current, cutterPreparedInput);
|
|
}
|
|
|
|
function cutterInputIdentitiesMatch(
|
|
left: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null,
|
|
right: { path: string; size: number; mtimeMs: number; dev: number; ino: number } | null,
|
|
): boolean {
|
|
return Boolean(left && right
|
|
&& left.path === right.path
|
|
&& left.size === right.size
|
|
&& left.mtimeMs === right.mtimeMs
|
|
&& left.dev === right.dev
|
|
&& left.ino === right.ino);
|
|
}
|
|
|
|
function normalizeComparablePath(filePath: string): string {
|
|
const resolved = path.resolve(filePath);
|
|
let canonical = resolved;
|
|
try { canonical = fs.realpathSync.native(resolved); } catch {
|
|
try {
|
|
const parent = fs.realpathSync.native(path.dirname(resolved));
|
|
canonical = path.join(parent, path.basename(resolved));
|
|
} catch { }
|
|
}
|
|
return process.platform === 'win32' ? canonical.toLocaleLowerCase('en-US') : canonical;
|
|
}
|
|
|
|
function pathsReferToSameFile(left: string, right: string): boolean {
|
|
if (normalizeComparablePath(left) === normalizeComparablePath(right)) return true;
|
|
if (!fs.existsSync(left) || !fs.existsSync(right)) return false;
|
|
try {
|
|
const leftStat = fs.statSync(left);
|
|
const rightStat = fs.statSync(right);
|
|
return leftStat.dev === rightStat.dev && leftStat.ino !== 0 && leftStat.ino === rightStat.ino;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function publishVideoEditorOutput(partialFile: string, outputFile: string): void {
|
|
const backupFile = `${outputFile}.${process.pid}.${Date.now()}.tvm-backup`;
|
|
const hadExistingOutput = fs.existsSync(outputFile);
|
|
if (hadExistingOutput) fs.renameSync(outputFile, backupFile);
|
|
try {
|
|
fs.renameSync(partialFile, outputFile);
|
|
if (hadExistingOutput) {
|
|
try { fs.rmSync(backupFile, { force: true }); } catch { }
|
|
}
|
|
} catch (error) {
|
|
if (hadExistingOutput && fs.existsSync(backupFile) && !fs.existsSync(outputFile)) fs.renameSync(backupFile, outputFile);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function performVideoEditExport(request: VideoEditExportRequest, onProgress: (percent: number) => void): Promise<boolean> {
|
|
if (appShutdownStarted) return false;
|
|
if (!request || typeof request.inputFile !== 'string' || typeof request.outputFile !== 'string') return false;
|
|
if (!path.isAbsolute(request.inputFile) || !path.isAbsolute(request.outputFile) || !fs.existsSync(request.inputFile)) return false;
|
|
if (!isCutterExportProfile(request.profile) || !isCutterExportEncoder(request.encoder)) return false;
|
|
const profile = getCutterExportProfile(request.profile);
|
|
const outputExtension = `.${profile.container}`;
|
|
if (path.extname(request.outputFile).toLowerCase() !== outputExtension || pathsReferToSameFile(request.inputFile, request.outputFile)) return false;
|
|
if (!Number.isFinite(request.trimStart) || !Number.isFinite(request.trimEnd) || !Array.isArray(request.cuts) || request.cuts.length > 64) return false;
|
|
if (request.cuts.some((cut) => !isPlainObject(cut) || typeof cut.id !== 'string' || !Number.isFinite(cut.start) || !Number.isFinite(cut.end))) return false;
|
|
const inputIdentity = getCutterInputIdentity(request.inputFile);
|
|
if (!cutterInputIdentitiesMatch(inputIdentity, cutterPreparedInput)) return false;
|
|
const info = await getVideoInfo(request.inputFile, currentCutterExportProcesses);
|
|
if (!info || cutterExportCancelled) return false;
|
|
if (!Number.isInteger(request.audioStreamIndex) || request.audioStreamIndex < 0) return false;
|
|
if ((info.hasAudio && !info.audioStreams.some((stream) => stream.index === request.audioStreamIndex)) || (!info.hasAudio && request.audioStreamIndex !== 0)) return false;
|
|
let state = setTrimRange(createVideoEditorState(info.duration, info.fps), request.trimStart, request.trimEnd);
|
|
if (Math.abs(state.trimStart - request.trimStart) > 1 / info.fps || Math.abs(state.trimEnd - request.trimEnd) > 1 / info.fps) return false;
|
|
try {
|
|
for (const cut of request.cuts) {
|
|
state = addCutAt(state, cut.start, cut.end - cut.start).state;
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
const segments = getPlayableSegments(state);
|
|
if (segments.length === 0) return false;
|
|
const outputDir = path.dirname(request.outputFile);
|
|
if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) return false;
|
|
const inputBytes = fs.statSync(request.inputFile).size;
|
|
const diskCheck = ensureDiskSpace(outputDir, Math.max(128 * 1024 * 1024, Math.ceil(inputBytes * 1.25)), 'Video-Editor');
|
|
if (!diskCheck.success) return false;
|
|
const partialFile = path.join(outputDir, `.${path.basename(request.outputFile, outputExtension)}.${process.pid}.${Date.now()}.tvm-edit${outputExtension}`);
|
|
const availableHardwareEncoders = request.encoder === 'software' ? [] : await getCutterHardwareEncoders();
|
|
let plan = createCutterExportPlan({
|
|
inputFile: request.inputFile,
|
|
outputFile: partialFile,
|
|
segments,
|
|
hasAudio: info.hasAudio,
|
|
profile: request.profile,
|
|
encoder: request.encoder,
|
|
availableHardwareEncoders,
|
|
audioStreamIndex: request.audioStreamIndex,
|
|
rotation: info.rotation,
|
|
});
|
|
if (plan.filterComplex.length > 24000 || cutterExportCancelled) return false;
|
|
currentCutterPartialFile = partialFile;
|
|
const runPlan = async (activePlan: ReturnType<typeof createCutterExportPlan>): Promise<boolean> => await new Promise<boolean>((resolve) => {
|
|
const ffmpegPath = getFFmpegPath();
|
|
recordManagedToolExecution('ffmpeg', ffmpegPath);
|
|
const proc = spawn(ffmpegPath, activePlan.ffmpegArgs, { windowsHide: true });
|
|
currentCutterProcess = proc;
|
|
currentCutterExportProcesses.add(proc);
|
|
proc.stderr?.resume();
|
|
let stdout = '';
|
|
proc.stdout?.on('data', (data) => {
|
|
stdout += data.toString();
|
|
const lines = stdout.split(/\r?\n/);
|
|
stdout = lines.pop() || '';
|
|
for (const line of lines) {
|
|
const match = line.match(/^out_time_(?:us|ms)=(\d+)$/);
|
|
if (match) onProgress(calculateCutterExportProgress(Number(match[1]) / 1_000_000, activePlan));
|
|
}
|
|
});
|
|
proc.on('close', (code) => {
|
|
currentCutterExportProcesses.delete(proc);
|
|
if (currentCutterProcess === proc) currentCutterProcess = null;
|
|
resolve(code === 0 && !cutterExportCancelled);
|
|
});
|
|
proc.on('error', () => {
|
|
currentCutterExportProcesses.delete(proc);
|
|
if (currentCutterProcess === proc) currentCutterProcess = null;
|
|
resolve(false);
|
|
});
|
|
});
|
|
let success = await runPlan(plan);
|
|
if (!success && !cutterExportCancelled && plan.selectedEncoder !== 'libx264' && plan.selectedEncoder !== 'ffv1') {
|
|
try { fs.rmSync(partialFile, { force: true }); } catch { }
|
|
plan = createCutterExportPlan({
|
|
inputFile: request.inputFile,
|
|
outputFile: partialFile,
|
|
segments,
|
|
hasAudio: info.hasAudio,
|
|
profile: request.profile,
|
|
encoder: 'software',
|
|
audioStreamIndex: request.audioStreamIndex,
|
|
rotation: info.rotation,
|
|
});
|
|
success = await runPlan(plan);
|
|
}
|
|
if (!success || !fs.existsSync(partialFile) || fs.statSync(partialFile).size <= 256) {
|
|
fs.rmSync(partialFile, { force: true });
|
|
currentCutterPartialFile = null;
|
|
return false;
|
|
}
|
|
if (cutterExportCancelled) {
|
|
fs.rmSync(partialFile, { force: true });
|
|
currentCutterPartialFile = null;
|
|
return false;
|
|
}
|
|
const outputInfo = await getVideoInfo(partialFile, currentCutterExportProcesses);
|
|
if (cutterExportCancelled || !outputInfo || Math.abs(outputInfo.duration - plan.remainingDuration) > Math.max(0.12, 3 / info.fps)) {
|
|
fs.rmSync(partialFile, { force: true });
|
|
currentCutterPartialFile = null;
|
|
return false;
|
|
}
|
|
if (cutterExportCancelled || pathsReferToSameFile(request.inputFile, request.outputFile) || !cutterInputIdentitiesMatch(getCutterInputIdentity(request.inputFile), inputIdentity)) {
|
|
fs.rmSync(partialFile, { force: true });
|
|
currentCutterPartialFile = null;
|
|
return false;
|
|
}
|
|
publishVideoEditorOutput(partialFile, request.outputFile);
|
|
currentCutterPartialFile = null;
|
|
onProgress(100);
|
|
return true;
|
|
}
|
|
|
|
async function exportVideoEdit(request: VideoEditExportRequest, onProgress: (percent: number) => void): Promise<{ success: boolean; cancelled: boolean }> {
|
|
if (cutterExportActive || appShutdownStarted) return { success: false, cancelled: false };
|
|
cutterExportActive = true;
|
|
cutterExportCancelled = false;
|
|
try {
|
|
const success = await performVideoEditExport(request, onProgress);
|
|
return { success, cancelled: !success && cutterExportCancelled };
|
|
} catch (error) {
|
|
appendDebugLog('video-editor-export-failed', String(error));
|
|
return { success: false, cancelled: cutterExportCancelled };
|
|
} finally {
|
|
cutterExportActive = false;
|
|
cutterExportCancelled = false;
|
|
if (currentCutterPartialFile && !currentCutterProcess) {
|
|
try { fs.rmSync(currentCutterPartialFile, { force: true }); } catch { }
|
|
currentCutterPartialFile = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// VIDEO CUTTER
|
|
// ==========================================
|
|
async function extractFrame(filePath: string, timeSeconds: number): Promise<string | null> {
|
|
if (appShutdownStarted) return null;
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady || appShutdownStarted) {
|
|
appendDebugLog('extract-frame-missing-ffmpeg');
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const ffmpeg = getFFmpegPath();
|
|
const tempFile = path.join(app.getPath('temp'), `frame_${process.pid}_${Date.now()}.jpg`);
|
|
|
|
const args = [
|
|
'-ss', timeSeconds.toString(),
|
|
'-i', filePath,
|
|
'-vframes', '1',
|
|
'-q:v', '2',
|
|
'-y',
|
|
tempFile
|
|
];
|
|
|
|
let proc: ChildProcess;
|
|
try {
|
|
recordManagedToolExecution('ffmpeg', ffmpeg);
|
|
proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
} catch {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
let settled = false;
|
|
currentCutterFrameProcesses.add(proc);
|
|
currentCutterFrameFiles.add(tempFile);
|
|
proc.stderr?.resume();
|
|
|
|
const finish = (code: number | null): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
currentCutterFrameProcesses.delete(proc);
|
|
currentCutterFrameFiles.delete(tempFile);
|
|
let frame: string | null = null;
|
|
try {
|
|
if (code === 0 && !appShutdownStarted && fs.existsSync(tempFile)) {
|
|
frame = `data:image/jpeg;base64,${fs.readFileSync(tempFile).toString('base64')}`;
|
|
}
|
|
} catch { }
|
|
try { fs.rmSync(tempFile, { force: true }); } catch { }
|
|
resolve(frame);
|
|
};
|
|
|
|
proc.once('close', finish);
|
|
proc.once('error', () => finish(null));
|
|
});
|
|
}
|
|
|
|
// Concatenates same-codec mp4 files into a single output via ffmpeg's
|
|
// concat demuxer. No re-encoding — purely a container stitch, which is
|
|
// what we want for resumed-recording parts (same streamlink, same codec
|
|
// settings, just split across files). Returns false on any error so the
|
|
// caller can keep the original parts.
|
|
async function concatVideoFiles(inputFiles: string[], outputFile: string, itemId: string | null = null): Promise<boolean> {
|
|
if (inputFiles.length < 2) return false;
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) return false;
|
|
|
|
for (const f of inputFiles) {
|
|
if (!fs.existsSync(f)) {
|
|
appendDebugLog('concat-missing-part', { missing: f });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const listFile = path.join(path.dirname(outputFile), `.concat-${Date.now()}.txt`);
|
|
try {
|
|
// ffmpeg concat demuxer escaping: paths go in single quotes, embedded
|
|
// single quotes need '\''. Backslashes are fine on Windows.
|
|
const lines = inputFiles
|
|
.map((f) => `file '${f.replace(/'/g, "'\\''")}'`)
|
|
.join('\n');
|
|
fs.writeFileSync(listFile, lines, 'utf8');
|
|
} catch (e) {
|
|
appendDebugLog('concat-listfile-write-failed', String(e));
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const args = [
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', listFile,
|
|
'-c', 'copy',
|
|
'-y',
|
|
outputFile
|
|
];
|
|
|
|
try {
|
|
const success = await new Promise<boolean>((resolve) => {
|
|
recordManagedToolExecution('ffmpeg', ffmpeg);
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
const registration = itemId
|
|
? queueProcessRegistry.register(itemId, 'post-processing', createPhaseBoundaryProcessResource(
|
|
proc,
|
|
() => waitForChildProcessExit(proc),
|
|
() => {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
try { fs.rmSync(listFile, { force: true }); } catch { }
|
|
},
|
|
))
|
|
: null;
|
|
if (registration && !registration.accepted) {
|
|
proc.once('error', () => undefined);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
let stderrBuf = '';
|
|
proc.stderr?.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); });
|
|
proc.on('close', (code) => {
|
|
registration?.release();
|
|
if (code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) {
|
|
appendDebugLog('concat-ok', { output: outputFile, parts: inputFiles.length });
|
|
resolve(true);
|
|
} else {
|
|
appendDebugLog('concat-failed', { code, stderrTail: stderrBuf.slice(-400) });
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
resolve(false);
|
|
}
|
|
});
|
|
proc.on('error', (err) => {
|
|
registration?.release();
|
|
appendDebugLog('concat-spawn-error', String(err));
|
|
resolve(false);
|
|
});
|
|
});
|
|
if (itemId && queueProcessRegistry.isPaused(itemId) && !(await waitForQueuePhaseBoundary(itemId))) {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
return false;
|
|
}
|
|
if (!success) return false;
|
|
if (!(await waitForQueuePhaseBoundary(itemId))) {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
return false;
|
|
}
|
|
return true;
|
|
} finally {
|
|
try { fs.rmSync(listFile, { force: true }); } catch { }
|
|
}
|
|
}
|
|
|
|
async function cutVideo(
|
|
inputFile: string,
|
|
outputFile: string,
|
|
startTime: number,
|
|
endTime: number,
|
|
onProgress: (percent: number) => void
|
|
): Promise<boolean> {
|
|
if (appShutdownStarted) return false;
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (appShutdownStarted) return false;
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('cut-video-missing-ffmpeg');
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const duration = Math.max(0.1, endTime - startTime);
|
|
|
|
let inputBytes = 0;
|
|
try {
|
|
inputBytes = fs.statSync(inputFile).size;
|
|
} catch { }
|
|
|
|
const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75));
|
|
const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut');
|
|
if (!cutDiskCheck.success) {
|
|
appendDebugLog('cut-video-no-disk-space', {
|
|
inputFile,
|
|
outputFile,
|
|
requiredBytes: cutRequiredBytes,
|
|
error: cutDiskCheck.error
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const runCutAttempt = async (copyMode: boolean): Promise<boolean> => {
|
|
if (appShutdownStarted) return false;
|
|
const args = [
|
|
'-ss', formatDuration(startTime),
|
|
'-i', inputFile,
|
|
'-t', formatDuration(duration)
|
|
];
|
|
|
|
if (copyMode) {
|
|
args.push('-c', 'copy');
|
|
} else {
|
|
args.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-crf', '20',
|
|
'-c:a', 'aac',
|
|
'-b:a', '160k',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
|
|
|
appendDebugLog('cut-video-attempt', { copyMode, args });
|
|
|
|
return await new Promise((resolve) => {
|
|
recordManagedToolExecution('ffmpeg', ffmpeg);
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
currentEditorProcesses.add(proc);
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
const line = data.toString();
|
|
const match = line.match(/out_time_us=(\d+)/);
|
|
if (match) {
|
|
const currentUs = parseInt(match[1], 10);
|
|
const percent = Math.min(100, (currentUs / 1000000) / duration * 100);
|
|
onProgress(percent);
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
currentEditorProcesses.delete(proc);
|
|
if (!appShutdownStarted && 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', () => {
|
|
currentEditorProcesses.delete(proc);
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
const copySuccess = await runCutAttempt(true);
|
|
if (appShutdownStarted) return false;
|
|
if (copySuccess) {
|
|
return true;
|
|
}
|
|
|
|
appendDebugLog('cut-video-copy-failed-fallback-reencode', { inputFile, outputFile });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { }
|
|
|
|
return await runCutAttempt(false);
|
|
}
|
|
|
|
// ==========================================
|
|
// MERGE VIDEOS
|
|
// ==========================================
|
|
async function mergeVideos(
|
|
inputFiles: string[],
|
|
outputFile: string,
|
|
onProgress: (percent: number) => void,
|
|
totalDurationSec?: number,
|
|
itemId: string | null = null
|
|
): Promise<boolean> {
|
|
if (appShutdownStarted) return false;
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (appShutdownStarted) return false;
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('merge-videos-missing-ffmpeg');
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`);
|
|
const concatContent = inputFiles.map((filePath) => {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
return `file '${normalized.replace(/'/g, "'\\''")}'`;
|
|
}).join('\n');
|
|
fs.writeFileSync(concatFile, concatContent);
|
|
|
|
let mergeInputBytes = 0;
|
|
for (const filePath of inputFiles) {
|
|
try {
|
|
mergeInputBytes += fs.statSync(filePath).size;
|
|
} catch {
|
|
// ignore missing file in estimation
|
|
}
|
|
}
|
|
|
|
const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1));
|
|
const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge');
|
|
if (!mergeDiskCheck.success) {
|
|
appendDebugLog('merge-video-no-disk-space', {
|
|
outputFile,
|
|
files: inputFiles.length,
|
|
requiredBytes: mergeRequiredBytes,
|
|
error: mergeDiskCheck.error
|
|
});
|
|
try {
|
|
fs.unlinkSync(concatFile);
|
|
} catch { }
|
|
return false;
|
|
}
|
|
|
|
// Determine total duration for accurate progress
|
|
let mergeTotalDurationUs = 0;
|
|
if (totalDurationSec && totalDurationSec > 0) {
|
|
mergeTotalDurationUs = totalDurationSec * 1_000_000;
|
|
} else {
|
|
// Fallback: use ffprobe to get total duration of all input files
|
|
const ffprobe = getFFprobePath();
|
|
for (const filePath of inputFiles) {
|
|
try {
|
|
recordManagedToolExecution('ffprobe', ffprobe);
|
|
const result = execSync(
|
|
`"${ffprobe}" -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`,
|
|
{ timeout: 10000, windowsHide: true }
|
|
).toString().trim();
|
|
const dur = parseFloat(result);
|
|
if (!isNaN(dur)) {
|
|
mergeTotalDurationUs += dur * 1_000_000;
|
|
}
|
|
} catch {
|
|
// If ffprobe fails, fall back to old behavior
|
|
}
|
|
}
|
|
}
|
|
|
|
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
|
|
if (appShutdownStarted) return false;
|
|
const args = [
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', concatFile
|
|
];
|
|
|
|
if (copyMode) {
|
|
args.push('-c', 'copy');
|
|
} else {
|
|
args.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-crf', '20',
|
|
'-c:a', 'aac',
|
|
'-b:a', '160k',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
|
appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length });
|
|
|
|
return await new Promise((resolve) => {
|
|
recordManagedToolExecution('ffmpeg', ffmpeg);
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
const registration = itemId
|
|
? queueProcessRegistry.register(itemId, 'merge', createPhaseBoundaryProcessResource(
|
|
proc,
|
|
() => waitForChildProcessExit(proc),
|
|
() => {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
try { fs.rmSync(concatFile, { force: true }); } catch { }
|
|
},
|
|
))
|
|
: null;
|
|
if (registration && !registration.accepted) {
|
|
proc.once('error', () => undefined);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
if (!itemId) currentEditorProcesses.add(proc);
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
const line = data.toString();
|
|
const match = line.match(/out_time_us=(\d+)/);
|
|
if (match) {
|
|
const currentUs = parseInt(match[1], 10);
|
|
if (mergeTotalDurationUs > 0) {
|
|
onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100));
|
|
} else {
|
|
onProgress(Math.min(99, currentUs / 10000000));
|
|
}
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
registration?.release();
|
|
if (!itemId) currentEditorProcesses.delete(proc);
|
|
const success = !appShutdownStarted && code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(outputFile);
|
|
if (success) {
|
|
onProgress(100);
|
|
}
|
|
resolve(success);
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
registration?.release();
|
|
if (!itemId) currentEditorProcesses.delete(proc);
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
try {
|
|
const copySuccess = await runMergeAttempt(true);
|
|
if (appShutdownStarted) return false;
|
|
if (copySuccess) {
|
|
return true;
|
|
}
|
|
|
|
if (itemId && queueProcessRegistry.isCancelled(itemId)) {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
return false;
|
|
}
|
|
|
|
const boundaryReady = await waitForQueuePhaseBoundary(itemId);
|
|
if (appShutdownStarted || !boundaryReady) {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
return false;
|
|
}
|
|
|
|
appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { }
|
|
|
|
const reencodeSuccess = await runMergeAttempt(false);
|
|
if (appShutdownStarted) return false;
|
|
if (!reencodeSuccess) {
|
|
try { fs.rmSync(outputFile, { force: true }); } catch { }
|
|
}
|
|
return reencodeSuccess;
|
|
} finally {
|
|
try {
|
|
fs.unlinkSync(concatFile);
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// SPLIT MERGED FILE
|
|
// ==========================================
|
|
async function splitMergedFile(
|
|
inputFile: string,
|
|
outputFolder: string,
|
|
partDurationSec: number,
|
|
totalDurationSec: number,
|
|
filenameGenerator: (partNum: number) => string,
|
|
onProgress: (currentPart: number, totalParts: number) => void,
|
|
onPartState: (partIndex: number, outputFile: string, temporaryFile: string | null) => void,
|
|
itemId: string | null = null
|
|
): Promise<{ success: boolean; files: string[] }> {
|
|
if (appShutdownStarted) return { success: false, files: [] };
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (appShutdownStarted) return { success: false, files: [] };
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('split-merged-missing-ffmpeg');
|
|
return { success: false, files: [] };
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const numParts = Math.ceil(totalDurationSec / partDurationSec);
|
|
const splitFiles: string[] = [];
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
if (itemId && cancelledItemIds.has(itemId)) {
|
|
return { success: false, files: splitFiles };
|
|
}
|
|
|
|
const startSec = i * partDurationSec;
|
|
const thisDuration = Math.min(partDurationSec, totalDurationSec - startSec);
|
|
const outputFile = ensureUniqueFilename(path.join(outputFolder, filenameGenerator(i + 1)), itemId);
|
|
const temporaryFile = ensureUniqueFilename(path.join(outputFolder, `.merge_split_${Date.now()}_${process.pid}_${i}.mp4`), itemId);
|
|
|
|
onPartState(i, outputFile, temporaryFile);
|
|
onProgress(i + 1, numParts);
|
|
|
|
const args = [
|
|
'-ss', formatDuration(startSec),
|
|
'-i', inputFile,
|
|
'-t', formatDuration(thisDuration),
|
|
'-c', 'copy',
|
|
'-y', temporaryFile
|
|
];
|
|
|
|
appendDebugLog('split-merged-part', { part: i + 1, total: numParts, startSec, duration: thisDuration });
|
|
|
|
const success = await new Promise<boolean>((resolve) => {
|
|
recordManagedToolExecution('ffmpeg', ffmpeg);
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
const registration = itemId
|
|
? queueProcessRegistry.register(itemId, 'split', createPhaseBoundaryProcessResource(
|
|
proc,
|
|
() => waitForChildProcessExit(proc),
|
|
() => { try { fs.rmSync(temporaryFile, { force: true }); } catch { } },
|
|
))
|
|
: null;
|
|
if (registration && !registration.accepted) {
|
|
proc.once('error', () => undefined);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
if (!itemId) currentEditorProcesses.add(proc);
|
|
|
|
proc.on('close', (code) => {
|
|
registration?.release();
|
|
if (!itemId) currentEditorProcesses.delete(proc);
|
|
resolve(!appShutdownStarted && code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(temporaryFile));
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
registration?.release();
|
|
if (!itemId) currentEditorProcesses.delete(proc);
|
|
resolve(false);
|
|
});
|
|
});
|
|
|
|
if (!success) {
|
|
appendDebugLog('split-merged-part-failed', { part: i + 1, outputFile });
|
|
try { fs.rmSync(temporaryFile, { force: true }); } catch { }
|
|
if (!fs.existsSync(temporaryFile)) onPartState(i, '', null);
|
|
return { success: false, files: splitFiles };
|
|
}
|
|
|
|
try {
|
|
fs.renameSync(temporaryFile, outputFile);
|
|
} catch {
|
|
try { fs.rmSync(temporaryFile, { force: true }); } catch { }
|
|
if (!fs.existsSync(temporaryFile)) onPartState(i, '', null);
|
|
return { success: false, files: splitFiles };
|
|
}
|
|
onPartState(i, outputFile, null);
|
|
splitFiles.push(outputFile);
|
|
if (itemId) registerQueuePartialFile(itemId, outputFile);
|
|
if (!(await waitForQueuePhaseBoundary(itemId))) {
|
|
return { success: false, files: splitFiles };
|
|
}
|
|
}
|
|
|
|
return { success: true, files: splitFiles };
|
|
}
|
|
|
|
// ==========================================
|
|
// DOWNLOAD FUNCTIONS
|
|
// ==========================================
|
|
function downloadVODPart(
|
|
url: string,
|
|
filename: string,
|
|
startTime: string | null,
|
|
endTime: string | null,
|
|
onProgress: (progress: DownloadProgress) => void,
|
|
itemId: string,
|
|
partNum: number,
|
|
totalParts: number,
|
|
/** Erwartete Dauer in Sekunden fuer den Progress-Estimate. Wenn endTime
|
|
gesetzt ist, ueberschrieben aus dort. Wenn startTime und endTime null
|
|
sind (Full-VOD), kann Caller hier die VOD-Gesamtdauer reingeben,
|
|
damit der Bar nicht in indeterminate haengt. 0 = unknown. */
|
|
expectedTotalSec: number = 0
|
|
): Promise<DownloadResult> {
|
|
return new Promise((resolve) => {
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];
|
|
if (config.streamlink_disable_ads !== false) {
|
|
// Skips Twitch mid-roll ads which would otherwise be embedded
|
|
// in the VOD output. Off only if the user explicitly disabled it.
|
|
args.push('--twitch-disable-ads');
|
|
}
|
|
// HLS-Segment-Resilience: bei vereinzelten CDN-Fehlern weiter retrien,
|
|
// statt komplett zu sterben. Twitch hat 2025/26 oefter transiente 403/
|
|
// timeout-Errors auf einzelne HLS-Segments. Default ist 3 — 5 ist ein
|
|
// pragmatischer Kompromiss zwischen Resilience und Failing-Fast.
|
|
args.push('--stream-segment-attempts', '5');
|
|
args.push('--stream-segment-timeout', '20');
|
|
args.push('--stream-timeout', '120');
|
|
// Streamlink-Plugin retry: bei "stream not found on URL"-Erstabfrage
|
|
// einmal nachhaken, bevor wir den ganzen Run failen.
|
|
args.push('--retry-streams', '3');
|
|
args.push('--retry-max', '2');
|
|
let lastErrorLine = '';
|
|
const stderrBuffer: string[] = [];
|
|
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
|
let lastStreamlinkPercent = 0;
|
|
|
|
if (startTime) {
|
|
args.push('--hls-start-offset', startTime);
|
|
}
|
|
if (endTime) {
|
|
args.push('--hls-duration', endTime);
|
|
}
|
|
|
|
// download-part-start in the debug log captures the same info
|
|
// for support / forensics — no need to flood stdout too.
|
|
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
|
|
|
|
const partialFilename = partialDownloadRegistry.begin(filename);
|
|
recordManagedToolExecution('streamlink', streamlinkCmd.command);
|
|
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
|
|
const outputStream = fs.createWriteStream(partialFilename, { flags: 'w' });
|
|
if (!proc.stdout) {
|
|
outputStream.destroy();
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
|
return;
|
|
}
|
|
const output = createPausableOutput(
|
|
proc.stdout,
|
|
outputStream,
|
|
createDownloadThrottleTransform(),
|
|
);
|
|
const outputFinished = output.finished.then(() => null, (error) => error);
|
|
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
|
|
kill: () => proc.kill(),
|
|
wait: () => waitForChildProcessExit(proc),
|
|
pause: () => output.pause(),
|
|
resume: () => output.resume(),
|
|
cancel: () => output.cancel(),
|
|
cleanup: () => partialDownloadRegistry.discard(partialFilename),
|
|
});
|
|
|
|
const itemTracking: ActiveDownloadTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0, output, partialFilename };
|
|
activeDownloads.set(itemId, itemTracking);
|
|
if (queuePaused) output.pause();
|
|
|
|
downloadStartTime = itemTracking.startTime;
|
|
downloadedBytes = 0;
|
|
let lastBytes = 0;
|
|
let lastTime = Date.now();
|
|
|
|
// Monitor file size for progress
|
|
const progressInterval = setInterval(() => {
|
|
if (fs.existsSync(partialFilename)) {
|
|
try {
|
|
const stats = fs.statSync(partialFilename);
|
|
downloadedBytes = stats.size;
|
|
itemTracking.bytes = stats.size;
|
|
|
|
const now = Date.now();
|
|
const timeDiff = (now - lastTime) / 1000;
|
|
const bytesDiff = downloadedBytes - lastBytes;
|
|
const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
|
|
|
|
runtimeMetrics.lastSpeedBytesPerSec = speed;
|
|
if (speed > 0) {
|
|
runtimeMetrics.avgSpeedBytesPerSec = runtimeMetrics.avgSpeedBytesPerSec <= 0
|
|
? speed
|
|
: (runtimeMetrics.avgSpeedBytesPerSec * 0.8) + (speed * 0.2);
|
|
}
|
|
|
|
lastBytes = downloadedBytes;
|
|
lastTime = now;
|
|
|
|
let etaStr = '';
|
|
if (downloadedBytes > 0) {
|
|
const elapsedSec = (Date.now() - (itemTracking?.startTime || Date.now())) / 1000;
|
|
if (elapsedSec > 5 && lastStreamlinkPercent > 1) {
|
|
// Use streamlink's reported progress for accurate ETA
|
|
const remainingSec = (elapsedSec / lastStreamlinkPercent) * (100 - lastStreamlinkPercent);
|
|
if (remainingSec > 0 && remainingSec < 86400) {
|
|
etaStr = formatETA(remainingSec);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bytes-basierte Schaetzung statt progress=-1, damit die Bar
|
|
// determinate bleibt + kontinuierlich waechst. Wenn streamlink
|
|
// spaeter eine echte % rausgibt (Path B), wird die ueber den
|
|
// bytes-Estimate gelegt (siehe lastStreamlinkPercent-Logik
|
|
// im stdout-handler).
|
|
// Quelle: endTime (--hls-duration arg) ODER expectedTotalSec
|
|
// Param (fuer Full-VOD wo Caller die Dauer kennt).
|
|
const expectedDurationSecForEstimate = parseClockDurationSeconds(endTime) || expectedTotalSec;
|
|
const expectedBytes = expectedDurationSecForEstimate > 0 ? expectedDurationSecForEstimate * 625_000 : 0;
|
|
let progressEstimate: number;
|
|
if (lastStreamlinkPercent > 0) {
|
|
// Streamlink hat % rausgegeben — vertrau dem (genauer als bytes).
|
|
progressEstimate = lastStreamlinkPercent;
|
|
} else if (expectedBytes > 0 && downloadedBytes > 0) {
|
|
// Bytes-Fallback: cap bei 95% damit der Bar nicht 100%
|
|
// vor dem tatsaechlichen Abschluss hinrennt.
|
|
progressEstimate = Math.min(95, (downloadedBytes / expectedBytes) * 100);
|
|
} else {
|
|
// Keine Info -> echtes Unknown, Bar geht in indeterminate.
|
|
progressEstimate = -1;
|
|
}
|
|
|
|
onProgress({
|
|
id: itemId,
|
|
progress: progressEstimate,
|
|
speed: formatSpeed(speed),
|
|
eta: etaStr,
|
|
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
|
|
currentPart: partNum,
|
|
totalParts: totalParts,
|
|
downloadedBytes: downloadedBytes,
|
|
speedBytesPerSec: speed
|
|
});
|
|
} catch { }
|
|
}
|
|
}, 1000);
|
|
|
|
proc.stderr?.on('data', (data: Buffer) => {
|
|
const message = data.toString();
|
|
if (message.trim()) {
|
|
stderrBuffer.push(message);
|
|
const match = message.match(/(\d+\.\d+)%/);
|
|
if (match) {
|
|
const percent = parseFloat(match[1]);
|
|
lastStreamlinkPercent = percent;
|
|
onProgress({
|
|
id: itemId,
|
|
progress: percent,
|
|
speed: '',
|
|
eta: '',
|
|
status: `${percent.toFixed(1)}%`,
|
|
currentPart: partNum,
|
|
totalParts: totalParts
|
|
});
|
|
}
|
|
// Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem
|
|
// streamlink-loop. 200 chunks reichen fuer normale Diagnose.
|
|
if (stderrBuffer.length > 200) stderrBuffer.shift();
|
|
// Letzte echte Errorzeile fuer User-Surface. "[ ... ] log lines"
|
|
// ueberspringen, "error: ..." bevorzugen damit nicht ein triviales
|
|
// INFO-Statement als User-facing-Fehler landet.
|
|
const lines = message.split('\n').map(l => l.trim()).filter(Boolean);
|
|
for (const line of lines) {
|
|
const lower = line.toLowerCase();
|
|
if (lower.startsWith('error:') || lower.includes('error:')) {
|
|
lastErrorLine = line;
|
|
} else if (!lastErrorLine && line.length > 0 && !lower.startsWith('[')) {
|
|
// Fallback: jede non-bracket non-INFO Zeile
|
|
lastErrorLine = line;
|
|
}
|
|
}
|
|
appendDebugLog('download-part-stderr', { itemId, message: message.trim() });
|
|
console.error('Streamlink error:', message);
|
|
}
|
|
});
|
|
|
|
proc.on('close', async (code) => {
|
|
clearInterval(progressInterval);
|
|
const outputError = await outputFinished;
|
|
processRegistration.release();
|
|
activeDownloads.delete(itemId);
|
|
|
|
if (outputError) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
resolve({ success: false, error: String(outputError) });
|
|
return;
|
|
}
|
|
|
|
if (cancelledItemIds.has(itemId)) {
|
|
cancelledItemIds.delete(itemId);
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
appendDebugLog('download-part-cancelled', { itemId, filename });
|
|
resolve({ success: false, error: tBackend('downloadCancelled') });
|
|
return;
|
|
}
|
|
|
|
if (code === 0 && fs.existsSync(partialFilename)) {
|
|
const stats = fs.statSync(partialFilename);
|
|
if (stats.size <= MIN_FILE_BYTES) {
|
|
const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) });
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
|
|
resolve({ success: false, error: tooSmall });
|
|
return;
|
|
}
|
|
|
|
const integrityResult = validateDownloadedFileIntegrity(partialFilename, expectedDurationSeconds);
|
|
if (!integrityResult.success) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
appendDebugLog('download-part-failed-integrity', {
|
|
itemId,
|
|
filename,
|
|
bytes: stats.size,
|
|
error: integrityResult.error
|
|
});
|
|
resolve(integrityResult);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
partialDownloadRegistry.commit(partialFilename, filename);
|
|
} catch (error) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
resolve({ success: false, error: String(error) });
|
|
return;
|
|
}
|
|
runtimeMetrics.downloadedBytesTotal += stats.size;
|
|
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
|
|
resolve({ success: true });
|
|
return;
|
|
}
|
|
|
|
// Volle stderr+stdout-History im Debug-Log fuer Forensik.
|
|
// Streamlink-Windows-Builds schreiben Errors gelegentlich auf
|
|
// stdout statt stderr ("No playable streams found on this URL"
|
|
// war historisch ein stdout-Error). Wir mergen beide Streams
|
|
// damit immer SICHTBAR ist was passiert.
|
|
const fullStderr = stderrBuffer.join('').trim();
|
|
// Letzte Error-/Warning-Zeile aus beiden Streams suchen, falls
|
|
// lastErrorLine noch leer ist (z.B. weil streamlink ohne Output
|
|
// mit Code 1 exited — was bei pre-flight-Auth-Fails passiert).
|
|
let userFacingError = lastErrorLine;
|
|
if (!userFacingError) {
|
|
const combined = fullStderr.split('\n').map(l => l.trim()).filter(Boolean);
|
|
userFacingError = combined.filter(l => l.toLowerCase().includes('error:')).pop()
|
|
|| combined.filter(l => !l.startsWith('[')).pop()
|
|
|| '';
|
|
}
|
|
if (!userFacingError) {
|
|
userFacingError = tBackend('streamlinkExitCode', { code: String(code ?? -1) });
|
|
}
|
|
appendDebugLog('download-part-failed', {
|
|
itemId, filename, code, error: userFacingError,
|
|
stderrTail: fullStderr.slice(-2000)
|
|
});
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
resolve({ success: false, error: userFacingError });
|
|
});
|
|
|
|
proc.on('error', async (err) => {
|
|
clearInterval(progressInterval);
|
|
await output.cancel();
|
|
processRegistration.release();
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
console.error('Process error:', err);
|
|
activeDownloads.delete(itemId);
|
|
const rawError = String(err);
|
|
const errorMessage = rawError.includes('ENOENT')
|
|
? tBackend('streamlinkNotFound')
|
|
: rawError;
|
|
appendDebugLog('download-part-process-error', { itemId, error: errorMessage, rawError });
|
|
resolve({ success: false, error: errorMessage });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-RECORD POLLER
|
|
// ==========================================
|
|
// Tracks the last-known live state of every streamer in
|
|
// config.auto_record_streamers. When a streamer transitions from
|
|
// offline -> live AND no live recording is already in flight for them,
|
|
// we auto-queue a live recording. Polling stops when no streamer has
|
|
// auto-record enabled.
|
|
const autoRecordLastLiveState = new Map<string, boolean>();
|
|
let autoRecordPollTimer: NodeJS.Timeout | null = null;
|
|
let autoRecordStartupTimer: NodeJS.Timeout | null = null;
|
|
let autoRecordPollInFlight = false;
|
|
let autoRecordLastRunAt = 0;
|
|
let autoRecordNextRunAt = 0;
|
|
let autoRecordLastTriggerCount = 0;
|
|
|
|
function stopAutoRecordPoller(): void {
|
|
if (autoRecordPollTimer) {
|
|
clearInterval(autoRecordPollTimer);
|
|
autoRecordPollTimer = null;
|
|
}
|
|
if (autoRecordStartupTimer) {
|
|
clearTimeout(autoRecordStartupTimer);
|
|
autoRecordStartupTimer = null;
|
|
}
|
|
}
|
|
|
|
function restartAutoRecordPoller(): void {
|
|
stopAutoRecordPoller();
|
|
const list = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : [];
|
|
if (list.length === 0) {
|
|
appendDebugLog('auto-record-poller-idle', { reason: 'no streamers' });
|
|
return;
|
|
}
|
|
const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds);
|
|
appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds });
|
|
autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000);
|
|
autoRecordPollTimer.unref?.();
|
|
autoRecordNextRunAt = Date.now() + seconds * 1000;
|
|
// Kick off an immediate first poll so a freshly-enabled streamer that's
|
|
// already live gets picked up without waiting a full interval.
|
|
autoRecordStartupTimer = setTimeout(() => {
|
|
autoRecordStartupTimer = null;
|
|
if (!appShutdownStarted) void runAutoRecordPoll();
|
|
}, 1500);
|
|
}
|
|
|
|
async function runAutoRecordPoll(): Promise<number> {
|
|
if (appShutdownStarted || autoRecordPollInFlight) return 0;
|
|
autoRecordPollInFlight = true;
|
|
let triggered = 0;
|
|
try {
|
|
const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : [];
|
|
for (const streamer of list) {
|
|
// Check if list still contains streamer (config may have changed
|
|
// mid-iteration via save-config from the renderer).
|
|
if (!config.auto_record_streamers.includes(streamer)) continue;
|
|
|
|
const info = await getLiveStreamInfo(streamer);
|
|
if (info === null) {
|
|
// Couldn't determine live state — skip this streamer this
|
|
// round. Don't update lastLiveState so a subsequent successful
|
|
// poll can still detect an offline->live transition cleanly.
|
|
continue;
|
|
}
|
|
|
|
const wasLive = autoRecordLastLiveState.get(streamer) === true;
|
|
autoRecordLastLiveState.set(streamer, info.isLive);
|
|
|
|
if (!info.isLive || wasLive) continue;
|
|
|
|
// offline -> live transition. Don't double-record if a live item
|
|
// already exists in the queue (e.g. user manually triggered it).
|
|
const alreadyRecording = downloadQueue.some((it) =>
|
|
it.isLive && it.streamer === streamer
|
|
&& (it.status === 'pending' || it.status === 'downloading')
|
|
);
|
|
if (alreadyRecording) {
|
|
appendDebugLog('auto-record-skip-already', { streamer });
|
|
continue;
|
|
}
|
|
|
|
const liveItem: QueueItem = {
|
|
id: generateQueueItemId(),
|
|
createdAt: new Date().toISOString(),
|
|
title: info.title || `${streamer} (LIVE)`,
|
|
url: `https://www.twitch.tv/${streamer}`,
|
|
date: new Date().toISOString(),
|
|
streamer,
|
|
duration_str: '0s',
|
|
status: 'pending',
|
|
progress: 0,
|
|
isLive: true
|
|
};
|
|
const addition = commitQueueItemWithResult(liveItem, false);
|
|
if (!addition.accepted) {
|
|
if (addition.reason === 'shutting-down' || addition.reason === 'persistence-failed') return triggered;
|
|
continue;
|
|
}
|
|
triggered++;
|
|
appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title });
|
|
|
|
if (!isDownloading) {
|
|
scheduleQueueProcessing();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('auto-record-poll-failed', String(e));
|
|
} finally {
|
|
autoRecordPollInFlight = false;
|
|
autoRecordLastRunAt = Date.now();
|
|
autoRecordLastTriggerCount = triggered;
|
|
const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds);
|
|
autoRecordNextRunAt = Date.now() + seconds * 1000;
|
|
}
|
|
return triggered;
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-VOD-DOWNLOAD POLLER
|
|
// ==========================================
|
|
// Periodically scans VOD listings of opted-in streamers and auto-queues
|
|
// any VOD that's (a) recent enough to be in scope, (b) not already
|
|
// downloaded, and (c) not already in the active queue. Cadence is
|
|
// minutes, not seconds — a VOD-listing scan is much heavier than a
|
|
// live-status check, and new VODs only appear after a stream ends, so
|
|
// minute-level lag is fine.
|
|
let autoVodPollTimer: NodeJS.Timeout | null = null;
|
|
let autoVodStartupTimer: NodeJS.Timeout | null = null;
|
|
let autoVodPollInFlight = false;
|
|
let autoVodLastRunAt = 0;
|
|
let autoVodNextRunAt = 0;
|
|
let autoVodLastQueuedCount = 0;
|
|
|
|
function stopAutoVodPoller(): void {
|
|
if (autoVodPollTimer) {
|
|
clearInterval(autoVodPollTimer);
|
|
autoVodPollTimer = null;
|
|
}
|
|
if (autoVodStartupTimer) {
|
|
clearTimeout(autoVodStartupTimer);
|
|
autoVodStartupTimer = null;
|
|
}
|
|
}
|
|
|
|
function restartAutoVodPoller(): void {
|
|
stopAutoVodPoller();
|
|
const list = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
|
if (list.length === 0) {
|
|
appendDebugLog('auto-vod-poller-idle', { reason: 'no streamers' });
|
|
return;
|
|
}
|
|
const minutes = (() => {
|
|
const n = Number(config.auto_vod_download_poll_minutes);
|
|
if (!Number.isFinite(n)) return 15;
|
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
|
})();
|
|
appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes });
|
|
autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000);
|
|
autoVodPollTimer.unref?.();
|
|
autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
|
|
autoVodStartupTimer = setTimeout(() => {
|
|
autoVodStartupTimer = null;
|
|
if (!appShutdownStarted) void runAutoVodPoll();
|
|
}, 5000);
|
|
}
|
|
|
|
async function runAutoVodPoll(): Promise<number> {
|
|
if (appShutdownStarted || autoVodPollInFlight) return 0;
|
|
autoVodPollInFlight = true;
|
|
let queuedCount = 0;
|
|
try {
|
|
const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
|
|
if (list.length === 0) return 0;
|
|
|
|
const maxAgeHours = (() => {
|
|
const n = Number(config.auto_vod_max_age_hours);
|
|
if (!Number.isFinite(n)) return 24;
|
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
|
})();
|
|
const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000;
|
|
|
|
const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []);
|
|
for (const streamer of list) {
|
|
if (!config.auto_vod_download_streamers.includes(streamer)) continue;
|
|
|
|
const userId = await getUserId(streamer);
|
|
if (!userId) {
|
|
appendDebugLog('auto-vod-skip-no-user', { streamer });
|
|
continue;
|
|
}
|
|
|
|
let vods: VOD[] = [];
|
|
try {
|
|
vods = await getVODs(userId, true);
|
|
} catch (e) {
|
|
appendDebugLog('auto-vod-list-failed', { streamer, error: String(e) });
|
|
continue;
|
|
}
|
|
if (!Array.isArray(vods) || vods.length === 0) continue;
|
|
|
|
for (const vod of vods) {
|
|
if (!vod || !vod.id || !vod.url) continue;
|
|
if (downloadedSet.has(vod.id)) continue;
|
|
|
|
const createdMs = Date.parse(vod.created_at || '');
|
|
if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue;
|
|
|
|
const queueItem: QueueItem = {
|
|
id: generateQueueItemId(),
|
|
createdAt: new Date().toISOString(),
|
|
title: vod.title || `${streamer} VOD ${vod.id}`,
|
|
url: vod.url,
|
|
date: vod.created_at,
|
|
streamer,
|
|
duration_str: vod.duration || '',
|
|
status: 'pending',
|
|
progress: 0
|
|
};
|
|
const addition = commitQueueItemWithResult(queueItem, false);
|
|
if (!addition.accepted) {
|
|
if (addition.reason === 'shutting-down' || addition.reason === 'persistence-failed') return queuedCount;
|
|
continue;
|
|
}
|
|
queuedCount++;
|
|
appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title });
|
|
|
|
if (config.discord_notify_vod_auto_queued) {
|
|
try {
|
|
await sendDiscordWebhook({
|
|
title: 'New VOD auto-queued',
|
|
description: `\`${streamer}\` published a new VOD — queued for download.`,
|
|
color: 'info',
|
|
fields: [
|
|
{ name: 'Title', value: queueItem.title, inline: false },
|
|
{ name: 'VOD ID', value: String(vod.id), inline: true },
|
|
{ name: 'URL', value: vod.url, inline: false }
|
|
]
|
|
});
|
|
} catch (_) { /* ignore webhook errors */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) {
|
|
scheduleQueueProcessing();
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('auto-vod-poll-failed', String(e));
|
|
} finally {
|
|
autoVodPollInFlight = false;
|
|
autoVodLastRunAt = Date.now();
|
|
autoVodLastQueuedCount = queuedCount;
|
|
const minutes = (() => {
|
|
const n = Number(config.auto_vod_download_poll_minutes);
|
|
if (!Number.isFinite(n)) return 15;
|
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
|
})();
|
|
autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
|
|
if (queuedCount > 0 && mainWindow) {
|
|
mainWindow.webContents.send('auto-vod-scan-completed', { queuedCount });
|
|
}
|
|
}
|
|
return queuedCount;
|
|
}
|
|
|
|
// ==========================================
|
|
// LIVE STATUS BATCH POLLER — for the sidebar live indicators
|
|
// ==========================================
|
|
// Background poller that asks "which of these streamers are live right
|
|
// now?" for every streamer in the user's list, in a single GQL roundtrip
|
|
// (per chunk of 50). Results are stamped into liveStatusByLogin and
|
|
// pushed to the renderer so the sidebar gets a red pulsing dot next to
|
|
// anyone currently broadcasting. Independent from the auto-record
|
|
// poller — that one only watches a small subset and needs title/game,
|
|
// this one just needs the boolean and covers everyone.
|
|
const liveStatusByLogin = new Map<string, boolean>();
|
|
let liveStatusPollTimer: NodeJS.Timeout | null = null;
|
|
let liveStatusPollInFlight = false;
|
|
const LIVE_STATUS_POLL_INTERVAL_MS = 60_000;
|
|
const LIVE_STATUS_BATCH_CHUNK_SIZE = 50;
|
|
|
|
async function fetchLiveStatusBatch(logins: string[]): Promise<Map<string, boolean>> {
|
|
const result = new Map<string, boolean>();
|
|
if (logins.length === 0) return result;
|
|
|
|
for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) {
|
|
const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE);
|
|
const vars: Record<string, string> = {};
|
|
const varDecls: string[] = [];
|
|
const aliases: string[] = [];
|
|
chunk.forEach((login, idx) => {
|
|
const varName = `l${idx}`;
|
|
vars[varName] = login;
|
|
varDecls.push(`$${varName}:String!`);
|
|
aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`);
|
|
});
|
|
const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`;
|
|
try {
|
|
const data = await fetchPublicTwitchGql<Record<string, { login: string; stream: { type: string } | null } | null>>(
|
|
query, vars
|
|
);
|
|
if (!data) continue;
|
|
for (const key of Object.keys(data)) {
|
|
const user = data[key];
|
|
if (!user || !user.login) continue;
|
|
result.set(normalizeLogin(user.login), user.stream?.type === 'live');
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runLiveStatusBatchPoll(): Promise<void> {
|
|
if (liveStatusPollInFlight) return;
|
|
liveStatusPollInFlight = true;
|
|
try {
|
|
const logins = ((config.streamers as string[]) || [])
|
|
.map((s) => normalizeLogin(s))
|
|
.filter((s): s is string => Boolean(s));
|
|
|
|
const changes: Array<{ login: string; isLive: boolean }> = [];
|
|
const watchedSet = new Set(logins);
|
|
|
|
// Always run the eviction pass FIRST — entries left over from a
|
|
// streamer that's no longer in the watch list must go regardless
|
|
// of whether we're about to fetch fresh data. Previously this
|
|
// ran inside the fetch branch only, so removing the last
|
|
// streamer left ghost entries in liveStatusByLogin until the
|
|
// next add.
|
|
for (const oldLogin of Array.from(liveStatusByLogin.keys())) {
|
|
if (!watchedSet.has(oldLogin)) {
|
|
liveStatusByLogin.delete(oldLogin);
|
|
changes.push({ login: oldLogin, isLive: false });
|
|
}
|
|
}
|
|
|
|
if (logins.length > 0) {
|
|
const fresh = await fetchLiveStatusBatch(logins);
|
|
for (const [login, isLive] of fresh.entries()) {
|
|
const prev = liveStatusByLogin.get(login);
|
|
if (prev !== isLive) changes.push({ login, isLive });
|
|
liveStatusByLogin.set(login, isLive);
|
|
}
|
|
}
|
|
|
|
if (mainWindow && changes.length > 0) {
|
|
// Renderer only consumes `changes` — initial state comes via
|
|
// the get-live-status-snapshot IPC at boot. Don't ship the
|
|
// full map on every tick (was ~1.5KB JSON per 60s with zero
|
|
// consumer-side use). Also skip the broadcast entirely when
|
|
// nothing actually changed.
|
|
mainWindow.webContents.send('live-status-batch-update', { changes });
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('live-status-poll-failed', String(e));
|
|
} finally {
|
|
liveStatusPollInFlight = false;
|
|
}
|
|
}
|
|
|
|
function stopLiveStatusPoller(): void {
|
|
if (liveStatusPollTimer) {
|
|
clearInterval(liveStatusPollTimer);
|
|
liveStatusPollTimer = null;
|
|
}
|
|
}
|
|
|
|
function restartLiveStatusPoller(): void {
|
|
stopLiveStatusPoller();
|
|
liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS);
|
|
liveStatusPollTimer.unref?.();
|
|
setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500);
|
|
}
|
|
|
|
// ==========================================
|
|
// CHAT REPLAY DOWNLOAD
|
|
// ==========================================
|
|
// Twitch retains chat replay alongside the VOD itself — same 7-60 day TTL.
|
|
// Anyone archiving the video usually wants the chat too. fetchVodChatReplay
|
|
// pulls the entire chat for a VOD via the public GQL endpoint, paginated
|
|
// via edge cursors (Twitch returns ~100 comments per page).
|
|
interface ChatReplayMessage {
|
|
id: string;
|
|
offset: number; // contentOffsetSeconds — when in the VOD
|
|
createdAt: string; // ISO timestamp
|
|
user: string; // display name
|
|
login: string; // login (lowercase)
|
|
color: string; // user chat color
|
|
text: string; // assembled message text
|
|
}
|
|
|
|
interface ChatReplayResult {
|
|
messages: ChatReplayMessage[];
|
|
truncated: boolean;
|
|
pages: number;
|
|
}
|
|
|
|
async function fetchVodChatReplay(
|
|
videoId: string,
|
|
onProgress?: (count: number) => void,
|
|
cancelCheck?: () => boolean
|
|
): Promise<ChatReplayResult> {
|
|
const messages: ChatReplayMessage[] = [];
|
|
let cursor: string | null = null;
|
|
let pages = 0;
|
|
let truncated = false;
|
|
// Hard cap to keep one runaway stream from filling memory. 200 pages =
|
|
// ~20k messages which covers typical 6-hour streams. Above that we
|
|
// stop and mark truncated.
|
|
const MAX_PAGES = 500;
|
|
|
|
type CommentNode = {
|
|
id: string;
|
|
contentOffsetSeconds: number;
|
|
createdAt: string;
|
|
message?: { fragments?: Array<{ text?: string }>; userColor?: string };
|
|
commenter?: { displayName?: string; login?: string };
|
|
};
|
|
type CommentEdge = { node: CommentNode; cursor: string };
|
|
type CommentsPage = {
|
|
video: { comments: { edges: CommentEdge[]; pageInfo: { hasNextPage: boolean } } } | null;
|
|
};
|
|
|
|
const query = 'query($videoID:ID!,$cursor:Cursor){video(id:$videoID){comments(contentOffsetSeconds:0,cursor:$cursor){edges{node{id contentOffsetSeconds createdAt message{fragments{text} userColor} commenter{displayName login}} cursor} pageInfo{hasNextPage}}}}';
|
|
|
|
while (pages < MAX_PAGES) {
|
|
if (cancelCheck && cancelCheck()) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
const data: CommentsPage | null = await fetchPublicTwitchGql<CommentsPage>(query, {
|
|
videoID: videoId,
|
|
cursor
|
|
});
|
|
if (!data || !data.video || !data.video.comments) break;
|
|
|
|
const edges: CommentEdge[] = Array.isArray(data.video.comments.edges) ? data.video.comments.edges : [];
|
|
for (const edge of edges) {
|
|
const node = edge.node;
|
|
const fragments = node.message?.fragments || [];
|
|
const text = fragments.map((f: { text?: string }) => (typeof f.text === 'string' ? f.text : '')).join('');
|
|
messages.push({
|
|
id: node.id,
|
|
offset: Number(node.contentOffsetSeconds) || 0,
|
|
createdAt: node.createdAt || '',
|
|
user: node.commenter?.displayName || '',
|
|
login: node.commenter?.login || '',
|
|
color: node.message?.userColor || '',
|
|
text
|
|
});
|
|
}
|
|
|
|
pages += 1;
|
|
if (onProgress) onProgress(messages.length);
|
|
|
|
const last: CommentEdge | undefined = edges[edges.length - 1];
|
|
if (!data.video.comments.pageInfo.hasNextPage || !last) break;
|
|
cursor = last.cursor;
|
|
}
|
|
|
|
if (pages >= MAX_PAGES) truncated = true;
|
|
return { messages, truncated, pages };
|
|
}
|
|
|
|
function chatReplayPathFor(vodFilePath: string): string {
|
|
// Strip the final extension and append .chat.json so the chat file
|
|
// lives next to the video and is easy to find.
|
|
const ext = path.extname(vodFilePath);
|
|
const base = ext ? vodFilePath.slice(0, -ext.length) : vodFilePath;
|
|
return `${base}.chat.json`;
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-CLEANUP
|
|
// ==========================================
|
|
// Targets old recording artifacts (.mp4/.ts/.mkv plus their sibling
|
|
// .chat.json/.chat.jsonl) older than auto_cleanup_days. Two scopes —
|
|
// live_only (only files inside a streamer/live/ subfolder, set-and-
|
|
// forget for auto-record users) or all (everything under the streamer
|
|
// folders). Two actions — delete or archive (move to a parallel
|
|
// archived/{streamer}/{YYYY-MM}/ tree). Archive is the safer default.
|
|
// Sibling chat files travel with the video so we don't end up with
|
|
// an orphan transcript.
|
|
interface CleanupCandidate {
|
|
videoPath: string;
|
|
sidecarPaths: string[];
|
|
streamer: string;
|
|
bytes: number;
|
|
ageDays: number;
|
|
}
|
|
interface CleanupReport {
|
|
enabled: boolean;
|
|
dryRun: boolean;
|
|
cutoffDays: number;
|
|
target: 'live_only' | 'all';
|
|
action: 'delete' | 'archive';
|
|
scannedAt: string;
|
|
candidates: number;
|
|
processed: number;
|
|
failed: number;
|
|
bytesFreed: number;
|
|
failures: Array<{ path: string; error: string }>;
|
|
}
|
|
|
|
const VIDEO_FILE_REGEX = /\.(mp4|ts|mkv|mov|avi)$/i;
|
|
|
|
function findCleanupCandidates(cutoffDays: number, target: 'live_only' | 'all'): CleanupCandidate[] {
|
|
const out: CleanupCandidate[] = [];
|
|
const root = config.download_path;
|
|
if (!root || !fs.existsSync(root)) return out;
|
|
const cutoffMs = Date.now() - cutoffDays * 24 * 60 * 60 * 1000;
|
|
const knownStreamers = new Set<string>(((config.streamers as string[]) || []).map((s) => s.toLowerCase()));
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return out;
|
|
}
|
|
|
|
const visit = (dir: string, streamer: string, mustBeUnderLive: boolean): void => {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
// Never walk back into the archived/ tree we own.
|
|
if (entry.name === 'archived') continue;
|
|
const enteringLive = entry.name === 'live';
|
|
visit(full, streamer, mustBeUnderLive && !enteringLive);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
if (!VIDEO_FILE_REGEX.test(entry.name)) continue;
|
|
if (mustBeUnderLive) continue; // live_only mode + we're not under live/
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(full);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (stat.mtimeMs > cutoffMs) continue;
|
|
|
|
// Find sibling chat files (same basename, .chat.json / .chat.jsonl)
|
|
const ext = path.extname(full);
|
|
const base = ext ? full.slice(0, -ext.length) : full;
|
|
const sidecars: string[] = [];
|
|
for (const sidecarExt of ['.chat.json', '.chat.jsonl']) {
|
|
const candidate = base + sidecarExt;
|
|
if (fs.existsSync(candidate)) sidecars.push(candidate);
|
|
}
|
|
|
|
out.push({
|
|
videoPath: full,
|
|
sidecarPaths: sidecars,
|
|
streamer,
|
|
bytes: stat.size,
|
|
ageDays: Math.floor((Date.now() - stat.mtimeMs) / (24 * 60 * 60 * 1000))
|
|
});
|
|
}
|
|
};
|
|
|
|
for (const top of topEntries) {
|
|
if (!top.isDirectory()) continue;
|
|
if (top.name === 'archived') continue; // never recurse into the archive tree
|
|
const lowered = top.name.toLowerCase();
|
|
const isKnown = knownStreamers.has(lowered) || top.name === 'Clips';
|
|
if (!isKnown) continue;
|
|
const folderPath = path.join(root, top.name);
|
|
// For live_only mode, we descend with mustBeUnderLive=true; the
|
|
// visit() call flips it to false the moment we enter a "live"
|
|
// subfolder. For "all" mode, mustBeUnderLive is false from the
|
|
// top so every video matches.
|
|
visit(folderPath, top.name, target === 'live_only');
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function archivePathForCleanup(streamer: string, originalPath: string, mtimeMs: number): string {
|
|
const root = config.download_path;
|
|
const date = new Date(mtimeMs);
|
|
const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
|
|
const dir = path.join(root, 'archived', streamer, monthKey);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return ensureUniqueFilename(path.join(dir, path.basename(originalPath)), null);
|
|
}
|
|
|
|
function runStorageCleanup(opts: { dryRun: boolean }): CleanupReport {
|
|
const report: CleanupReport = {
|
|
enabled: config.auto_cleanup_enabled === true,
|
|
dryRun: opts.dryRun,
|
|
cutoffDays: Number(config.auto_cleanup_days) || 30,
|
|
target: config.auto_cleanup_target === 'all' ? 'all' : 'live_only',
|
|
action: config.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
|
|
scannedAt: new Date().toISOString(),
|
|
candidates: 0,
|
|
processed: 0,
|
|
failed: 0,
|
|
bytesFreed: 0,
|
|
failures: []
|
|
};
|
|
|
|
const candidates = findCleanupCandidates(report.cutoffDays, report.target);
|
|
report.candidates = candidates.length;
|
|
if (opts.dryRun) {
|
|
for (const c of candidates) {
|
|
report.bytesFreed += c.bytes;
|
|
for (const sc of c.sidecarPaths) {
|
|
try { report.bytesFreed += fs.statSync(sc).size; } catch { /* ignore */ }
|
|
}
|
|
}
|
|
appendDebugLog('storage-cleanup-dry-run', { candidates: report.candidates, bytes: report.bytesFreed });
|
|
return report;
|
|
}
|
|
|
|
for (const c of candidates) {
|
|
const allPaths = [c.videoPath, ...c.sidecarPaths];
|
|
try {
|
|
if (report.action === 'delete') {
|
|
for (const p of allPaths) {
|
|
let bytes = 0;
|
|
try { bytes = fs.statSync(p).size; } catch { /* ignore */ }
|
|
fs.unlinkSync(p);
|
|
report.bytesFreed += bytes;
|
|
}
|
|
} else {
|
|
// Archive: keep the same basename, group by streamer + month.
|
|
const stat = fs.statSync(c.videoPath);
|
|
const archived = archivePathForCleanup(c.streamer, c.videoPath, stat.mtimeMs);
|
|
fs.renameSync(c.videoPath, archived);
|
|
report.bytesFreed += stat.size;
|
|
// Move sidecars to the same archive folder.
|
|
const archDir = path.dirname(archived);
|
|
for (const sc of c.sidecarPaths) {
|
|
try {
|
|
const dest = ensureUniqueFilename(path.join(archDir, path.basename(sc)), null);
|
|
fs.renameSync(sc, dest);
|
|
} catch (err) {
|
|
report.failures.push({ path: sc, error: String(err) });
|
|
}
|
|
}
|
|
}
|
|
report.processed += 1;
|
|
} catch (err) {
|
|
report.failed += 1;
|
|
report.failures.push({ path: c.videoPath, error: String(err) });
|
|
}
|
|
}
|
|
|
|
appendDebugLog('storage-cleanup-run', {
|
|
candidates: report.candidates,
|
|
processed: report.processed,
|
|
failed: report.failed,
|
|
bytes: report.bytesFreed,
|
|
action: report.action,
|
|
target: report.target
|
|
});
|
|
return report;
|
|
}
|
|
|
|
let autoCleanupTimer: NodeJS.Timeout | null = null;
|
|
let autoCleanupStartupTimer: NodeJS.Timeout | null = null;
|
|
let lastAutoCleanupAt = 0;
|
|
let startupToolsProvisionTimer: NodeJS.Timeout | null = null;
|
|
|
|
function stopStartupToolsProvisionTimer(): void {
|
|
if (startupToolsProvisionTimer) {
|
|
clearTimeout(startupToolsProvisionTimer);
|
|
startupToolsProvisionTimer = null;
|
|
}
|
|
}
|
|
|
|
function stopAutoCleanupTimer(): void {
|
|
if (autoCleanupTimer) {
|
|
clearInterval(autoCleanupTimer);
|
|
autoCleanupTimer = null;
|
|
}
|
|
if (autoCleanupStartupTimer) {
|
|
clearTimeout(autoCleanupStartupTimer);
|
|
autoCleanupStartupTimer = null;
|
|
}
|
|
}
|
|
|
|
function restartAutoCleanupTimer(): void {
|
|
stopAutoCleanupTimer();
|
|
if (appShutdownStarted) return;
|
|
if (!config.auto_cleanup_enabled) return;
|
|
// Run every 6 hours while the app is running. Skip the first cycle if
|
|
// the previous run was less than 6h ago to avoid hammering on every
|
|
// settings save.
|
|
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
|
autoCleanupTimer = setInterval(() => {
|
|
if (appShutdownStarted) return;
|
|
if (Date.now() - lastAutoCleanupAt < SIX_HOURS_MS) return;
|
|
lastAutoCleanupAt = Date.now();
|
|
try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); }
|
|
}, SIX_HOURS_MS);
|
|
autoCleanupTimer.unref?.();
|
|
|
|
// First run is delayed 60s so it doesn't compete with startup IO.
|
|
autoCleanupStartupTimer = setTimeout(() => {
|
|
autoCleanupStartupTimer = null;
|
|
if (appShutdownStarted || !config.auto_cleanup_enabled) return;
|
|
if (Date.now() - lastAutoCleanupAt < 60 * 1000) return;
|
|
lastAutoCleanupAt = Date.now();
|
|
try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); }
|
|
}, 60 * 1000);
|
|
}
|
|
|
|
// ==========================================
|
|
// STORAGE STATS
|
|
// ==========================================
|
|
// Walks the download folder once on demand and reports per-streamer disk
|
|
// usage so the user can see which streamers are eating their archive
|
|
// budget. Only enumerates direct subfolders that match a known streamer
|
|
// name (from config.streamers) plus a special "Clips" bucket. Refusing
|
|
// to recurse the entire filesystem means a user with a huge unrelated
|
|
// download_path doesn't pay for it here.
|
|
interface StreamerStorageEntry {
|
|
name: string;
|
|
fileCount: number;
|
|
totalBytes: number;
|
|
liveBytes: number;
|
|
chatBytes: number;
|
|
folderPath: string;
|
|
}
|
|
interface StorageStatsResult {
|
|
downloadPath: string;
|
|
rootExists: boolean;
|
|
freeBytes: number | null;
|
|
totalFiles: number;
|
|
totalBytes: number;
|
|
streamers: StreamerStorageEntry[];
|
|
extras: StreamerStorageEntry[];
|
|
scannedAt: string;
|
|
}
|
|
|
|
function walkFolderForStats(folderPath: string): { files: number; bytes: number; liveBytes: number; chatBytes: number } {
|
|
const result = { files: 0, bytes: 0, liveBytes: 0, chatBytes: 0 };
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(folderPath, entry.name);
|
|
try {
|
|
if (entry.isDirectory()) {
|
|
const sub = walkFolderForStats(full);
|
|
result.files += sub.files;
|
|
result.bytes += sub.bytes;
|
|
if (entry.name === 'live') {
|
|
result.liveBytes += sub.bytes;
|
|
}
|
|
} else if (entry.isFile()) {
|
|
const st = fs.statSync(full);
|
|
result.files += 1;
|
|
result.bytes += st.size;
|
|
if (/\.chat\.json(l)?$/i.test(entry.name)) {
|
|
result.chatBytes += st.size;
|
|
}
|
|
}
|
|
} catch {
|
|
// Symlink / permissions blip — skip the entry, continue.
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function computeStorageStats(): StorageStatsResult {
|
|
const root = config.download_path;
|
|
const result: StorageStatsResult = {
|
|
downloadPath: root,
|
|
rootExists: false,
|
|
freeBytes: null,
|
|
totalFiles: 0,
|
|
totalBytes: 0,
|
|
streamers: [],
|
|
extras: [],
|
|
scannedAt: new Date().toISOString()
|
|
};
|
|
|
|
if (!root || !fs.existsSync(root)) return result;
|
|
result.rootExists = true;
|
|
result.freeBytes = getFreeDiskBytes(root);
|
|
|
|
const knownStreamers = new Set<string>(
|
|
((config.streamers as string[]) || []).map((s) => s.toLowerCase())
|
|
);
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
|
|
for (const entry of topEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const full = path.join(root, entry.name);
|
|
const safeName = entry.name.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const isKnownStreamer = knownStreamers.has(safeName.toLowerCase());
|
|
// Treat Clips/ + anything that matches known streamers as a tracked
|
|
// bucket; everything else (random user folders) lives in `extras`.
|
|
const sub = walkFolderForStats(full);
|
|
const stats: StreamerStorageEntry = {
|
|
name: entry.name,
|
|
fileCount: sub.files,
|
|
totalBytes: sub.bytes,
|
|
liveBytes: sub.liveBytes,
|
|
chatBytes: sub.chatBytes,
|
|
folderPath: full
|
|
};
|
|
if (isKnownStreamer || entry.name === 'Clips') {
|
|
result.streamers.push(stats);
|
|
} else {
|
|
result.extras.push(stats);
|
|
}
|
|
result.totalFiles += sub.files;
|
|
result.totalBytes += sub.bytes;
|
|
}
|
|
|
|
// Largest first — that's what the user wants to see.
|
|
result.streamers.sort((a, b) => b.totalBytes - a.totalBytes);
|
|
result.extras.sort((a, b) => b.totalBytes - a.totalBytes);
|
|
return result;
|
|
}
|
|
|
|
// ==========================================
|
|
// ARCHIVE STATS — DASHBOARD AGGREGATION
|
|
// ==========================================
|
|
interface ArchiveStatsTopStreamer {
|
|
streamer: string;
|
|
bytes: number;
|
|
fileCount: number;
|
|
liveBytes: number;
|
|
vodBytes: number;
|
|
chatBytes: number;
|
|
}
|
|
interface ArchiveStatsDay { date: string; count: number; bytes: number }
|
|
interface ArchiveStatsBucket { label: string; count: number; bytes: number }
|
|
interface ArchiveStats {
|
|
totalFiles: number;
|
|
totalBytes: number;
|
|
liveCount: number;
|
|
liveBytes: number;
|
|
vodCount: number;
|
|
vodBytes: number;
|
|
chatCount: number;
|
|
chatBytes: number;
|
|
eventsCount: number;
|
|
streamerCount: number;
|
|
avgRecordingSizeBytes: number;
|
|
topStreamers: ArchiveStatsTopStreamer[];
|
|
dailyActivity: ArchiveStatsDay[];
|
|
sizeBuckets: ArchiveStatsBucket[];
|
|
scannedAt: string;
|
|
downloadPath: string;
|
|
rootExists: boolean;
|
|
}
|
|
|
|
const SIZE_BUCKETS: Array<{ label: string; min: number; max: number }> = [
|
|
{ label: '< 100 MB', min: 0, max: 100 * 1024 * 1024 },
|
|
{ label: '100 MB - 500 MB', min: 100 * 1024 * 1024, max: 500 * 1024 * 1024 },
|
|
{ label: '500 MB - 1 GB', min: 500 * 1024 * 1024, max: 1024 * 1024 * 1024 },
|
|
{ label: '1 GB - 5 GB', min: 1024 * 1024 * 1024, max: 5 * 1024 * 1024 * 1024 },
|
|
{ label: '5 GB - 10 GB', min: 5 * 1024 * 1024 * 1024, max: 10 * 1024 * 1024 * 1024 },
|
|
{ label: '> 10 GB', min: 10 * 1024 * 1024 * 1024, max: Number.POSITIVE_INFINITY }
|
|
];
|
|
|
|
type ArchiveFileType = 'live' | 'vod' | 'chat' | 'events' | 'other';
|
|
|
|
function classifyArchiveFile(relativePath: string): ArchiveFileType {
|
|
if (/\.chat\.jsonl?$/i.test(relativePath)) return 'chat';
|
|
if (/\.events\.jsonl$/i.test(relativePath)) return 'events';
|
|
const norm = relativePath.replace(/\\/g, '/').toLowerCase();
|
|
if (norm.startsWith('live/')) return 'live';
|
|
if (/\.(mp4|mkv|ts|m4v)$/i.test(relativePath)) return 'vod';
|
|
return 'other';
|
|
}
|
|
|
|
function extractFilenameDate(name: string): string | null {
|
|
const m = /(\d{4})-(\d{2})-(\d{2})/.exec(name);
|
|
if (!m) return null;
|
|
return `${m[1]}-${m[2]}-${m[3]}`;
|
|
}
|
|
|
|
function bucketIndexForSize(bytes: number): number {
|
|
for (let i = 0; i < SIZE_BUCKETS.length; i++) {
|
|
if (bytes < SIZE_BUCKETS[i].max) return i;
|
|
}
|
|
return SIZE_BUCKETS.length - 1;
|
|
}
|
|
|
|
interface ArchiveFileRecord { size: number; mtimeMs: number; type: ArchiveFileType; date: string }
|
|
|
|
function walkForArchiveStats(
|
|
folderPath: string,
|
|
relPrefix: string,
|
|
accum: { files: ArchiveFileRecord[] }
|
|
): void {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(folderPath, entry.name);
|
|
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
try {
|
|
if (entry.isDirectory()) {
|
|
walkForArchiveStats(full, rel, accum);
|
|
} else if (entry.isFile()) {
|
|
const st = fs.statSync(full);
|
|
const type = classifyArchiveFile(rel);
|
|
const dateFromName = extractFilenameDate(entry.name);
|
|
const date = dateFromName || new Date(st.mtimeMs).toISOString().slice(0, 10);
|
|
accum.files.push({ size: st.size, mtimeMs: st.mtimeMs, type, date });
|
|
}
|
|
} catch { /* permission blip — skip */ }
|
|
}
|
|
}
|
|
|
|
// Search a single file matches the live query. Empty query matches all.
|
|
// streamerFolder is the top-level directory under root (which we equate
|
|
// with the channel name); relativePath is everything below that.
|
|
interface ArchiveSearchFilter {
|
|
query: string;
|
|
type: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
|
streamer: string;
|
|
sinceMs: number | null;
|
|
untilMs: number | null;
|
|
sort: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
|
limit: number;
|
|
}
|
|
|
|
interface ArchiveSearchHit {
|
|
fullPath: string;
|
|
fileName: string;
|
|
streamer: string;
|
|
type: ArchiveFileType;
|
|
size: number;
|
|
mtimeMs: number;
|
|
chatPath: string | null;
|
|
eventsPath: string | null;
|
|
}
|
|
|
|
interface ArchiveSearchResult {
|
|
totalScanned: number;
|
|
matchCount: number;
|
|
truncated: boolean;
|
|
hits: ArchiveSearchHit[];
|
|
scannedAt: string;
|
|
rootExists: boolean;
|
|
}
|
|
|
|
function matchSearchFilter(
|
|
streamerFolder: string,
|
|
relativePath: string,
|
|
fileName: string,
|
|
fileSize: number,
|
|
mtimeMs: number,
|
|
type: ArchiveFileType,
|
|
filter: ArchiveSearchFilter
|
|
): boolean {
|
|
if (filter.type !== 'all' && filter.type !== type) return false;
|
|
if (filter.streamer && streamerFolder.toLowerCase() !== filter.streamer.toLowerCase()) return false;
|
|
if (filter.sinceMs !== null && mtimeMs < filter.sinceMs) return false;
|
|
if (filter.untilMs !== null && mtimeMs > filter.untilMs) return false;
|
|
if (filter.query) {
|
|
const q = filter.query.toLowerCase();
|
|
const hay = `${fileName} ${streamerFolder} ${relativePath}`.toLowerCase();
|
|
if (!hay.includes(q)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function searchArchive(filter: ArchiveSearchFilter): ArchiveSearchResult {
|
|
const root = config.download_path;
|
|
const result: ArchiveSearchResult = {
|
|
totalScanned: 0,
|
|
matchCount: 0,
|
|
truncated: false,
|
|
hits: [],
|
|
scannedAt: new Date().toISOString(),
|
|
rootExists: false
|
|
};
|
|
if (!root || !fs.existsSync(root)) return result;
|
|
result.rootExists = true;
|
|
|
|
const maxHits = Math.max(10, Math.min(2000, Math.floor(filter.limit) || 200));
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
|
|
// To attach chat/events sibling paths to a recording hit, we collect
|
|
// every file in a streamer's tree first, then make a second pass to
|
|
// pair up companions by stripping the .mp4 base.
|
|
for (const entry of topEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const streamerFolder = entry.name;
|
|
const streamerRoot = path.join(root, streamerFolder);
|
|
const filesInTree: Array<{ fullPath: string; rel: string; name: string; size: number; mtimeMs: number; type: ArchiveFileType }> = [];
|
|
const accum: { files: ArchiveFileRecord[] } = { files: [] };
|
|
// We re-walk here instead of reusing walkForArchiveStats because
|
|
// we need the full path + rel path on each file, not just the
|
|
// type/size aggregates. The cost is one redundant tree walk per
|
|
// search; acceptable for an interactive search.
|
|
const walkWithPaths = (folderPath: string, relPrefix: string): void => {
|
|
let entries2: fs.Dirent[];
|
|
try {
|
|
entries2 = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
} catch { return; }
|
|
for (const e2 of entries2) {
|
|
const full = path.join(folderPath, e2.name);
|
|
const rel = relPrefix ? `${relPrefix}/${e2.name}` : e2.name;
|
|
try {
|
|
if (e2.isDirectory()) {
|
|
walkWithPaths(full, rel);
|
|
} else if (e2.isFile()) {
|
|
const st = fs.statSync(full);
|
|
const type = classifyArchiveFile(rel);
|
|
filesInTree.push({ fullPath: full, rel, name: e2.name, size: st.size, mtimeMs: st.mtimeMs, type });
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
};
|
|
walkWithPaths(streamerRoot, '');
|
|
|
|
if (filesInTree.length === 0) continue;
|
|
result.totalScanned += filesInTree.length;
|
|
|
|
// Build a quick lookup so a recording file can attach its sibling
|
|
// .chat.* and .events.jsonl by stripping the .mp4/.mkv extension.
|
|
const companionByBase = new Map<string, { chat: string | null; events: string | null }>();
|
|
for (const f of filesInTree) {
|
|
if (f.type !== 'chat' && f.type !== 'events') continue;
|
|
// Strip companion suffix to get the base name shared with the
|
|
// recording: foo.mp4 + foo.chat.jsonl + foo.events.jsonl.
|
|
const base = f.fullPath.replace(/\.chat\.jsonl?$/i, '').replace(/\.events\.jsonl$/i, '');
|
|
const existing = companionByBase.get(base) || { chat: null, events: null };
|
|
if (f.type === 'chat') existing.chat = f.fullPath;
|
|
else if (f.type === 'events') existing.events = f.fullPath;
|
|
companionByBase.set(base, existing);
|
|
}
|
|
|
|
for (const f of filesInTree) {
|
|
// We only surface recordings (live/vod) as search hits — chat
|
|
// and events files attach as companions and don't appear as
|
|
// standalone rows. Users searching for chat usually want the
|
|
// recording it belongs to anyway.
|
|
if (f.type !== 'live' && f.type !== 'vod') continue;
|
|
if (!matchSearchFilter(streamerFolder, f.rel, f.name, f.size, f.mtimeMs, f.type, filter)) continue;
|
|
|
|
const recordingBase = f.fullPath.replace(/\.(mp4|mkv|ts|m4v)$/i, '');
|
|
const companions = companionByBase.get(recordingBase) || { chat: null, events: null };
|
|
|
|
result.hits.push({
|
|
fullPath: f.fullPath,
|
|
fileName: f.name,
|
|
streamer: streamerFolder,
|
|
type: f.type,
|
|
size: f.size,
|
|
mtimeMs: f.mtimeMs,
|
|
chatPath: companions.chat,
|
|
eventsPath: companions.events
|
|
});
|
|
result.matchCount++;
|
|
}
|
|
}
|
|
|
|
// Sort then truncate. We sort the FULL match set (not the truncated
|
|
// one) so the user gets the genuinely largest/newest results, not
|
|
// arbitrary order.
|
|
const cmp: Record<typeof filter.sort, (a: ArchiveSearchHit, b: ArchiveSearchHit) => number> = {
|
|
date_desc: (a, b) => b.mtimeMs - a.mtimeMs,
|
|
date_asc: (a, b) => a.mtimeMs - b.mtimeMs,
|
|
size_desc: (a, b) => b.size - a.size,
|
|
size_asc: (a, b) => a.size - b.size,
|
|
name_asc: (a, b) => a.fileName.localeCompare(b.fileName)
|
|
};
|
|
result.hits.sort(cmp[filter.sort] || cmp.date_desc);
|
|
if (result.hits.length > maxHits) {
|
|
result.truncated = true;
|
|
result.hits = result.hits.slice(0, maxHits);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function computeArchiveStats(): ArchiveStats {
|
|
const root = config.download_path;
|
|
const stats: ArchiveStats = {
|
|
totalFiles: 0,
|
|
totalBytes: 0,
|
|
liveCount: 0,
|
|
liveBytes: 0,
|
|
vodCount: 0,
|
|
vodBytes: 0,
|
|
chatCount: 0,
|
|
chatBytes: 0,
|
|
eventsCount: 0,
|
|
streamerCount: 0,
|
|
avgRecordingSizeBytes: 0,
|
|
topStreamers: [],
|
|
dailyActivity: [],
|
|
sizeBuckets: SIZE_BUCKETS.map((b) => ({ label: b.label, count: 0, bytes: 0 })),
|
|
scannedAt: new Date().toISOString(),
|
|
downloadPath: root || '',
|
|
rootExists: false
|
|
};
|
|
if (!root || !fs.existsSync(root)) return stats;
|
|
stats.rootExists = true;
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return stats;
|
|
}
|
|
|
|
const perStreamer = new Map<string, ArchiveStatsTopStreamer>();
|
|
const dailyMap = new Map<string, ArchiveStatsDay>();
|
|
let recordingCount = 0;
|
|
let recordingBytes = 0;
|
|
|
|
for (const entry of topEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const streamerFolder = entry.name;
|
|
const full = path.join(root, streamerFolder);
|
|
const accum: { files: ArchiveFileRecord[] } = { files: [] };
|
|
walkForArchiveStats(full, '', accum);
|
|
if (accum.files.length === 0) continue;
|
|
|
|
const ts: ArchiveStatsTopStreamer = {
|
|
streamer: streamerFolder,
|
|
bytes: 0,
|
|
fileCount: 0,
|
|
liveBytes: 0,
|
|
vodBytes: 0,
|
|
chatBytes: 0
|
|
};
|
|
|
|
for (const f of accum.files) {
|
|
stats.totalFiles++;
|
|
stats.totalBytes += f.size;
|
|
ts.fileCount++;
|
|
ts.bytes += f.size;
|
|
|
|
if (f.type === 'live') {
|
|
stats.liveCount++;
|
|
stats.liveBytes += f.size;
|
|
ts.liveBytes += f.size;
|
|
recordingCount++;
|
|
recordingBytes += f.size;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].count++;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size;
|
|
} else if (f.type === 'vod') {
|
|
stats.vodCount++;
|
|
stats.vodBytes += f.size;
|
|
ts.vodBytes += f.size;
|
|
recordingCount++;
|
|
recordingBytes += f.size;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].count++;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size;
|
|
} else if (f.type === 'chat') {
|
|
stats.chatCount++;
|
|
stats.chatBytes += f.size;
|
|
ts.chatBytes += f.size;
|
|
} else if (f.type === 'events') {
|
|
stats.eventsCount++;
|
|
}
|
|
|
|
if (f.type === 'live' || f.type === 'vod') {
|
|
const cur = dailyMap.get(f.date) || { date: f.date, count: 0, bytes: 0 };
|
|
cur.count++;
|
|
cur.bytes += f.size;
|
|
dailyMap.set(f.date, cur);
|
|
}
|
|
}
|
|
|
|
perStreamer.set(streamerFolder, ts);
|
|
}
|
|
|
|
stats.streamerCount = perStreamer.size;
|
|
stats.avgRecordingSizeBytes = recordingCount > 0 ? Math.round(recordingBytes / recordingCount) : 0;
|
|
stats.topStreamers = Array.from(perStreamer.values())
|
|
.sort((a, b) => b.bytes - a.bytes)
|
|
.slice(0, 10);
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const days: ArchiveStatsDay[] = [];
|
|
for (let i = 29; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const key = d.toISOString().slice(0, 10);
|
|
days.push(dailyMap.get(key) || { date: key, count: 0, bytes: 0 });
|
|
}
|
|
stats.dailyActivity = days;
|
|
|
|
return stats;
|
|
}
|
|
|
|
// ==========================================
|
|
// DISCORD WEBHOOK NOTIFICATIONS
|
|
// ==========================================
|
|
// Fire-and-forget webhook for "stream went live", "recording finished",
|
|
// "VOD download complete". Useful when the user runs the app on a
|
|
// dedicated archival machine and isn't checking it directly.
|
|
type DiscordEmbedColor = 'live' | 'success' | 'info';
|
|
const DISCORD_EMBED_COLORS: Record<DiscordEmbedColor, number> = {
|
|
live: 0xE91916, // red — recording started
|
|
success: 0x00C853, // green — completed cleanly
|
|
info: 0x9146FF // twitch purple — neutral
|
|
};
|
|
|
|
function isAcceptableDiscordWebhook(url: string): boolean {
|
|
const trimmed = (url || '').trim();
|
|
if (!trimmed) return false;
|
|
return /^https:\/\/(?:[a-z]+\.)?discord(?:app)?\.com\/api\/webhooks\//i.test(trimmed);
|
|
}
|
|
|
|
async function sendDiscordWebhook(payload: {
|
|
title: string;
|
|
description: string;
|
|
color: DiscordEmbedColor;
|
|
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
}): Promise<void> {
|
|
const url = discordWebhookUrl.trim();
|
|
if (!isAcceptableDiscordWebhook(url)) return;
|
|
|
|
const body = {
|
|
username: 'Twitch VOD Manager',
|
|
embeds: [
|
|
{
|
|
title: payload.title.slice(0, 256),
|
|
description: payload.description.slice(0, 4096),
|
|
color: DISCORD_EMBED_COLORS[payload.color],
|
|
fields: (payload.fields || []).slice(0, 25).map((f) => ({
|
|
name: (f.name || '').slice(0, 256),
|
|
value: (f.value || '').slice(0, 1024),
|
|
inline: f.inline === true
|
|
})),
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
]
|
|
};
|
|
|
|
try {
|
|
await axios.post(url, body, { timeout: 8000, headers: { 'Content-Type': 'application/json' } });
|
|
appendDebugLog('discord-webhook-ok', { title: payload.title, color: payload.color });
|
|
} catch (e) {
|
|
appendDebugLog('discord-webhook-failed', { title: payload.title, error: String(e) });
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// LIVE RECORDING EVENTS LOG
|
|
// ==========================================
|
|
// Sibling .events.jsonl file alongside each live recording. Tracks
|
|
// recording start/end + Twitch metadata changes (title / game) that
|
|
// happen while the stream is being captured. Useful when seeking
|
|
// inside a long archived stream — tells you "at minute 142 he switched
|
|
// from Just Chatting to Counter-Strike". Independent of chat capture
|
|
// (lives even if capture_live_chat is off) and uses JSON Lines for
|
|
// the same crash-safety reason.
|
|
interface LiveEventTracker {
|
|
itemId: string;
|
|
streamer: string;
|
|
eventsPath: string;
|
|
fileHandle: number | null;
|
|
startedAt: number; // Date.now() when recording started
|
|
lastTitle: string;
|
|
lastGame: string;
|
|
closing: boolean;
|
|
}
|
|
|
|
const liveEventTrackers = new Map<string, LiveEventTracker>();
|
|
let liveEventsPollTimer: NodeJS.Timeout | null = null;
|
|
|
|
function eventsLogPathFor(videoPath: string): string {
|
|
const ext = path.extname(videoPath);
|
|
const base = ext ? videoPath.slice(0, -ext.length) : videoPath;
|
|
return `${base}.events.jsonl`;
|
|
}
|
|
|
|
function appendEventLine(tracker: LiveEventTracker, payload: Record<string, unknown>): void {
|
|
if (tracker.fileHandle === null) return;
|
|
const line = JSON.stringify({ t: new Date().toISOString(), ...payload }) + '\n';
|
|
try {
|
|
fs.writeSync(tracker.fileHandle, line);
|
|
} catch (e) {
|
|
appendDebugLog('events-log-write-failed', { itemId: tracker.itemId, error: String(e) });
|
|
}
|
|
}
|
|
|
|
function startLiveEventsTracker(itemId: string, streamer: string, videoPath: string, initialTitle: string, initialGame: string): LiveEventTracker | null {
|
|
const eventsPath = eventsLogPathFor(videoPath);
|
|
let fd: number;
|
|
try {
|
|
fd = fs.openSync(eventsPath, 'w');
|
|
} catch (e) {
|
|
appendDebugLog('events-log-open-failed', { itemId, eventsPath, error: String(e) });
|
|
return null;
|
|
}
|
|
|
|
const tracker: LiveEventTracker = {
|
|
itemId,
|
|
streamer,
|
|
eventsPath,
|
|
fileHandle: fd,
|
|
startedAt: Date.now(),
|
|
lastTitle: initialTitle,
|
|
lastGame: initialGame,
|
|
closing: false
|
|
};
|
|
|
|
appendEventLine(tracker, {
|
|
type: 'recording_start',
|
|
streamer,
|
|
title: initialTitle,
|
|
game: initialGame
|
|
});
|
|
|
|
liveEventTrackers.set(itemId, tracker);
|
|
ensureLiveEventsPollTimer();
|
|
return tracker;
|
|
}
|
|
|
|
function stopLiveEventsTracker(itemId: string, finalNote?: { success: boolean; durationMs: number; error?: string }): void {
|
|
const tracker = liveEventTrackers.get(itemId);
|
|
if (!tracker || tracker.closing) return;
|
|
tracker.closing = true;
|
|
|
|
appendEventLine(tracker, {
|
|
type: 'recording_end',
|
|
durationSeconds: finalNote ? Math.floor(finalNote.durationMs / 1000) : Math.floor((Date.now() - tracker.startedAt) / 1000),
|
|
success: finalNote?.success === true,
|
|
error: finalNote?.error || ''
|
|
});
|
|
|
|
if (tracker.fileHandle !== null) {
|
|
try { fs.closeSync(tracker.fileHandle); } catch { /* ignore */ }
|
|
tracker.fileHandle = null;
|
|
}
|
|
liveEventTrackers.delete(itemId);
|
|
|
|
if (liveEventTrackers.size === 0 && liveEventsPollTimer) {
|
|
clearInterval(liveEventsPollTimer);
|
|
liveEventsPollTimer = null;
|
|
}
|
|
}
|
|
|
|
function ensureLiveEventsPollTimer(): void {
|
|
if (liveEventsPollTimer) return;
|
|
// Same cadence as auto-record polling; metadata changes don't need
|
|
// sub-minute resolution and we want to keep API load bounded.
|
|
liveEventsPollTimer = setInterval(() => { void pollLiveEventsForChanges(); }, 60 * 1000);
|
|
liveEventsPollTimer.unref?.();
|
|
}
|
|
|
|
async function pollLiveEventsForChanges(): Promise<void> {
|
|
if (liveEventTrackers.size === 0) return;
|
|
for (const tracker of liveEventTrackers.values()) {
|
|
if (tracker.closing) continue;
|
|
const info = await getLiveStreamInfo(tracker.streamer);
|
|
if (!info || !info.isLive) continue;
|
|
const currentTitle = info.title || '';
|
|
const currentGame = info.gameName || '';
|
|
|
|
if (currentTitle !== tracker.lastTitle) {
|
|
appendEventLine(tracker, {
|
|
type: 'title_change',
|
|
from: tracker.lastTitle,
|
|
to: currentTitle
|
|
});
|
|
tracker.lastTitle = currentTitle;
|
|
}
|
|
if (currentGame !== tracker.lastGame) {
|
|
appendEventLine(tracker, {
|
|
type: 'game_change',
|
|
from: tracker.lastGame,
|
|
to: currentGame
|
|
});
|
|
tracker.lastGame = currentGame;
|
|
// Also fire a webhook ping if the user wants it. Game changes
|
|
// matter more than title micro-tweaks, so we only ping for game.
|
|
if (config.discord_notify_live_start) {
|
|
void sendDiscordWebhook({
|
|
title: `Game change: ${tracker.streamer}`,
|
|
description: `Now playing **${currentGame || 'unknown'}**`,
|
|
color: 'info',
|
|
fields: [
|
|
{ name: 'Title', value: currentTitle || '-', inline: false }
|
|
]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// LIVE CHAT CAPTURE (during live recording)
|
|
// ==========================================
|
|
// Companion to fetchVodChatReplay: while a stream is being recorded live,
|
|
// open an anonymous IRC connection to Twitch chat and append every message
|
|
// to a sibling .chat.jsonl file. Format is JSON Lines (one JSON object per
|
|
// line) so a partial / killed write still parses correctly — important
|
|
// because live recordings can run for many hours and we don't want to
|
|
// keep the full chat in memory.
|
|
interface LiveChatSession {
|
|
streamer: string;
|
|
outputPath: string;
|
|
socket: TLSSocket;
|
|
fileHandle: number | null;
|
|
closing: boolean;
|
|
messageCount: number;
|
|
buffer: string;
|
|
}
|
|
|
|
const TWITCH_IRC_HOST = 'irc.chat.twitch.tv';
|
|
const TWITCH_IRC_PORT = 6697;
|
|
|
|
function liveChatPathFor(videoPath: string): string {
|
|
const ext = path.extname(videoPath);
|
|
const base = ext ? videoPath.slice(0, -ext.length) : videoPath;
|
|
return `${base}.chat.jsonl`;
|
|
}
|
|
|
|
function startLiveChatCapture(streamer: string, outputPath: string): LiveChatSession | null {
|
|
const channelName = normalizeLogin(streamer);
|
|
if (!channelName) return null;
|
|
|
|
let fd: number;
|
|
try {
|
|
fd = fs.openSync(outputPath, 'w');
|
|
} catch (e) {
|
|
appendDebugLog('chat-capture-open-failed', { streamer: channelName, outputPath, error: String(e) });
|
|
return null;
|
|
}
|
|
|
|
const session: LiveChatSession = {
|
|
streamer: channelName,
|
|
outputPath,
|
|
socket: tlsConnect({ host: TWITCH_IRC_HOST, port: TWITCH_IRC_PORT, servername: TWITCH_IRC_HOST }),
|
|
fileHandle: fd,
|
|
closing: false,
|
|
messageCount: 0,
|
|
buffer: ''
|
|
};
|
|
|
|
// Write a header line so the file is self-describing even if zero
|
|
// messages arrive (e.g. silent stream, immediate disconnect).
|
|
const header = {
|
|
type: 'header',
|
|
streamer: channelName,
|
|
startedAt: new Date().toISOString(),
|
|
format: 'twitch-vod-manager-chat-jsonl-v1'
|
|
};
|
|
try { fs.writeSync(fd, JSON.stringify(header) + '\n'); } catch { /* ignore */ }
|
|
|
|
session.socket.on('secureConnect', () => {
|
|
// Anonymous Twitch IRC: any nick prefixed with "justinfan" is
|
|
// accepted without a password. Random suffix avoids collisions.
|
|
const nick = `justinfan${Math.floor(Math.random() * 100000)}`;
|
|
try {
|
|
session.socket.write('CAP REQ :twitch.tv/tags twitch.tv/commands\r\n');
|
|
session.socket.write(`NICK ${nick}\r\n`);
|
|
session.socket.write(`JOIN #${channelName}\r\n`);
|
|
} catch (e) {
|
|
appendDebugLog('chat-capture-handshake-failed', { streamer: channelName, error: String(e) });
|
|
}
|
|
appendDebugLog('chat-capture-connected', { streamer: channelName, nick });
|
|
});
|
|
|
|
session.socket.on('data', (chunk: Buffer) => {
|
|
session.buffer += chunk.toString('utf-8');
|
|
const lines = session.buffer.split('\r\n');
|
|
session.buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
|
handleIrcLine(session, line);
|
|
}
|
|
});
|
|
|
|
session.socket.on('error', (err: Error) => {
|
|
appendDebugLog('chat-capture-socket-error', { streamer: channelName, error: String(err) });
|
|
});
|
|
|
|
session.socket.on('close', () => {
|
|
if (!session.closing) {
|
|
appendDebugLog('chat-capture-disconnected', { streamer: channelName, messages: session.messageCount });
|
|
}
|
|
if (session.fileHandle !== null) {
|
|
try { fs.closeSync(session.fileHandle); } catch { /* ignore */ }
|
|
session.fileHandle = null;
|
|
}
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
function handleIrcLine(session: LiveChatSession, line: string): void {
|
|
if (!line) return;
|
|
if (line.startsWith('PING')) {
|
|
try { session.socket.write('PONG' + line.slice(4) + '\r\n'); } catch { /* ignore */ }
|
|
return;
|
|
}
|
|
|
|
let rest = line;
|
|
let tagsStr = '';
|
|
if (rest.startsWith('@')) {
|
|
const sp = rest.indexOf(' ');
|
|
if (sp < 0) return;
|
|
tagsStr = rest.slice(1, sp);
|
|
rest = rest.slice(sp + 1);
|
|
}
|
|
let prefix = '';
|
|
if (rest.startsWith(':')) {
|
|
const sp = rest.indexOf(' ');
|
|
if (sp < 0) return;
|
|
prefix = rest.slice(1, sp);
|
|
rest = rest.slice(sp + 1);
|
|
}
|
|
const cmdSp = rest.indexOf(' ');
|
|
const command = cmdSp < 0 ? rest : rest.slice(0, cmdSp);
|
|
const params = cmdSp < 0 ? '' : rest.slice(cmdSp + 1);
|
|
|
|
if (command !== 'PRIVMSG' && command !== 'USERNOTICE' && command !== 'CLEARCHAT' && command !== 'CLEARMSG') return;
|
|
|
|
const colonIdx = params.indexOf(' :');
|
|
const text = colonIdx >= 0 ? params.slice(colonIdx + 2) : '';
|
|
|
|
const tags: Record<string, string> = {};
|
|
if (tagsStr) {
|
|
for (const pair of tagsStr.split(';')) {
|
|
const eq = pair.indexOf('=');
|
|
if (eq < 0) continue;
|
|
tags[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
}
|
|
}
|
|
|
|
const login = (prefix.split('!')[0] || tags['login'] || '').toLowerCase();
|
|
const message = {
|
|
t: new Date().toISOString(),
|
|
type: command === 'PRIVMSG' ? 'msg' : (command === 'USERNOTICE' ? 'notice' : command.toLowerCase()),
|
|
u: tags['display-name'] || login,
|
|
login,
|
|
color: tags['color'] || '',
|
|
msg: text,
|
|
badges: tags['badges'] || '',
|
|
bits: tags['bits'] || '',
|
|
msgId: tags['msg-id'] || '',
|
|
systemMsg: (tags['system-msg'] || '').replace(/\\s/g, ' ')
|
|
};
|
|
|
|
if (session.fileHandle === null) return;
|
|
try {
|
|
fs.writeSync(session.fileHandle, JSON.stringify(message) + '\n');
|
|
session.messageCount++;
|
|
} catch (e) {
|
|
appendDebugLog('chat-capture-write-failed', { error: String(e) });
|
|
}
|
|
}
|
|
|
|
function stopLiveChatCapture(session: LiveChatSession): void {
|
|
if (session.closing) return;
|
|
session.closing = true;
|
|
appendDebugLog('chat-capture-stopping', { streamer: session.streamer, messages: session.messageCount });
|
|
try { session.socket.write(`PART #${session.streamer}\r\nQUIT\r\n`); } catch { /* ignore */ }
|
|
try { session.socket.end(); } catch { /* ignore */ }
|
|
setTimeout(() => {
|
|
try { session.socket.destroy(); } catch { /* ignore */ }
|
|
}, 500);
|
|
}
|
|
|
|
async function downloadLiveStream(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusPreparingTools'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
if (!streamlinkReady) {
|
|
return { success: false, error: tBackend('streamlinkAutoInstallFailed') };
|
|
}
|
|
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusDownloadStarted'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
const safeStreamer = (item.streamer || 'live').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const now = new Date();
|
|
const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`;
|
|
const timeStr = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`;
|
|
const folder = path.join(config.download_path, safeStreamer, 'live');
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const baseFilename = ensureUniqueFilename(
|
|
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
|
|
item.id
|
|
);
|
|
|
|
// Optional: anonymous IRC chat capture for the duration of the
|
|
// recording. Sibling .chat.jsonl file. We start it BEFORE streamlink
|
|
// so the very first chat lines after JOIN aren't dropped, and stop it
|
|
// AFTER streamlink exits so trailing messages (e.g. "stream offline"
|
|
// user reactions) are still captured. Chat + events span the whole
|
|
// multi-part recording (chat is an independent IRC connection, events
|
|
// is an independent poller), so they stay alive across resume cycles.
|
|
let chatSession: LiveChatSession | null = null;
|
|
if (config.capture_live_chat) {
|
|
const chatPath = liveChatPathFor(baseFilename);
|
|
chatSession = startLiveChatCapture(item.streamer, chatPath);
|
|
}
|
|
|
|
let eventsTracker: LiveEventTracker | null = null;
|
|
if (config.log_stream_events) {
|
|
let initialTitle = '';
|
|
let initialGame = '';
|
|
try {
|
|
const info = await getLiveStreamInfo(item.streamer);
|
|
if (info) {
|
|
initialTitle = info.title || '';
|
|
initialGame = info.gameName || '';
|
|
}
|
|
} catch { /* ignore */ }
|
|
eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame);
|
|
}
|
|
|
|
if (config.discord_notify_live_start) {
|
|
void sendDiscordWebhook({
|
|
title: `Recording started: ${item.streamer}`,
|
|
description: item.title || `${item.streamer} is live`,
|
|
color: 'live',
|
|
fields: [
|
|
{ name: 'URL', value: item.url, inline: false },
|
|
{ name: 'Output', value: path.basename(baseFilename), inline: false }
|
|
]
|
|
});
|
|
}
|
|
|
|
const recordingStartedAt = Date.now();
|
|
const BYTES_FRESH_MS = 30_000;
|
|
const MIN_HEALTHY_PART_MS = 30_000;
|
|
const RESUME_WAIT_MS = 10_000;
|
|
const MAX_RESUME_ATTEMPTS = 5;
|
|
|
|
// Total-recording byte tracking. Each resumed part starts streamlink
|
|
// fresh, so its byte counter resets to 0; we keep accumulatedBytes
|
|
// across parts so the meta line shows the TOTAL recorded size, not
|
|
// just the current part. Same for elapsed — recordingStartedAt is the
|
|
// overall start, not per-part.
|
|
let accumulatedBytes = 0;
|
|
let currentPartBytes = 0;
|
|
let lastBytesValue = 0;
|
|
let lastBytesAdvancedAt = 0;
|
|
let lastEmittedProgress: DownloadProgress | null = null;
|
|
|
|
const computeHealth = (): 'ok' | 'stale' | 'unknown' => {
|
|
if (lastBytesAdvancedAt === 0) return 'unknown';
|
|
return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale';
|
|
};
|
|
|
|
const wrappedProgress = (p: DownloadProgress): void => {
|
|
const bytes = Number(p.downloadedBytes) || 0;
|
|
if (bytes > lastBytesValue) {
|
|
lastBytesValue = bytes;
|
|
lastBytesAdvancedAt = Date.now();
|
|
}
|
|
currentPartBytes = bytes;
|
|
const totalBytes = accumulatedBytes + currentPartBytes;
|
|
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
|
const avgBitrateMbps = (totalBytes * 8) / elapsed / 1_000_000;
|
|
const parts: string[] = [formatDuration(elapsed)];
|
|
if (totalBytes > 0) parts.push(formatBytes(totalBytes));
|
|
if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
|
|
const next = {
|
|
...p,
|
|
speed: '',
|
|
eta: '',
|
|
status: parts.join(' · '),
|
|
recordingHealth: computeHealth()
|
|
};
|
|
lastEmittedProgress = next;
|
|
onProgress(next);
|
|
};
|
|
|
|
// Health-tick: re-emit the most recent progress every 10s so the
|
|
// renderer's health badge updates even when streamlink is silent.
|
|
// Without this, a streamlink hung on a buffer-stall would keep showing
|
|
// 'ok' until the next real byte event.
|
|
const healthTick = setInterval(() => {
|
|
if (!lastEmittedProgress) return;
|
|
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
|
|
lastEmittedProgress = updated;
|
|
onProgress(updated);
|
|
}, 10_000);
|
|
healthTick.unref?.();
|
|
|
|
const outputs: string[] = [];
|
|
let partNumber = 1;
|
|
let resumeCount = 0;
|
|
let failedPartResult: DownloadResult | null = null;
|
|
|
|
try {
|
|
// Resume loop. Each iteration runs streamlink once. On clean exit,
|
|
// we re-check whether the stream is still live on Twitch's side;
|
|
// if yes, the exit was an interruption (network blip, segment
|
|
// discontinuity, etc.) — start a new part and append. If the
|
|
// stream really ended, break and finalize.
|
|
while (true) {
|
|
const partFilename = partNumber === 1
|
|
? baseFilename
|
|
: ensureUniqueFilename(
|
|
baseFilename.replace(/\.mp4$/i, `_part${partNumber}.mp4`),
|
|
item.id
|
|
);
|
|
|
|
// Reset per-part counters — streamlink is fresh, byte counter
|
|
// restarts at zero. lastBytesAdvancedAt stays at zero until
|
|
// the first segment arrives, which correctly flips the health
|
|
// dot to 'unknown' during the resume gap.
|
|
lastBytesValue = 0;
|
|
lastBytesAdvancedAt = 0;
|
|
currentPartBytes = 0;
|
|
|
|
const partStartedAt = Date.now();
|
|
appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) });
|
|
|
|
const partResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber);
|
|
|
|
// Accumulate this part's final bytes into the running total so
|
|
// the next part's meta line continues from the correct figure.
|
|
let partFinalBytes = 0;
|
|
if (fs.existsSync(partFilename)) {
|
|
try {
|
|
partFinalBytes = fs.statSync(partFilename).size || 0;
|
|
} catch { /* ignore */ }
|
|
}
|
|
if (partFinalBytes > 0) {
|
|
outputs.push(partFilename);
|
|
accumulatedBytes += partFinalBytes;
|
|
} else {
|
|
failedPartResult = partResult;
|
|
// Streamlink produced no bytes — likely permission or auth
|
|
// failure. Skip resume because retrying will hit the same
|
|
// wall. The error from lastPartResult will surface upstream.
|
|
appendDebugLog('recording-part-zero-bytes', { itemId: item.id, partNumber });
|
|
break;
|
|
}
|
|
|
|
// Resume decision tree.
|
|
if (cancelledItemIds.has(item.id) || !isDownloading) {
|
|
appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: 'cancel' });
|
|
break;
|
|
}
|
|
if (!config.auto_resume_live_recording) {
|
|
appendDebugLog('recording-resume-disabled', { itemId: item.id });
|
|
break;
|
|
}
|
|
if (resumeCount >= MAX_RESUME_ATTEMPTS) {
|
|
appendDebugLog('recording-resume-max-attempts', { itemId: item.id, max: MAX_RESUME_ATTEMPTS });
|
|
break;
|
|
}
|
|
// Don't resume on suspiciously short parts — that pattern points
|
|
// at a config issue (bad URL, auth-required stream, streamlink
|
|
// missing plugin) where retrying will just loop and burn API
|
|
// quota.
|
|
const partDurationMs = Date.now() - partStartedAt;
|
|
if (partDurationMs < MIN_HEALTHY_PART_MS) {
|
|
appendDebugLog('recording-resume-skip-short', { itemId: item.id, partNumber, durationMs: partDurationMs });
|
|
break;
|
|
}
|
|
|
|
// Only resume if Twitch still says the stream is live. If the
|
|
// streamer actually ended their broadcast, we accept the part
|
|
// we have and call the recording done.
|
|
let stillLive = false;
|
|
try {
|
|
const info = await getLiveStreamInfo(item.streamer);
|
|
stillLive = info?.isLive === true;
|
|
} catch {
|
|
// Unknown liveness — err on the side of NOT resuming to
|
|
// avoid infinite-loop on network-out conditions where we
|
|
// can't even reach Twitch to check. The user can always
|
|
// restart manually.
|
|
stillLive = false;
|
|
}
|
|
if (!stillLive) {
|
|
appendDebugLog('recording-finished-stream-offline', { itemId: item.id, parts: partNumber });
|
|
break;
|
|
}
|
|
|
|
appendDebugLog('recording-resume-attempt', { itemId: item.id, previousPart: partNumber, attempt: resumeCount + 1 });
|
|
if (eventsTracker) {
|
|
appendEventLine(eventsTracker, { type: 'recording_resume', part: partNumber + 1 });
|
|
}
|
|
resumeCount++;
|
|
partNumber++;
|
|
await sleep(RESUME_WAIT_MS);
|
|
}
|
|
} finally {
|
|
clearInterval(healthTick);
|
|
}
|
|
|
|
if (chatSession) {
|
|
stopLiveChatCapture(chatSession);
|
|
}
|
|
if (eventsTracker) {
|
|
stopLiveEventsTracker(item.id, {
|
|
success: outputs.length > 0,
|
|
durationMs: Date.now() - recordingStartedAt,
|
|
error: outputs.length === 0 ? failedPartResult?.error ?? tBackend('unknownDownloadError') : undefined
|
|
});
|
|
}
|
|
|
|
if (config.discord_notify_live_end) {
|
|
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
|
const sizeBytes = accumulatedBytes;
|
|
const success = outputs.length > 0;
|
|
void sendDiscordWebhook({
|
|
title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
|
|
description: item.title || `${item.streamer}`,
|
|
color: success ? 'success' : 'info',
|
|
fields: [
|
|
{ name: 'Duration', value: formatDuration(durationSec), inline: true },
|
|
{ name: 'Size', value: formatBytes(sizeBytes), inline: true },
|
|
{ name: 'Parts', value: String(outputs.length || 1), inline: true },
|
|
{ name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true },
|
|
{ name: 'Output', value: path.basename(baseFilename), inline: false }
|
|
]
|
|
});
|
|
}
|
|
|
|
if (outputs.length === 0) return failedPartResult ?? { success: false, error: tBackend('unknownDownloadError') };
|
|
|
|
// Auto-merge resumed parts. Only attempt when (a) the user opted in,
|
|
// (b) there's actually something to merge, and (c) the parts are all
|
|
// present on disk. Failure is non-fatal — we keep the parts so the
|
|
// user still has working files even if ffmpeg trips on a corrupted
|
|
// segment header.
|
|
let finalRecordings = outputs.slice();
|
|
if (config.auto_merge_resumed_parts && outputs.length > 1) {
|
|
const mergedOutput = ensureUniqueFilename(
|
|
baseFilename.replace(/\.mp4$/i, '_merged.mp4'),
|
|
item.id
|
|
);
|
|
const mergeOk = await concatVideoFiles(outputs, mergedOutput, item.id);
|
|
if (mergeOk) {
|
|
if (config.delete_parts_after_merge) {
|
|
for (const partPath of outputs) {
|
|
try { fs.unlinkSync(partPath); } catch (e) {
|
|
appendDebugLog('merge-part-delete-failed', { path: partPath, error: String(e) });
|
|
}
|
|
}
|
|
finalRecordings = [mergedOutput];
|
|
} else {
|
|
finalRecordings = [mergedOutput, ...outputs];
|
|
}
|
|
appendDebugLog('merge-resumed-parts-ok', { merged: mergedOutput, partsKept: !config.delete_parts_after_merge });
|
|
} else {
|
|
appendDebugLog('merge-resumed-parts-failed-keeping-parts');
|
|
}
|
|
}
|
|
|
|
if (chatSession && fs.existsSync(chatSession.outputPath)) {
|
|
finalRecordings.push(chatSession.outputPath);
|
|
}
|
|
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
|
|
finalRecordings.push(eventsTracker.eventsPath);
|
|
}
|
|
return { success: true, outputFiles: finalRecordings };
|
|
}
|
|
|
|
async function downloadVOD(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
// Live-recording branch: URL is the channel page, no VOD id, no time
|
|
// window. Streamlink runs until the stream ends, then we treat the
|
|
// whole capture as a single output file.
|
|
if (item.isLive) {
|
|
return await downloadLiveStream(item, onProgress);
|
|
}
|
|
|
|
const vodId = parseVodId(item.url);
|
|
if (!isLikelyVodUrl(item.url) || !vodId) {
|
|
return {
|
|
success: false,
|
|
error: tBackend('invalidVodUrl')
|
|
};
|
|
}
|
|
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusPreparingTools'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
if (!streamlinkReady) {
|
|
return {
|
|
success: false,
|
|
error: tBackend('streamlinkAutoInstallFailed')
|
|
};
|
|
}
|
|
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusDownloadStarted'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
const streamer = item.streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const date = new Date(item.date);
|
|
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
|
|
|
const folder = path.join(config.download_path, streamer, dateStr);
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const totalDuration = parseDuration(item.duration_str);
|
|
|
|
const requiredBytesEstimate = estimateRequiredDownloadBytes(item);
|
|
const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download');
|
|
if (!diskSpaceCheck.success) {
|
|
return diskSpaceCheck;
|
|
}
|
|
|
|
const makeTemplateFilename = (
|
|
template: string,
|
|
templateFallback: string,
|
|
partNum: number,
|
|
trimStartSec: number,
|
|
trimLengthSec: number
|
|
): string => {
|
|
const relativeName = renderClipFilenameTemplate({
|
|
template: normalizeFilenameTemplate(template, templateFallback),
|
|
title: item.title,
|
|
vodId,
|
|
channel: item.streamer,
|
|
date,
|
|
part: partNum,
|
|
partPadded: partNum.toString().padStart(2, '0'),
|
|
trimStartSec,
|
|
trimEndSec: trimStartSec + trimLengthSec,
|
|
trimLengthSec,
|
|
fullLengthSec: totalDuration
|
|
});
|
|
|
|
return path.join(folder, relativeName);
|
|
};
|
|
|
|
// Custom Clip - download specific time range
|
|
if (item.customClip) {
|
|
const clip = item.customClip;
|
|
const partDuration = config.part_minutes * 60;
|
|
|
|
// Helper to generate filename based on format
|
|
const makeClipFilename = (partNum: number, startOffset: number, clipLengthSec: number): string => {
|
|
if (clip.filenameFormat === 'template') {
|
|
return makeTemplateFilename(
|
|
clip.filenameTemplate || config.filename_template_clip,
|
|
DEFAULT_FILENAME_TEMPLATE_CLIP,
|
|
partNum,
|
|
startOffset,
|
|
clipLengthSec
|
|
);
|
|
}
|
|
|
|
if (clip.filenameFormat === 'timestamp') {
|
|
const h = Math.floor(startOffset / 3600);
|
|
const m = Math.floor((startOffset % 3600) / 60);
|
|
const s = Math.floor(startOffset % 60);
|
|
const timeStr = `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
|
return path.join(folder, `${dateStr}_CLIP_${timeStr}_${partNum}.mp4`);
|
|
}
|
|
|
|
if (clip.filenameFormat === 'parts') {
|
|
// Mirrors the global filename_template_parts default:
|
|
// `{date}_Part{part_padded}.mp4` -> e.g. 08.05.2026_Part07.mp4
|
|
return path.join(folder, `${dateStr}_Part${partNum.toString().padStart(2, '0')}.mp4`);
|
|
}
|
|
|
|
return path.join(folder, `${dateStr}_${partNum}.mp4`);
|
|
};
|
|
|
|
// If clip is longer than part duration, split into parts
|
|
if (clip.durationSec > partDuration) {
|
|
const numParts = Math.ceil(clip.durationSec / partDuration);
|
|
const downloadedFiles: string[] = [];
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
if (cancelledItemIds.has(item.id)) break;
|
|
|
|
const partNum = clip.startPart + i;
|
|
const startOffset = clip.startSec + (i * partDuration);
|
|
const remainingDuration = clip.durationSec - (i * partDuration);
|
|
const thisDuration = Math.min(partDuration, remainingDuration);
|
|
|
|
const partFilename = ensureUniqueFilename(makeClipFilename(partNum, startOffset, thisDuration), item.id);
|
|
|
|
const result = await downloadVODPart(
|
|
item.url,
|
|
partFilename,
|
|
formatDuration(startOffset),
|
|
formatDuration(thisDuration),
|
|
onProgress,
|
|
item.id,
|
|
i + 1,
|
|
numParts
|
|
);
|
|
|
|
if (!result.success) return result;
|
|
downloadedFiles.push(partFilename);
|
|
}
|
|
|
|
return {
|
|
success: downloadedFiles.length === numParts,
|
|
error: downloadedFiles.length === numParts ? undefined : tBackend('notAllClipPartsDownloaded'),
|
|
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
|
};
|
|
} else {
|
|
// Single clip file
|
|
const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec), item.id);
|
|
const result = await downloadVODPart(
|
|
item.url,
|
|
filename,
|
|
formatDuration(clip.startSec),
|
|
formatDuration(clip.durationSec),
|
|
onProgress,
|
|
item.id,
|
|
1,
|
|
1
|
|
);
|
|
return result.success ? { ...result, outputFiles: [filename] } : result;
|
|
}
|
|
}
|
|
|
|
// Check download mode
|
|
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
|
|
// Full download — totalDuration als expectedTotalSec damit der Bar
|
|
// determinate-progress aus bytes/duration schaetzen kann (statt in
|
|
// indeterminate-Animation zu haengen).
|
|
const filename = ensureUniqueFilename(makeTemplateFilename(
|
|
config.filename_template_vod,
|
|
DEFAULT_FILENAME_TEMPLATE_VOD,
|
|
1,
|
|
0,
|
|
totalDuration
|
|
), item.id);
|
|
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1, totalDuration);
|
|
return result.success ? { ...result, outputFiles: [filename] } : result;
|
|
} else {
|
|
// Part-based download — wrappt onProgress mit einem Aggregator, der
|
|
// pro Part den letzten bekannten %-Wert haelt und einen weighted
|
|
// overallProgress (0-100%) zurueck an die UI emittiert. Ohne den
|
|
// Wrapper sah die UI nur "Part X bei Y%" und der Bar sprang bei
|
|
// Part-Wechsel von 100% zurueck auf 0%.
|
|
const partDuration = config.part_minutes * 60;
|
|
const numParts = Math.ceil(totalDuration / partDuration);
|
|
const downloadedFiles: string[] = [];
|
|
const partProgresses: number[] = Array(numParts).fill(0);
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
if (cancelledItemIds.has(item.id)) break;
|
|
|
|
const startSec = i * partDuration;
|
|
const endSec = Math.min((i + 1) * partDuration, totalDuration);
|
|
const duration = endSec - startSec;
|
|
|
|
const partFilename = ensureUniqueFilename(makeTemplateFilename(
|
|
config.filename_template_parts,
|
|
DEFAULT_FILENAME_TEMPLATE_PARTS,
|
|
i + 1,
|
|
startSec,
|
|
duration
|
|
), item.id);
|
|
|
|
const result = await downloadVODPart(
|
|
item.url,
|
|
partFilename,
|
|
formatDuration(startSec),
|
|
formatDuration(duration),
|
|
(progress) => {
|
|
// Per-part %-Update — clampen, NaN/negativ filtern
|
|
if (Number.isFinite(progress.progress) && progress.progress > 0 && progress.progress <= 100) {
|
|
partProgresses[i] = Math.max(partProgresses[i], progress.progress);
|
|
}
|
|
// Overall: avg ueber alle Parts (parts haben gleiche
|
|
// Dauer per Definition, also avg = weighted avg)
|
|
const overall = partProgresses.reduce((s, p) => s + p, 0) / numParts;
|
|
onProgress({
|
|
...progress,
|
|
progress: overall,
|
|
currentPart: i + 1,
|
|
totalParts: numParts
|
|
});
|
|
},
|
|
item.id,
|
|
i + 1,
|
|
numParts,
|
|
duration
|
|
);
|
|
|
|
if (!result.success) {
|
|
return result;
|
|
}
|
|
|
|
partProgresses[i] = 100;
|
|
downloadedFiles.push(partFilename);
|
|
}
|
|
|
|
return {
|
|
success: downloadedFiles.length === numParts,
|
|
error: downloadedFiles.length === numParts ? undefined : tBackend('notAllPartsDownloaded'),
|
|
outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined
|
|
};
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// MERGE GROUP DOWNLOAD PIPELINE
|
|
// ==========================================
|
|
async function processDownloadMergeGroup(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
const mg = item.mergeGroup!;
|
|
const artifactRootResolution = resolveMergeArtifactRoot(item, config.download_path);
|
|
if (!artifactRootResolution.artifactRoot) {
|
|
item.mergeRecoveryBlocked = true;
|
|
item.last_error = tBackend('mergeRecoveryBlocked');
|
|
saveQueue(downloadQueue);
|
|
return { success: false, error: item.last_error };
|
|
}
|
|
const artifactRoot = artifactRootResolution.artifactRoot;
|
|
if (item.artifactRoot !== artifactRoot) {
|
|
item.artifactRoot = artifactRoot;
|
|
saveQueue(downloadQueue);
|
|
}
|
|
const totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0);
|
|
mg.totalDurationSec = totalDurationSec;
|
|
|
|
// ---- PHASE 1: DOWNLOADING ----
|
|
if (mg.mergePhase === 'downloading') {
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusPreparingTools'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
if (!streamlinkReady) {
|
|
return { success: false, error: tBackend('streamlinkAutoInstallFailed') };
|
|
}
|
|
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
return { success: false, error: tBackend('ffmpegAutoInstallFailed') };
|
|
}
|
|
|
|
const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const date = new Date(mg.items[0].date);
|
|
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
|
const folder = path.join(artifactRoot, streamer, dateStr);
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
// Disk space pre-check: 3x total estimated size
|
|
const estimatedBytes = mg.items.reduce((sum, i) => {
|
|
const dur = parseDuration(i.duration_str);
|
|
return sum + Math.ceil(dur * 500_000); // ~500KB/s estimate
|
|
}, 0);
|
|
const requiredBytes = Math.max(256 * 1024 * 1024, estimatedBytes * 3);
|
|
const diskCheck = ensureDiskSpace(folder, requiredBytes, 'Merge-Group-Download');
|
|
if (!diskCheck.success) {
|
|
return diskCheck;
|
|
}
|
|
|
|
for (let i = 0; i < mg.items.length; i++) {
|
|
if (cancelledItemIds.has(item.id)) {
|
|
return { success: false, error: tBackend('downloadCancelled') };
|
|
}
|
|
|
|
// Skip already downloaded files (retry recovery)
|
|
if (mg.downloadedFiles[i] && fs.existsSync(mg.downloadedFiles[i])) {
|
|
appendDebugLog('merge-group-skip-existing', { index: i, file: mg.downloadedFiles[i] });
|
|
continue;
|
|
}
|
|
|
|
// Reset stale per-item cancel state (global cancel already checked above)
|
|
cancelledItemIds.delete(item.id);
|
|
mg.currentItemIndex = i;
|
|
mg.mergePhase = 'downloading';
|
|
saveQueue(downloadQueue);
|
|
|
|
const vodItem = mg.items[i];
|
|
const tmpFilename = ensureUniqueFilename(path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`), item.id);
|
|
|
|
// Calculate progress weighting per VOD
|
|
const vodDuration = parseDuration(vodItem.duration_str);
|
|
const vodWeight = vodDuration / totalDurationSec;
|
|
const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec;
|
|
|
|
// Geschaetzte Bytes pro Part fuer den Fallback-Progress: Twitch-
|
|
// VOD Bitrate ~5 Mbit/s = ~625 KB/s. Wenn streamlink-stdout keine
|
|
// %-Lines emittiert (HLS ohne known total), nutzen wir
|
|
// downloadedBytes / estimatedTotalBytes als rough progress. Cap
|
|
// bei 95% damit der Bar nie 100% vorm tatsaechlichen Done erreicht.
|
|
const estimatedTotalBytes = Math.max(1, vodDuration * 625_000);
|
|
|
|
// Persistente per-part vodProgress. Quelle 1: streamlink stdout %
|
|
// (genau). Quelle 2: downloadedBytes / estimated (Fallback wenn
|
|
// % nicht reportet wird). Ohne den Fallback haengte der Bar auf
|
|
// dem indeterminate-Pattern (animierte 35%-Box) waehrend tatsaechlich
|
|
// schon ein paar 100 MB unten waren — User sieht das als "fest mittig
|
|
// links" weil die Animation schnell ist und nur Snapshots zeigen.
|
|
let lastVodProgress = 0;
|
|
const result = await downloadVODPart(
|
|
vodItem.url,
|
|
tmpFilename,
|
|
null, // startTime: null = full VOD
|
|
null, // endTime: null = full VOD
|
|
(progress) => {
|
|
if (progress.progress > 0 && progress.progress <= 100) {
|
|
lastVodProgress = progress.progress;
|
|
} else if (progress.downloadedBytes && progress.downloadedBytes > 0) {
|
|
// Fallback: bytes-basierte Schaetzung. Streamlink-stdout-%
|
|
// bleibt bevorzugt; bytes-Fallback wird nur genutzt wenn
|
|
// noch nie ein echter % rein kam (lastVodProgress noch 0).
|
|
if (lastVodProgress === 0) {
|
|
const bytePct = Math.min(95, (progress.downloadedBytes / estimatedTotalBytes) * 100);
|
|
lastVodProgress = bytePct;
|
|
}
|
|
}
|
|
// Weighted progress: download phase = 0-70%
|
|
const overallProgress = (priorWeight + vodWeight * (lastVodProgress / 100)) * 70;
|
|
onProgress({
|
|
...progress,
|
|
id: item.id,
|
|
progress: overallProgress,
|
|
status: `${getMergeGroupPhaseText('downloading')} ${i + 1}/${mg.items.length} — ${progress.status}`,
|
|
currentPart: i + 1,
|
|
totalParts: mg.items.length
|
|
});
|
|
},
|
|
item.id,
|
|
i + 1,
|
|
mg.items.length
|
|
);
|
|
|
|
if (!result.success) {
|
|
return result;
|
|
}
|
|
|
|
mg.downloadedFiles[i] = tmpFilename;
|
|
registerQueuePartialFile(item.id, tmpFilename);
|
|
saveQueue(downloadQueue);
|
|
}
|
|
}
|
|
|
|
// ---- PHASE 2: MERGING ----
|
|
mg.mergePhase = 'merging';
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
// Check all downloaded files exist (retry recovery)
|
|
for (let i = 0; i < mg.items.length; i++) {
|
|
if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) {
|
|
mg.mergePhase = 'downloading';
|
|
return { success: false, error: tBackend('mergeGroupFileMissing', { index: i + 1 }) };
|
|
}
|
|
}
|
|
|
|
if (!mg.mergedFile || !fs.existsSync(mg.mergedFile)) {
|
|
const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const date = new Date(mg.items[0].date);
|
|
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
|
const folder = path.join(artifactRoot, streamer, dateStr);
|
|
const mergedFilePath = ensureUniqueFilename(path.join(folder, `.merge_output_${Date.now()}_${process.pid}.mp4`), item.id);
|
|
|
|
// Get files in correct order (explicit sort by index — do NOT rely on Object.values ordering)
|
|
const sortedFiles = Object.keys(mg.downloadedFiles)
|
|
.sort((a, b) => Number(a) - Number(b))
|
|
.map(k => mg.downloadedFiles[Number(k)]);
|
|
|
|
mg.mergedFile = mergedFilePath;
|
|
saveQueue(downloadQueue);
|
|
registerQueuePartialFile(item.id, mergedFilePath);
|
|
|
|
const mergeSuccess = await mergeVideos(
|
|
sortedFiles,
|
|
mergedFilePath,
|
|
(percent) => {
|
|
const overallProgress = 70 + (percent / 100) * 20; // merge = 70-90%
|
|
onProgress({
|
|
id: item.id,
|
|
progress: overallProgress,
|
|
speed: '',
|
|
eta: '',
|
|
status: getMergeGroupPhaseText('merging'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
},
|
|
totalDurationSec,
|
|
item.id
|
|
);
|
|
|
|
if (!mergeSuccess) {
|
|
try { fs.rmSync(mergedFilePath, { force: true }); } catch { }
|
|
if (fs.existsSync(mergedFilePath)) {
|
|
item.mergeRecoveryBlocked = true;
|
|
item.last_error = tBackend('mergeRecoveryBlocked');
|
|
saveQueue(downloadQueue);
|
|
return { success: false, error: item.last_error };
|
|
}
|
|
delete mg.mergedFile;
|
|
saveQueue(downloadQueue);
|
|
return { success: false, error: tBackend('ffmpegMergeFailed') };
|
|
}
|
|
|
|
saveQueue(downloadQueue);
|
|
if (!(await waitForQueuePhaseBoundary(item.id))) {
|
|
return { success: false, error: tBackend('downloadCancelled') };
|
|
}
|
|
}
|
|
|
|
// ---- PHASE 3: SPLITTING ----
|
|
mg.mergePhase = 'splitting';
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
if (cancelledItemIds.has(item.id)) {
|
|
return { success: false, error: tBackend('downloadCancelled') };
|
|
}
|
|
|
|
const partDuration = config.part_minutes * 60;
|
|
const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const date = new Date(mg.items[0].date);
|
|
const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`;
|
|
const folder = path.join(artifactRoot, streamer, dateStr);
|
|
const vodId = parseVodId(mg.items[0].url) || 'merged';
|
|
|
|
const splitTempFilesByPart = new Map<number, string>();
|
|
const splitResult = await splitMergedFile(
|
|
mg.mergedFile!,
|
|
folder,
|
|
partDuration,
|
|
totalDurationSec,
|
|
(partNum: number) => {
|
|
const startSec = (partNum - 1) * partDuration;
|
|
const thisDuration = Math.min(partDuration, totalDurationSec - startSec);
|
|
return renderClipFilenameTemplate({
|
|
template: normalizeFilenameTemplate(config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
|
|
title: mg.items[0].title,
|
|
vodId,
|
|
channel: mg.items[0].streamer,
|
|
date,
|
|
part: partNum,
|
|
partPadded: partNum.toString().padStart(2, '0'),
|
|
trimStartSec: startSec,
|
|
trimEndSec: startSec + thisDuration,
|
|
trimLengthSec: thisDuration,
|
|
fullLengthSec: totalDurationSec
|
|
});
|
|
},
|
|
(currentPart, totalParts) => {
|
|
const overallProgress = 90 + ((currentPart - 1) / totalParts) * 10; // split = 90-100%
|
|
onProgress({
|
|
id: item.id,
|
|
progress: overallProgress,
|
|
speed: '',
|
|
eta: '',
|
|
status: `${getMergeGroupPhaseText('splitting')} ${currentPart}/${totalParts}...`,
|
|
currentPart,
|
|
totalParts
|
|
});
|
|
},
|
|
(partIndex, outputFile, temporaryFile) => {
|
|
const nextSplitFiles = [...(mg.splitFiles ?? [])];
|
|
if (outputFile) nextSplitFiles[partIndex] = outputFile;
|
|
else nextSplitFiles.splice(partIndex, 1);
|
|
if (temporaryFile) splitTempFilesByPart.set(partIndex, temporaryFile);
|
|
else splitTempFilesByPart.delete(partIndex);
|
|
if (nextSplitFiles.length > 0) mg.splitFiles = nextSplitFiles;
|
|
else delete mg.splitFiles;
|
|
const nextTemporaryFiles = [...splitTempFilesByPart.values()];
|
|
if (nextTemporaryFiles.length > 0) mg.splitTempFiles = nextTemporaryFiles;
|
|
else delete mg.splitTempFiles;
|
|
saveQueue(downloadQueue);
|
|
},
|
|
item.id
|
|
);
|
|
|
|
if (!splitResult.success) {
|
|
const failedCleanup = new Set<string>();
|
|
const cleanupCandidates = new Set([
|
|
...splitResult.files,
|
|
...(mg.splitFiles ?? []),
|
|
...(mg.splitTempFiles ?? []),
|
|
]);
|
|
for (const partFile of cleanupCandidates) {
|
|
try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { }
|
|
if (fs.existsSync(partFile)) failedCleanup.add(partFile);
|
|
}
|
|
if (failedCleanup.size > 0) {
|
|
mg.splitFiles = (mg.splitFiles ?? []).filter((filePath) => failedCleanup.has(filePath));
|
|
mg.splitTempFiles = (mg.splitTempFiles ?? []).filter((filePath) => failedCleanup.has(filePath));
|
|
item.mergeRecoveryBlocked = true;
|
|
item.last_error = tBackend('mergeRecoveryBlocked');
|
|
saveQueue(downloadQueue);
|
|
return { success: false, error: item.last_error };
|
|
}
|
|
delete mg.splitFiles;
|
|
delete mg.splitTempFiles;
|
|
saveQueue(downloadQueue);
|
|
return { success: false, error: tBackend('ffmpegSplitFailed') };
|
|
}
|
|
|
|
mg.splitFiles = splitResult.files;
|
|
delete mg.splitTempFiles;
|
|
|
|
// ---- PHASE 4: CLEANUP ----
|
|
mg.mergePhase = 'cleanup';
|
|
saveQueue(downloadQueue);
|
|
|
|
const failedCleanup = new Set<string>();
|
|
for (const key of Object.keys(mg.downloadedFiles)) {
|
|
const filePath = mg.downloadedFiles[Number(key)];
|
|
try {
|
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
if (fs.existsSync(filePath)) failedCleanup.add(filePath);
|
|
} catch {
|
|
failedCleanup.add(filePath);
|
|
}
|
|
}
|
|
|
|
if (mg.mergedFile) {
|
|
try {
|
|
if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile);
|
|
if (fs.existsSync(mg.mergedFile)) failedCleanup.add(mg.mergedFile);
|
|
} catch {
|
|
failedCleanup.add(mg.mergedFile);
|
|
}
|
|
}
|
|
|
|
if (failedCleanup.size > 0) {
|
|
item.mergeRecoveryBlocked = true;
|
|
item.last_error = tBackend('mergeRecoveryBlocked');
|
|
saveQueue(downloadQueue);
|
|
appendDebugLog('merge-group-cleanup-failed', { itemId: item.id, files: failedCleanup.size });
|
|
return { success: false, error: item.last_error };
|
|
}
|
|
|
|
mg.downloadedFiles = {};
|
|
delete mg.mergedFile;
|
|
mg.mergePhase = 'done';
|
|
saveQueue(downloadQueue);
|
|
appendDebugLog('merge-group-complete', {
|
|
itemId: item.id,
|
|
parts: splitResult.files.length,
|
|
totalDurationSec
|
|
});
|
|
|
|
return { success: true, outputFiles: [...splitResult.files] };
|
|
}
|
|
|
|
async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|
queueProcessRegistry.resetItem(item.id);
|
|
const itemRegistration = queueProcessRegistry.register(item.id, 'post-processing', {});
|
|
if (!itemRegistration.accepted) return;
|
|
if (item.mergeGroup) {
|
|
for (const filePath of Object.values(item.mergeGroup.downloadedFiles)) {
|
|
if (filePath && fs.existsSync(filePath)) registerQueuePartialFile(item.id, filePath);
|
|
}
|
|
if (item.mergeGroup.mergedFile && fs.existsSync(item.mergeGroup.mergedFile)) {
|
|
registerQueuePartialFile(item.id, item.mergeGroup.mergedFile);
|
|
}
|
|
}
|
|
|
|
appendDebugLog('queue-item-start', {
|
|
itemId: item.id,
|
|
title: item.title,
|
|
url: item.url,
|
|
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
|
|
});
|
|
|
|
runtimeMetrics.downloadsStarted += 1;
|
|
runtimeMetrics.activeItemId = item.id;
|
|
runtimeMetrics.activeItemTitle = item.title;
|
|
activeQueueItemId = item.id;
|
|
|
|
cancelledItemIds.delete(item.id);
|
|
applyQueueTransferState(item, 'downloading', item.progress);
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
item.last_error = '';
|
|
|
|
try {
|
|
let finalResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
|
const maxAttempts = getRetryAttemptLimit();
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
if (!isDownloading || cancelledItemIds.has(item.id) || queueProcessRegistry.isCancelled(item.id)) {
|
|
finalResult = { success: false, error: tBackend('downloadCancelled') };
|
|
break;
|
|
}
|
|
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
|
|
|
|
const result = item.mergeGroup
|
|
? await processDownloadMergeGroup(item, (progress) => {
|
|
recordDownloadProgress(progress);
|
|
if (!queuePaused) mainWindow?.webContents.send('download-progress', progress);
|
|
})
|
|
: await downloadVOD(item, (progress) => {
|
|
recordDownloadProgress(progress);
|
|
if (!queuePaused) mainWindow?.webContents.send('download-progress', progress);
|
|
});
|
|
|
|
if (result.success) {
|
|
finalResult = result;
|
|
break;
|
|
}
|
|
|
|
finalResult = result;
|
|
|
|
if (item.mergeRecoveryBlocked) break;
|
|
|
|
if (queueProcessRegistry.isPaused(item.id)) {
|
|
await queueProcessRegistry.whenResumed(item.id);
|
|
if (!isDownloading || cancelledItemIds.has(item.id) || queueProcessRegistry.isCancelled(item.id)) {
|
|
finalResult = { success: false, error: tBackend('downloadCancelled') };
|
|
break;
|
|
}
|
|
attempt -= 1;
|
|
continue;
|
|
}
|
|
|
|
if (!isDownloading || cancelledItemIds.has(item.id) || queueProcessRegistry.isCancelled(item.id)) {
|
|
finalResult = { success: false, error: tBackend('downloadCancelled') };
|
|
break;
|
|
}
|
|
|
|
const errorClass = classifyDownloadError(result.error || '');
|
|
runtimeMetrics.lastErrorClass = errorClass;
|
|
|
|
if (errorClass === 'tooling' || errorClass === 'validation') {
|
|
appendDebugLog('queue-item-no-retry', {
|
|
itemId: item.id,
|
|
errorClass,
|
|
error: result.error || 'unknown'
|
|
});
|
|
break;
|
|
}
|
|
|
|
if (attempt < maxAttempts) {
|
|
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
|
|
runtimeMetrics.retriesScheduled += 1;
|
|
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
|
|
|
|
item.last_error = tBackend('attemptFailed', { attempt, max: maxAttempts, errorClass, error: result.error || tBackend('unknownDownloadError') });
|
|
const retryProgress = prepareQueueRetryProgress(
|
|
item,
|
|
tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }),
|
|
);
|
|
recordDownloadProgress(retryProgress);
|
|
if (!queuePaused) mainWindow?.webContents.send('download-progress', retryProgress);
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
await Promise.race([
|
|
sleep(retryDelaySeconds * 1000),
|
|
queueProcessRegistry.whenCancelled(item.id),
|
|
]);
|
|
} else {
|
|
runtimeMetrics.retriesExhausted += 1;
|
|
}
|
|
}
|
|
|
|
if (!hasQueueItemId(item.id)) {
|
|
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
|
|
return;
|
|
}
|
|
|
|
applyQueueTransferState(
|
|
item,
|
|
finalResult.success ? 'completed' : 'error',
|
|
finalResult.success ? 100 : item.progress,
|
|
);
|
|
item.last_error = finalResult.success ? '' : (finalResult.error || tBackend('unknownDownloadError'));
|
|
|
|
if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) {
|
|
// Attach the produced file paths so the renderer can offer
|
|
// "Open file" / "Show in folder" actions on completed items,
|
|
// surviving a queue persistence round-trip.
|
|
item.outputFiles = [...finalResult.outputFiles];
|
|
}
|
|
|
|
// Discord webhook for non-live VOD completion. Live recordings
|
|
// already get their own end-of-recording webhook in downloadLiveStream.
|
|
if (finalResult.success && !item.isLive && config.discord_notify_vod_complete) {
|
|
const totalBytes = (item.outputFiles || []).reduce((sum, f) => {
|
|
try { return sum + (fs.statSync(f).size || 0); } catch { return sum; }
|
|
}, 0);
|
|
void sendDiscordWebhook({
|
|
title: `VOD download complete: ${item.streamer}`,
|
|
description: item.title || item.url,
|
|
color: 'success',
|
|
fields: [
|
|
{ name: 'Files', value: String((item.outputFiles || []).length), inline: true },
|
|
{ name: 'Size', value: formatBytes(totalBytes), inline: true }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Per-VOD completion notification (separate from the queue-end
|
|
// notification fired at the end of processQueue). Off by default
|
|
// because users with long queues would get spammed.
|
|
if (finalResult.success && config.notify_on_each_completion) {
|
|
try {
|
|
if (Notification.isSupported()) {
|
|
const itemNotification = new Notification({
|
|
title: 'Twitch VOD Manager',
|
|
body: `${item.title || item.url}`,
|
|
icon: path.join(__dirname, '../build/icon.png')
|
|
});
|
|
const firstFile = item.outputFiles?.[0];
|
|
itemNotification.on('click', () => {
|
|
try {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
}
|
|
// Click on a per-item notification opens the
|
|
// file directly when we know it; falls back to
|
|
// the download folder otherwise.
|
|
if (firstFile && fs.existsSync(firstFile)) {
|
|
shell.showItemInFolder(firstFile);
|
|
} else if (config.download_path && fs.existsSync(config.download_path)) {
|
|
void shell.openPath(config.download_path);
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('per-item-notification-click-failed', String(e));
|
|
}
|
|
});
|
|
itemNotification.show();
|
|
}
|
|
} catch { /* notifications optional */ }
|
|
}
|
|
|
|
if (finalResult.success) {
|
|
// Record the VOD ID so the renderer can mark this VOD as
|
|
// already-downloaded the next time the user browses the
|
|
// streamer's archive. Merge groups don't have a single VOD
|
|
// ID — record each component instead.
|
|
if (item.mergeGroup?.items?.length) {
|
|
for (const m of item.mergeGroup.items) {
|
|
const id = parseVodId(m.url);
|
|
if (id) recordDownloadedVodId(id);
|
|
}
|
|
} else {
|
|
const id = parseVodId(item.url);
|
|
if (id) recordDownloadedVodId(id);
|
|
}
|
|
|
|
// Optional chat-replay download. Only for non-live, non-merge
|
|
// VODs that have a parseable VOD id and produced at least one
|
|
// output file. Saved as {video_basename}.chat.json next to the
|
|
// video. Truncation is logged but not fatal.
|
|
if (config.download_chat_replay && !item.isLive && !item.mergeGroup) {
|
|
const vodIdForChat = parseVodId(item.url);
|
|
const firstOutput = item.outputFiles?.[0];
|
|
if (vodIdForChat && firstOutput) {
|
|
try {
|
|
mainWindow?.webContents.send('download-progress', {
|
|
id: item.id,
|
|
progress: 100,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusFetchingChatReplay'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
} as DownloadProgress);
|
|
|
|
const replay = await fetchVodChatReplay(vodIdForChat, (count) => {
|
|
mainWindow?.webContents.send('download-progress', {
|
|
id: item.id,
|
|
progress: 100,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusChatMessagesFetched', { count: String(count) }),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
} as DownloadProgress);
|
|
}, () => cancelledItemIds.has(item.id) || queueProcessRegistry.isCancelled(item.id));
|
|
|
|
const chatPath = chatReplayPathFor(firstOutput);
|
|
const payload = {
|
|
videoId: vodIdForChat,
|
|
videoUrl: item.url,
|
|
streamer: item.streamer,
|
|
title: item.title,
|
|
fetchedAt: new Date().toISOString(),
|
|
messageCount: replay.messages.length,
|
|
truncated: replay.truncated,
|
|
pages: replay.pages,
|
|
messages: replay.messages
|
|
};
|
|
writeFileAtomicSync(chatPath, JSON.stringify(payload, null, 2));
|
|
appendDebugLog('chat-replay-saved', {
|
|
itemId: item.id,
|
|
videoId: vodIdForChat,
|
|
messages: replay.messages.length,
|
|
pages: replay.pages,
|
|
truncated: replay.truncated,
|
|
path: chatPath
|
|
});
|
|
if (Array.isArray(item.outputFiles)) {
|
|
item.outputFiles = [...item.outputFiles, chatPath];
|
|
}
|
|
} catch (e) {
|
|
// Non-fatal: video download still succeeded.
|
|
appendDebugLog('chat-replay-failed', { itemId: item.id, error: String(e) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (finalResult.success) {
|
|
runtimeMetrics.downloadsCompleted += 1;
|
|
} else {
|
|
runtimeMetrics.downloadsFailed += 1;
|
|
}
|
|
|
|
appendDebugLog('queue-item-finished', {
|
|
itemId: item.id,
|
|
status: item.status,
|
|
error: item.last_error
|
|
});
|
|
|
|
saveQueue(downloadQueue);
|
|
if (!appShutdownStarted) emitQueueUpdated();
|
|
} finally {
|
|
queueProcessRegistry.releaseItem(item.id);
|
|
activeDownloads.delete(item.id);
|
|
cancelledItemIds.delete(item.id);
|
|
// Release only THIS item's claimed filenames (other parallel downloads keep their claims)
|
|
releaseClaimedFilenamesForItem(item.id);
|
|
clearDownloadProgress(item.id);
|
|
}
|
|
}
|
|
|
|
function scheduleQueueProcessing(manualOverride = false): boolean {
|
|
if (appShutdownStarted) return false;
|
|
if (!isDownloading && !canStartDownloadQueue(manualOverride)) return false;
|
|
return queueRunLifecycle.schedule(() => processQueue(manualOverride), (error) => {
|
|
appendDebugLog('queue-run-failed', String(error));
|
|
});
|
|
}
|
|
|
|
async function processQueue(manualOverride = false): Promise<void> {
|
|
if (appShutdownStarted || isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
|
if (!canStartDownloadQueue(manualOverride)) return;
|
|
|
|
appendDebugLog('queue-start', {
|
|
items: downloadQueue.length,
|
|
smartScheduler: config.smart_queue_scheduler,
|
|
performanceMode: config.performance_mode,
|
|
parallelDownloads: config.parallel_downloads || 1
|
|
});
|
|
|
|
isDownloading = true;
|
|
queuePaused = false;
|
|
cancelledItemIds.clear();
|
|
mainWindow?.webContents.send('download-started');
|
|
emitQueueUpdated();
|
|
|
|
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
|
|
const activePromises = new Map<string, Promise<void>>();
|
|
|
|
while (isDownloading) {
|
|
// Clean up finished promises
|
|
for (const [id] of activePromises) {
|
|
const queueItem = downloadQueue.find(i => i.id === id);
|
|
if (!queueItem || (queueItem.status !== 'downloading' && queueItem.status !== 'paused')) {
|
|
activePromises.delete(id);
|
|
}
|
|
}
|
|
|
|
// Fill available slots
|
|
while (activePromises.size < maxSlots && !queuePaused) {
|
|
const item = pickNextPendingQueueItem();
|
|
if (!item) break;
|
|
|
|
const itemPromise = processOneQueueItem(item);
|
|
activePromises.set(item.id, itemPromise);
|
|
}
|
|
|
|
if (activePromises.size === 0) break;
|
|
|
|
// Wait for any one download to finish before re-checking
|
|
await Promise.race([...activePromises.values()]);
|
|
}
|
|
|
|
// Wait for all remaining active downloads to complete
|
|
if (activePromises.size > 0) {
|
|
await Promise.allSettled([...activePromises.values()]);
|
|
}
|
|
|
|
isDownloading = false;
|
|
queuePaused = false;
|
|
runtimeMetrics.activeItemId = null;
|
|
runtimeMetrics.activeItemTitle = null;
|
|
activeQueueItemId = null;
|
|
activeDownloads.clear();
|
|
cancelledItemIds.clear();
|
|
|
|
saveQueue(downloadQueue);
|
|
if (appShutdownStarted) {
|
|
appendDebugLog('queue-finished', { items: downloadQueue.length, shutdown: true });
|
|
return;
|
|
}
|
|
emitQueueUpdated();
|
|
mainWindow?.webContents.send('download-finished');
|
|
try {
|
|
if (Notification.isSupported()) {
|
|
const completed = downloadQueue.filter(i => i.status === 'completed').length;
|
|
const failed = downloadQueue.filter(i => i.status === 'error').length;
|
|
const notification = new Notification({
|
|
title: 'Twitch VOD Manager',
|
|
body: failed > 0
|
|
? `${completed} Downloads fertig, ${failed} fehlgeschlagen`
|
|
: `${completed} Downloads abgeschlossen`,
|
|
icon: path.join(__dirname, '../build/icon.png')
|
|
});
|
|
// Click brings the app to the foreground AND opens the download
|
|
// folder so the user can immediately see the output files.
|
|
notification.on('click', () => {
|
|
try {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
}
|
|
if (config.download_path && fs.existsSync(config.download_path)) {
|
|
void shell.openPath(config.download_path);
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('notification-click-failed', String(e));
|
|
}
|
|
});
|
|
notification.show();
|
|
}
|
|
} catch { }
|
|
appendDebugLog('queue-finished', { items: downloadQueue.length });
|
|
}
|
|
|
|
// ==========================================
|
|
// WINDOW CREATION
|
|
// ==========================================
|
|
function resolveNativeThemeSource(theme: string): 'system' | 'light' | 'dark' {
|
|
if (theme === 'system') return 'system';
|
|
return theme === 'light' ? 'light' : 'dark';
|
|
}
|
|
|
|
function createWindow(): void {
|
|
nativeTheme.themeSource = resolveNativeThemeSource(config.theme);
|
|
const windowIconPath = WINDOWS_APP_ICON_PATH ?? path.join(__dirname, '../build/icon.png');
|
|
|
|
mainWindow = new BrowserWindow({
|
|
show: false,
|
|
width: 1400,
|
|
height: 900,
|
|
minWidth: 1200,
|
|
minHeight: 700,
|
|
title: `Twitch VOD Manager [v${APP_VERSION}]`,
|
|
backgroundColor: '#0e0e10',
|
|
icon: windowIconPath,
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
preload: path.join(__dirname, 'preload.js')
|
|
}
|
|
});
|
|
|
|
if (process.platform === 'win32') {
|
|
mainWindow.setAppDetails(createWindowsTaskbarDetails({
|
|
identity: WINDOWS_APP_IDENTITY,
|
|
iconPath: windowIconPath,
|
|
executablePath: process.execPath,
|
|
developmentRelaunchCommand: process.env.TWITCH_VOD_MANAGER_RELAUNCH_COMMAND,
|
|
isDevelopment: IS_HOT_DEVELOPMENT,
|
|
}));
|
|
}
|
|
mainWindow.show();
|
|
|
|
if (process.platform !== 'darwin') {
|
|
mainWindow.removeMenu();
|
|
}
|
|
|
|
const rendererFile = path.join(__dirname, '../src/index.html');
|
|
const rendererUrl = pathToFileURL(rendererFile).href;
|
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
|
if (url.split(/[?#]/, 1)[0] !== rendererUrl) event.preventDefault();
|
|
});
|
|
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
mainWindow.loadFile(rendererFile);
|
|
|
|
mainWindow.webContents.on('did-finish-load', () => {
|
|
emitQueueUpdated(true);
|
|
emitDownloadPolicyStatus(true);
|
|
if (isDownloading) {
|
|
mainWindow?.webContents.send('download-started');
|
|
}
|
|
|
|
const updateLifecycleSnapshot = autoUpdateLifecycle.snapshot;
|
|
if (updateLifecycleSnapshot.phase === 'ready') {
|
|
mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(updateLifecycleSnapshot.version));
|
|
}
|
|
|
|
// Auto-resume: if the user opted in AND the persisted queue has
|
|
// pending entries, kick off processing after a short delay so the
|
|
// UI has time to render and the user can still pause if they want.
|
|
if (config.auto_resume_queue_on_startup && !isDownloading) {
|
|
const hasPending = downloadQueue.some((it) => it.status === 'pending');
|
|
if (hasPending) {
|
|
appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length });
|
|
setTimeout(() => {
|
|
if (!appShutdownStarted && config.auto_resume_queue_on_startup && !isDownloading
|
|
&& downloadQueue.some((it) => it.status === 'pending')) {
|
|
scheduleQueueProcessing();
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
// Setup auto-updater after window is ready
|
|
autoUpdaterSetupTimer = setTimeout(() => {
|
|
autoUpdaterSetupTimer = null;
|
|
if (!appShutdownStarted) setupAutoUpdater();
|
|
}, 3000);
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-UPDATER (electron-updater)
|
|
// ==========================================
|
|
function normalizeReleaseVersionCandidate(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
return normalizeUpdateVersion(trimmed) || trimmed.replace(/^v/i, '');
|
|
}
|
|
|
|
function cacheLatestReleaseUpdateInfo(releaseData: any): void {
|
|
if (!releaseData || typeof releaseData !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const tagName = typeof releaseData.tag_name === 'string' ? releaseData.tag_name.trim() : '';
|
|
const version = normalizeReleaseVersionCandidate(tagName)
|
|
|| normalizeReleaseVersionCandidate(releaseData.name);
|
|
const releaseName = typeof releaseData.name === 'string' ? releaseData.name.trim() : '';
|
|
const releaseNotes = typeof releaseData.body === 'string' ? releaseData.body : '';
|
|
const releaseDate = typeof releaseData.published_at === 'string'
|
|
? releaseData.published_at
|
|
: (typeof releaseData.created_at === 'string' ? releaseData.created_at : undefined);
|
|
|
|
latestReleaseUpdateInfo = {
|
|
tagName: tagName || undefined,
|
|
version,
|
|
releaseDate,
|
|
releaseName: releaseName || undefined,
|
|
releaseNotes: releaseNotes.trim() ? releaseNotes : undefined
|
|
};
|
|
}
|
|
|
|
function buildUpdateInfoPayload(version: string, releaseDate?: string): {
|
|
version: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
} {
|
|
const normalizedVersion = normalizeReleaseVersionCandidate(version) || version;
|
|
const cachedVersion = latestReleaseUpdateInfo?.version
|
|
? (normalizeReleaseVersionCandidate(latestReleaseUpdateInfo.version) || latestReleaseUpdateInfo.version)
|
|
: undefined;
|
|
const hasMatchingReleaseInfo = !cachedVersion || cachedVersion === normalizedVersion;
|
|
|
|
return {
|
|
version: normalizedVersion,
|
|
releaseDate: releaseDate || (hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseDate : undefined),
|
|
releaseName: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseName : undefined,
|
|
releaseNotes: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseNotes : undefined
|
|
};
|
|
}
|
|
|
|
async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> {
|
|
if (appShutdownStarted) return { started: false, reason: 'shutting-down' };
|
|
if (autoUpdateCheckCoordinator.inProgress) {
|
|
return { started: false, reason: 'in-progress' };
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (!force && lastAutoUpdateCheckAt > 0 && (now - lastAutoUpdateCheckAt) < AUTO_UPDATE_MIN_CHECK_GAP_MS) {
|
|
return { started: false, reason: 'throttled' };
|
|
}
|
|
|
|
const lifecycleStart = autoUpdateLifecycle.beginCheck();
|
|
if (!lifecycleStart.started) return lifecycleStart;
|
|
|
|
const result = await autoUpdateCheckCoordinator.run(async () => {
|
|
lastAutoUpdateCheckAt = now;
|
|
appendDebugLog('update-check-start', { source });
|
|
try {
|
|
const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, {
|
|
timeout: 5000,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Twitch-VOD-Manager'
|
|
}
|
|
});
|
|
cacheLatestReleaseUpdateInfo(githubReleaseResponse.data);
|
|
const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name;
|
|
if (tagName) {
|
|
autoUpdater.setFeedURL({
|
|
provider: 'generic',
|
|
url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
|
|
});
|
|
appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME });
|
|
}
|
|
} catch (apiErr) {
|
|
appendDebugLog('github-api-failed', String(apiErr));
|
|
}
|
|
await autoUpdater.checkForUpdates();
|
|
}, AUTO_UPDATE_CHECK_TIMEOUT_MS);
|
|
|
|
if (result.state === 'completed') {
|
|
return { started: true };
|
|
}
|
|
if (result.state === 'timed-out') {
|
|
const failed = autoUpdateLifecycle.failCheck();
|
|
appendDebugLog('update-check-ui-timeout', { source, timeoutMs: AUTO_UPDATE_CHECK_TIMEOUT_MS, stateChanged: failed });
|
|
return { started: false, reason: 'timed-out' };
|
|
}
|
|
if (result.state === 'in-progress') {
|
|
return { started: false, reason: 'in-progress' };
|
|
}
|
|
|
|
const failed = autoUpdateLifecycle.failCheck();
|
|
appendDebugLog('update-check-failed', { source, error: String(result.error), stateChanged: failed });
|
|
if (failed) {
|
|
mainWindow?.webContents.send('update-error', { message: String(result.error), kind: 'check' });
|
|
}
|
|
console.error('Update check failed:', result.error);
|
|
return { started: false, reason: 'error' };
|
|
}
|
|
|
|
async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> {
|
|
if (appShutdownStarted) return { started: false, reason: 'shutting-down' };
|
|
const lifecycleSnapshot = autoUpdateLifecycle.snapshot;
|
|
const version = lifecycleSnapshot.phase === 'available' ? lifecycleSnapshot.version : latestKnownUpdateVersion || '';
|
|
const lifecycleStart = autoUpdateLifecycle.beginDownload(version);
|
|
if (!lifecycleStart.started) return lifecycleStart;
|
|
appendDebugLog('update-download-start', { source, version });
|
|
|
|
try {
|
|
await autoUpdater.downloadUpdate();
|
|
return { started: true };
|
|
} catch (err) {
|
|
const failed = autoUpdateLifecycle.failDownload(version);
|
|
appendDebugLog('update-download-failed', { source, version, error: String(err), stateChanged: failed });
|
|
if (failed) {
|
|
mainWindow?.webContents.send('update-error', { message: String(err), kind: 'download', version });
|
|
}
|
|
console.error('Download failed:', err);
|
|
return { started: false, reason: 'error' };
|
|
}
|
|
}
|
|
|
|
function stopAutoUpdatePolling(): void {
|
|
if (autoUpdaterSetupTimer) {
|
|
clearTimeout(autoUpdaterSetupTimer);
|
|
autoUpdaterSetupTimer = null;
|
|
}
|
|
if (autoUpdateCheckTimer) {
|
|
clearInterval(autoUpdateCheckTimer);
|
|
autoUpdateCheckTimer = null;
|
|
}
|
|
|
|
if (autoUpdateStartupTimer) {
|
|
clearTimeout(autoUpdateStartupTimer);
|
|
autoUpdateStartupTimer = null;
|
|
}
|
|
}
|
|
|
|
function startAutoUpdatePolling(): void {
|
|
if (appShutdownStarted) return;
|
|
if (!autoUpdateCheckTimer) {
|
|
autoUpdateCheckTimer = setInterval(() => {
|
|
void requestUpdateCheck('interval');
|
|
}, AUTO_UPDATE_CHECK_INTERVAL_MS);
|
|
|
|
autoUpdateCheckTimer.unref?.();
|
|
}
|
|
|
|
if (autoUpdateStartupTimer) {
|
|
clearTimeout(autoUpdateStartupTimer);
|
|
autoUpdateStartupTimer = null;
|
|
}
|
|
|
|
autoUpdateStartupTimer = setTimeout(() => {
|
|
autoUpdateStartupTimer = null;
|
|
void requestUpdateCheck('startup', true);
|
|
}, AUTO_UPDATE_STARTUP_CHECK_DELAY_MS);
|
|
}
|
|
|
|
function setupAutoUpdater() {
|
|
if (appShutdownStarted) return;
|
|
if (autoUpdaterInitialized) {
|
|
startAutoUpdatePolling();
|
|
return;
|
|
}
|
|
|
|
autoUpdaterInitialized = true;
|
|
autoUpdater.autoDownload = false;
|
|
autoUpdater.autoInstallOnAppQuit = true;
|
|
autoUpdater.autoRunAppAfterInstall = true;
|
|
|
|
autoUpdater.on('checking-for-update', () => {
|
|
if (autoUpdateLifecycle.snapshot.phase !== 'checking') {
|
|
appendDebugLog('auto-updater-checking-ignored');
|
|
return;
|
|
}
|
|
appendDebugLog('auto-updater-checking');
|
|
mainWindow?.webContents.send('update-checking');
|
|
});
|
|
|
|
autoUpdater.on('update-available', (info) => {
|
|
const incomingVersion = normalizeUpdateVersion(info.version);
|
|
const displayVersion = incomingVersion || info.version;
|
|
|
|
if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) {
|
|
autoUpdateLifecycle.failCheck();
|
|
appendDebugLog('update-available-ignored-older', {
|
|
incomingVersion: displayVersion,
|
|
knownVersion: latestKnownUpdateVersion
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!autoUpdateLifecycle.completeCheckAvailable(incomingVersion)) {
|
|
appendDebugLog('update-available-ignored-state', {
|
|
incomingVersion: displayVersion,
|
|
phase: autoUpdateLifecycle.snapshot.phase,
|
|
});
|
|
return;
|
|
}
|
|
|
|
latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion;
|
|
|
|
appendDebugLog('auto-updater-update-available', { version: displayVersion });
|
|
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate));
|
|
}
|
|
|
|
if (AUTO_UPDATE_AUTO_DOWNLOAD) {
|
|
void requestUpdateDownload('auto');
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('update-not-available', () => {
|
|
if (!autoUpdateLifecycle.completeCheckNotAvailable()) {
|
|
appendDebugLog('auto-updater-update-not-available-ignored', { phase: autoUpdateLifecycle.snapshot.phase });
|
|
return;
|
|
}
|
|
appendDebugLog('auto-updater-update-not-available');
|
|
mainWindow?.webContents.send('update-not-available');
|
|
});
|
|
|
|
autoUpdater.on('download-progress', (progress) => {
|
|
// No per-tick stdout — the autoUpdater fires this ~10x/sec during
|
|
// an in-flight download. The renderer banner is the user-visible
|
|
// surface; appendDebugLog already captures phase transitions.
|
|
if (autoUpdateLifecycle.snapshot.phase !== 'downloading') {
|
|
appendDebugLog('auto-updater-download-progress-ignored', { phase: autoUpdateLifecycle.snapshot.phase });
|
|
return;
|
|
}
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-download-progress', {
|
|
percent: progress.percent,
|
|
bytesPerSecond: progress.bytesPerSecond,
|
|
transferred: progress.transferred,
|
|
total: progress.total
|
|
});
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('update-downloaded', (info) => {
|
|
const downloadedVersion = normalizeUpdateVersion(info.version) || info.version;
|
|
if (!autoUpdateLifecycle.completeDownload(downloadedVersion)) {
|
|
appendDebugLog('auto-updater-update-downloaded-ignored', {
|
|
version: downloadedVersion,
|
|
phase: autoUpdateLifecycle.snapshot.phase,
|
|
});
|
|
return;
|
|
}
|
|
appendDebugLog('auto-updater-update-downloaded', { version: downloadedVersion });
|
|
if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) {
|
|
latestKnownUpdateVersion = downloadedVersion;
|
|
}
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate));
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('error', (err) => {
|
|
const message = String(err);
|
|
const lifecycleSnapshot = autoUpdateLifecycle.snapshot;
|
|
if (lifecycleSnapshot.phase === 'checking') {
|
|
autoUpdateLifecycle.failCheck();
|
|
appendDebugLog('auto-updater-error', { message, kind: 'check' });
|
|
mainWindow?.webContents.send('update-error', { message, kind: 'check' });
|
|
} else if (lifecycleSnapshot.phase === 'downloading') {
|
|
autoUpdateLifecycle.failDownload(lifecycleSnapshot.version);
|
|
appendDebugLog('auto-updater-error', { message, kind: 'download', version: lifecycleSnapshot.version });
|
|
mainWindow?.webContents.send('update-error', { message, kind: 'download', version: lifecycleSnapshot.version });
|
|
} else {
|
|
appendDebugLog('auto-updater-error-ignored', { message, phase: lifecycleSnapshot.phase });
|
|
}
|
|
console.error('Auto-updater error:', err);
|
|
});
|
|
|
|
startAutoUpdatePolling();
|
|
}
|
|
|
|
// ==========================================
|
|
// IPC HANDLERS
|
|
// ==========================================
|
|
ipcMain.handle('get-config', () => config);
|
|
|
|
ipcMain.handle('get-download-policy-status', (event) => {
|
|
if (!isTrustedRendererEvent(event)) return getDownloadPolicyStatus();
|
|
return getDownloadPolicyStatus();
|
|
});
|
|
|
|
ipcMain.handle('get-secret-status', (event) => {
|
|
if (!isTrustedRendererEvent(event) || !appSecretStore) {
|
|
return { encryptionAvailable: false, clientSecretConfigured: false, discordWebhookConfigured: false };
|
|
}
|
|
return appSecretStore.status();
|
|
});
|
|
|
|
ipcMain.handle('set-client-secret', (event, value: string) => {
|
|
if (!isTrustedRendererEvent(event) || !appSecretStore || !appSecretStore.status().encryptionAvailable) return appSecretStore?.status() ?? null;
|
|
const update = resolveSecretInputUpdate(typeof value === 'string' ? value : '', false);
|
|
if (update.action !== 'set') return appSecretStore.status();
|
|
appSecretStore.set('twitch_client_secret', update.value);
|
|
twitchClientSecret = update.value;
|
|
twitchAppTokenService.clear();
|
|
return appSecretStore.status();
|
|
});
|
|
|
|
ipcMain.handle('clear-client-secret', (event) => {
|
|
if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null;
|
|
appSecretStore.clear('twitch_client_secret');
|
|
twitchClientSecret = '';
|
|
twitchAppTokenService.clear();
|
|
return appSecretStore.status();
|
|
});
|
|
|
|
ipcMain.handle('set-discord-webhook', (event, value: string) => {
|
|
if (!isTrustedRendererEvent(event) || !appSecretStore || !appSecretStore.status().encryptionAvailable) return appSecretStore?.status() ?? null;
|
|
const update = resolveSecretInputUpdate(typeof value === 'string' ? value : '', false);
|
|
if (update.action !== 'set') return appSecretStore.status();
|
|
appSecretStore.set('discord_webhook_url', update.value);
|
|
discordWebhookUrl = update.value;
|
|
return appSecretStore.status();
|
|
});
|
|
|
|
ipcMain.handle('clear-discord-webhook', (event) => {
|
|
if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null;
|
|
appSecretStore.clear('discord_webhook_url');
|
|
discordWebhookUrl = '';
|
|
return appSecretStore.status();
|
|
});
|
|
|
|
ipcMain.handle('get-automation-status', () => ({
|
|
autoRecord: {
|
|
watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0,
|
|
lastRunAt: autoRecordLastRunAt,
|
|
nextRunAt: autoRecordNextRunAt,
|
|
lastTriggeredCount: autoRecordLastTriggerCount,
|
|
inFlight: autoRecordPollInFlight
|
|
},
|
|
autoVod: {
|
|
watching: Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers.length : 0,
|
|
lastRunAt: autoVodLastRunAt,
|
|
nextRunAt: autoVodNextRunAt,
|
|
lastQueuedCount: autoVodLastQueuedCount,
|
|
inFlight: autoVodPollInFlight
|
|
}
|
|
}));
|
|
|
|
ipcMain.handle('trigger-auto-record-scan', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { triggered: 0 };
|
|
const triggered = await runAutoRecordPoll();
|
|
return { triggered };
|
|
});
|
|
|
|
ipcMain.handle('trigger-auto-vod-scan', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { queuedCount: 0 };
|
|
const queuedCount = await runAutoVodPoll();
|
|
return { queuedCount };
|
|
});
|
|
|
|
function applyConfigTransition(previousConfig: Config, nextConfig: Config): Config {
|
|
const previousClientId = previousConfig.client_id;
|
|
const previousCacheMinutes = previousConfig.metadata_cache_minutes;
|
|
const previousPersistQueueOnRestart = previousConfig.persist_queue_on_restart;
|
|
const previousTheme = previousConfig.theme;
|
|
const previousAutoRecordList = JSON.stringify(previousConfig.auto_record_streamers || []);
|
|
const previousAutoRecordSeconds = previousConfig.auto_record_poll_seconds;
|
|
const previousAutoVodList = JSON.stringify(previousConfig.auto_vod_download_streamers || []);
|
|
const previousAutoVodMinutes = previousConfig.auto_vod_download_poll_minutes;
|
|
const previousStreamerList = JSON.stringify(previousConfig.streamers || []);
|
|
const previousDownloadPolicy = JSON.stringify(previousConfig.download_policy);
|
|
config = persistStateChange(previousConfig, () => 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 {
|
|
emitDownloadPolicyStatus(true);
|
|
}
|
|
|
|
if (config.client_id !== previousClientId) {
|
|
twitchAppTokenService.clear();
|
|
}
|
|
|
|
if (config.metadata_cache_minutes !== previousCacheMinutes) {
|
|
clearMetadataCaches();
|
|
}
|
|
|
|
if (config.theme !== previousTheme) {
|
|
nativeTheme.themeSource = resolveNativeThemeSource(config.theme);
|
|
}
|
|
|
|
if (config.persist_queue_on_restart === false) {
|
|
pendingQueueSnapshot = null;
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
clearQueueFileFromDisk();
|
|
} else if (previousPersistQueueOnRestart === false) {
|
|
saveQueue(downloadQueue, true);
|
|
}
|
|
|
|
const newAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
|
if (newAutoRecordList !== previousAutoRecordList || config.auto_record_poll_seconds !== previousAutoRecordSeconds) {
|
|
const watched = new Set(config.auto_record_streamers || []);
|
|
for (const k of Array.from(autoRecordLastLiveState.keys())) {
|
|
if (!watched.has(k)) autoRecordLastLiveState.delete(k);
|
|
}
|
|
restartAutoRecordPoller();
|
|
}
|
|
|
|
const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
|
if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) {
|
|
restartAutoVodPoller();
|
|
}
|
|
|
|
const newStreamerList = JSON.stringify(config.streamers || []);
|
|
if (newStreamerList !== previousStreamerList) {
|
|
restartLiveStatusPoller();
|
|
}
|
|
|
|
restartAutoCleanupTimer();
|
|
return config;
|
|
}
|
|
|
|
ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability?: string) => {
|
|
if (!isTrustedRendererEvent(event)) return config;
|
|
const acceptedConfig = sanitizeConfigInput(newConfig) as Partial<Config>;
|
|
if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) {
|
|
const selectedPath = typeof fileCapability === 'string'
|
|
? resolveFileCapability(event, fileCapability, 'selected-folder')
|
|
: null;
|
|
if (!selectedPath || normalizeComparablePath(selectedPath) !== normalizeComparablePath(acceptedConfig.download_path)) {
|
|
delete acceptedConfig.download_path;
|
|
}
|
|
}
|
|
const previousConfig = config;
|
|
const nextConfig = normalizeConfigTemplates({ ...previousConfig, ...acceptedConfig });
|
|
return applyConfigTransition(previousConfig, nextConfig);
|
|
});
|
|
|
|
ipcMain.handle('login', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return false;
|
|
return (await ensureTwitchAuth(true)) !== null;
|
|
});
|
|
|
|
ipcMain.handle('get-user-id', async (_, username: string) => {
|
|
return await getUserId(username);
|
|
});
|
|
|
|
ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = false) => {
|
|
return await getVODs(userId, forceRefresh);
|
|
});
|
|
|
|
ipcMain.handle('get-queue', (event) => {
|
|
if (!isTrustedRendererEvent(event)) return [];
|
|
rememberQueueFilePaths(downloadQueue);
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('start-live-recording', async (event, streamerName: string) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return { success: false, error: 'Access denied' };
|
|
if (typeof streamerName !== 'string' || !streamerName) {
|
|
return { success: false, error: 'Invalid streamer name' };
|
|
}
|
|
const login = normalizeLogin(streamerName);
|
|
if (!login) return { success: false, error: 'Invalid streamer name' };
|
|
|
|
const liveInfo = await getLiveStreamInfo(login);
|
|
if (appShutdownStarted) return { success: false, error: 'Access denied' };
|
|
if (liveInfo === null) {
|
|
return { success: false, error: 'Could not check live status. Try again.' };
|
|
}
|
|
if (!liveInfo.isLive) {
|
|
return { success: false, error: 'OFFLINE', streamer: login };
|
|
}
|
|
|
|
const channelUrl = `https://www.twitch.tv/${login}`;
|
|
const liveItem: QueueItem = {
|
|
id: generateQueueItemId(),
|
|
createdAt: new Date().toISOString(),
|
|
title: liveInfo.title || `${login} (LIVE)`,
|
|
url: channelUrl,
|
|
date: new Date().toISOString(),
|
|
streamer: login,
|
|
duration_str: '0s', // unknown — stream is in progress
|
|
status: 'pending',
|
|
progress: 0,
|
|
isLive: true
|
|
};
|
|
|
|
// Duplicate guard — refuse to start a second live recording of the
|
|
// same channel while one is already active or pending.
|
|
const dup = downloadQueue.some((it) => it.isLive && it.streamer === login
|
|
&& (it.status === 'pending' || it.status === 'downloading'));
|
|
if (dup) {
|
|
return { success: false, error: 'ALREADY_RECORDING', streamer: login };
|
|
}
|
|
|
|
const addition = commitQueueItemWithResult(liveItem, false);
|
|
if (!addition.accepted) {
|
|
return { success: false, error: addition.reason === 'duplicate' ? 'ALREADY_RECORDING' : addition.reason, streamer: login };
|
|
}
|
|
if (!isDownloading) scheduleQueueProcessing();
|
|
appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title });
|
|
return { success: true, streamer: login, title: liveInfo.title || login };
|
|
});
|
|
|
|
function commitQueueItemWithResult(item: QueueItem | null, notifyDuplicate: boolean): QueueAdditionResult<QueueItem> {
|
|
if (appShutdownStarted) {
|
|
return { queue: downloadQueue, accepted: false, reason: 'shutting-down' };
|
|
}
|
|
let duplicate = false;
|
|
const result = commitQueueAddition(
|
|
downloadQueue,
|
|
item,
|
|
(candidate) => {
|
|
duplicate = config.prevent_duplicate_downloads && hasActiveDuplicate(candidate);
|
|
return duplicate;
|
|
},
|
|
saveQueue,
|
|
);
|
|
if (duplicate && item) {
|
|
runtimeMetrics.duplicateSkips += 1;
|
|
if (notifyDuplicate) {
|
|
mainWindow?.webContents.send('queue-duplicate-skipped', {
|
|
title: item.title,
|
|
streamer: item.streamer,
|
|
url: item.url
|
|
});
|
|
}
|
|
appendDebugLog('queue-item-duplicate-skipped', {
|
|
title: item.title,
|
|
url: item.url,
|
|
streamer: item.streamer
|
|
});
|
|
}
|
|
if (result.accepted) {
|
|
downloadQueue = result.queue;
|
|
emitQueueUpdated();
|
|
} else if (result.reason === 'persistence-failed') {
|
|
appendDebugLog('queue-item-persist-failed', { title: item?.title || '', url: item?.url || '' });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function addRendererQueueItemWithResult(input: unknown, notifyDuplicate: boolean): QueueAdditionResult<QueueItem> {
|
|
const item = createRendererQueueItem(input, generateQueueItemId());
|
|
if (item) item.createdAt = new Date().toISOString();
|
|
return commitQueueItemWithResult(item, notifyDuplicate);
|
|
}
|
|
|
|
registerTrustedIpcHandler(ipcMain, 'add-to-queue', isTrustedRendererEvent, () => [], (_, input: unknown) => {
|
|
return addRendererQueueItemWithResult(input, true).queue;
|
|
});
|
|
|
|
registerTrustedIpcHandler(ipcMain, 'add-to-queue-with-result', isTrustedRendererEvent, () => ({ queue: [], accepted: false, reason: 'access-denied' as const }), (_, input: unknown) => {
|
|
return addRendererQueueItemWithResult(input, false);
|
|
});
|
|
|
|
registerTrustedIpcHandler(ipcMain, 'remove-from-queue', isTrustedRendererEvent, () => Promise.resolve(downloadQueue), async (_, id: string) => {
|
|
if (typeof id !== 'string' || !id) return downloadQueue;
|
|
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id) || queueProcessRegistry.activeItemIds().includes(id);
|
|
const removedItem = downloadQueue.find((item) => item.id === id);
|
|
|
|
if (removedItem?.mergeRecoveryBlocked) {
|
|
const recovery = recoverInterruptedMergeArtifacts([removedItem], config.download_path, new Set([removedItem.id]));
|
|
const recoveredItem = recovery.queue[0];
|
|
if (recovery.failedFiles.length > 0) {
|
|
recoveredItem.last_error = tBackend('mergeRecoveryBlocked');
|
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.map((item) => item.id === id ? recoveredItem : item), saveQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
}
|
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.map((item) => item.id === id ? recoveredItem : item), saveQueue);
|
|
}
|
|
|
|
await commitQueueMutation(
|
|
downloadQueue,
|
|
(current) => current.filter((item) => item.id !== id),
|
|
saveQueue,
|
|
(nextQueue) => { downloadQueue = nextQueue; },
|
|
async () => {
|
|
if (wasActiveItem) {
|
|
cancelledItemIds.add(id);
|
|
await queueProcessRegistry.cancelItem(id);
|
|
activeDownloads.delete(id);
|
|
const nextActiveId = queueProcessRegistry.activeItemIds()[0] || null;
|
|
activeQueueItemId = nextActiveId;
|
|
runtimeMetrics.activeItemId = nextActiveId;
|
|
runtimeMetrics.activeItemTitle = nextActiveId ? downloadQueue.find((item) => item.id === nextActiveId)?.title || null : null;
|
|
appendDebugLog('queue-item-removed-active-cancelled', { id });
|
|
}
|
|
for (const cleanupPath of getMergeGroupCleanupPaths(removedItem)) {
|
|
try { if (fs.existsSync(cleanupPath)) fs.unlinkSync(cleanupPath); } catch { }
|
|
}
|
|
},
|
|
);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('clear-completed', (event) => {
|
|
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.filter((item) => item.status !== 'completed'), saveQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('reorder-queue', (event, orderIds: string[]) => {
|
|
if (!isTrustedRendererEvent(event) || !Array.isArray(orderIds)) return downloadQueue;
|
|
const order = new Map(orderIds.map((id, idx) => [id, idx]));
|
|
const withOrder = [...downloadQueue].sort((a, b) => {
|
|
const ai = order.has(a.id) ? (order.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
|
|
const bi = order.has(b.id) ? (order.get(b.id) as number) : Number.MAX_SAFE_INTEGER;
|
|
return ai - bi;
|
|
});
|
|
|
|
downloadQueue = persistStateChange(downloadQueue, () => withOrder, saveQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('retry-failed-downloads', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
|
const failedIds = downloadQueue.filter((item) => item.status === 'error' && !item.mergeRecoveryBlocked).map((item) => item.id);
|
|
await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id)));
|
|
for (const id of failedIds) queueProcessRegistry.resetItem(id);
|
|
const nextQueue: QueueItem[] = downloadQueue.map((item) => {
|
|
if (item.status !== 'error' || item.mergeRecoveryBlocked) return item;
|
|
|
|
return { ...clearQueueTransferState(item, 'pending', 0), last_error: '' };
|
|
});
|
|
|
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
|
emitQueueUpdated();
|
|
|
|
if (!isDownloading) {
|
|
scheduleQueueProcessing();
|
|
}
|
|
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('retry-queue-item', async (event, id: string) => {
|
|
if (!isTrustedRendererEvent(event)) return downloadQueue;
|
|
if (typeof id !== 'string' || !id) return downloadQueue;
|
|
const idx = downloadQueue.findIndex((it) => it.id === id);
|
|
if (idx < 0) return downloadQueue;
|
|
const item = downloadQueue[idx];
|
|
if (item.status !== 'error' || item.mergeRecoveryBlocked) return downloadQueue;
|
|
|
|
await queueProcessRegistry.cancelItem(id);
|
|
queueProcessRegistry.resetItem(id);
|
|
|
|
downloadQueue = persistStateChange(downloadQueue, (current) => current.map((candidate) => candidate.id === id
|
|
? { ...clearQueueTransferState(candidate, 'pending', 0), last_error: '' }
|
|
: candidate), saveQueue);
|
|
emitQueueUpdated();
|
|
appendDebugLog('queue-item-retry-single', { id, title: item.title });
|
|
|
|
if (!isDownloading) {
|
|
scheduleQueueProcessing();
|
|
}
|
|
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('create-merge-group', (event, itemIds: string[]) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted || !Array.isArray(itemIds)) return downloadQueue;
|
|
const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id));
|
|
|
|
if (selectedItems.length < 2) {
|
|
return downloadQueue;
|
|
}
|
|
|
|
// Validate all are pending
|
|
if (selectedItems.some(item => item.status !== 'pending')) {
|
|
return downloadQueue;
|
|
}
|
|
|
|
// Preserve user-defined order from renderer (itemIds array order)
|
|
const sorted = itemIds
|
|
.map(id => selectedItems.find(item => item.id === id))
|
|
.filter((item): item is QueueItem => item !== undefined);
|
|
|
|
// Calculate total duration
|
|
const totalDurationSec = sorted.reduce((sum, item) => sum + parseDuration(item.duration_str), 0);
|
|
const totalDurationStr = (() => {
|
|
const h = Math.floor(totalDurationSec / 3600);
|
|
const m = Math.floor((totalDurationSec % 3600) / 60);
|
|
const s = totalDurationSec % 60;
|
|
const parts: string[] = [];
|
|
if (h > 0) parts.push(`${h}h`);
|
|
if (m > 0) parts.push(`${m}m`);
|
|
if (s > 0 || parts.length === 0) parts.push(`${s}s`);
|
|
return parts.join('');
|
|
})();
|
|
|
|
// Generate title (language-aware)
|
|
const first = sorted[0];
|
|
const isEnglish = config.language === 'en';
|
|
const title = sorted.length === 2
|
|
? `Merge: ${first.title} + ${sorted[1].title}`
|
|
: `Merge: ${first.title} + ${sorted.length - 1} ${isEnglish ? 'more' : 'weitere'}`;
|
|
|
|
// Build merge group
|
|
const mergeGroup: MergeGroup = {
|
|
items: sorted.map(item => ({
|
|
url: item.url,
|
|
title: item.title,
|
|
date: item.date,
|
|
streamer: item.streamer,
|
|
duration_str: item.duration_str
|
|
})),
|
|
mergePhase: 'downloading',
|
|
currentItemIndex: 0,
|
|
downloadedFiles: {},
|
|
totalDurationSec
|
|
};
|
|
|
|
// Create merged queue item
|
|
const mergedItem: QueueItem = {
|
|
id: generateQueueItemId(),
|
|
createdAt: new Date().toISOString(),
|
|
title,
|
|
url: first.url,
|
|
date: first.date,
|
|
streamer: first.streamer,
|
|
duration_str: totalDurationStr,
|
|
status: 'pending',
|
|
progress: 0,
|
|
mergeGroup
|
|
};
|
|
|
|
// Find position of first selected item
|
|
const firstIndex = downloadQueue.findIndex(item => itemIds.includes(item.id));
|
|
|
|
// Remove selected items and insert merged item at first position
|
|
const nextQueue = downloadQueue.filter((item) => !itemIds.includes(item.id));
|
|
nextQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, nextQueue.length) : nextQueue.length, 0, mergedItem);
|
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
|
emitQueueUpdated();
|
|
return downloadQueue;
|
|
});
|
|
|
|
ipcMain.handle('start-download', async (event, manualOverride: unknown = false) => {
|
|
if (!isTrustedRendererEvent(event)) return false;
|
|
if (isDownloading && queuePaused) {
|
|
const nextQueue = downloadQueue.map((item) => item.status === 'paused'
|
|
? clearQueueTransferState(item, 'downloading', item.progress)
|
|
: item);
|
|
saveQueue(nextQueue);
|
|
downloadQueue = applyQueueSnapshotPreservingActiveItems(downloadQueue, nextQueue, new Set(queueProcessRegistry.activeItemIds()));
|
|
queuePaused = false;
|
|
await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.resumeItem(id)));
|
|
emitQueueUpdated(true);
|
|
mainWindow?.webContents.send('download-started');
|
|
return true;
|
|
}
|
|
|
|
const nextQueue = downloadQueue.map((item) => item.status === 'paused'
|
|
? clearQueueTransferState(item, 'pending', 0)
|
|
: item);
|
|
|
|
const hasPendingItems = nextQueue.some(item => item.status === 'pending');
|
|
if (!hasPendingItems) {
|
|
emitQueueUpdated();
|
|
return false;
|
|
}
|
|
|
|
downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue);
|
|
emitQueueUpdated();
|
|
|
|
if (!isDownloading) {
|
|
scheduleQueueProcessing(manualOverride === true);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('pause-download', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return false;
|
|
if (!isDownloading || queuePaused) return false;
|
|
|
|
const nextQueue = downloadQueue.map((item) => {
|
|
if (item.status !== 'downloading') return item;
|
|
const waitsForBoundary = Boolean(item.mergeGroup && (item.mergeGroup.mergePhase === 'merging' || item.mergeGroup.mergePhase === 'splitting'));
|
|
return {
|
|
...item,
|
|
status: waitsForBoundary ? 'downloading' as const : 'paused' as const,
|
|
speed: '',
|
|
eta: '',
|
|
progressStatus: waitsForBoundary ? tBackend('downloadPausePending') : tBackend('downloadPaused'),
|
|
recordingHealth: undefined,
|
|
};
|
|
});
|
|
await commitQueueMutation(
|
|
downloadQueue,
|
|
() => nextQueue,
|
|
saveQueue,
|
|
(candidate) => {
|
|
downloadQueue = applyQueueSnapshotPreservingActiveItems(downloadQueue, candidate, new Set(queueProcessRegistry.activeItemIds()));
|
|
queuePaused = true;
|
|
},
|
|
async () => {
|
|
await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.pauseItem(id)));
|
|
},
|
|
);
|
|
emitQueueUpdated(true);
|
|
mainWindow?.webContents.send('download-paused');
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('cancel-download', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return false;
|
|
isDownloading = false;
|
|
queuePaused = false;
|
|
const activeItemIds = queueProcessRegistry.activeItemIds();
|
|
for (const id of activeItemIds) cancelledItemIds.add(id);
|
|
await queueProcessRegistry.cancelAll();
|
|
return true;
|
|
});
|
|
|
|
const fileCapabilities = new FileCapabilityStore();
|
|
const VIDEO_FILE_EXTENSIONS = ['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ts', 'avi'];
|
|
const knownRendererPaths = new Map<FileCapabilityPurpose, Set<string>>();
|
|
const activeChatReadControllers = new Map<number, Map<string, AbortController>>();
|
|
|
|
function issueFileCapability(event: IpcMainInvokeEvent, purpose: FileCapabilityPurpose, filePath: string, kind: 'input-file' | 'output-file' | 'directory', extensions: string[] = [], ttlMs?: number): FileCapabilityReference {
|
|
return fileCapabilities.issue({ ownerId: event.sender.id, purpose, path: filePath, kind, extensions, ttlMs });
|
|
}
|
|
|
|
function resolveFileCapability(event: IpcMainInvokeEvent, token: string, purpose: FileCapabilityPurpose, consume = false, protectedPaths: string[] = []): string | null {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
try {
|
|
return consume
|
|
? fileCapabilities.consume(token, event.sender.id, purpose, protectedPaths)
|
|
: fileCapabilities.resolve(token, event.sender.id, purpose);
|
|
} catch (error) {
|
|
appendDebugLog('file-capability-rejected', { purpose, error: String(error) });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rememberRendererPath(purpose: FileCapabilityPurpose, filePath: string): void {
|
|
if (typeof filePath !== 'string' || !filePath || !path.isAbsolute(filePath) || !fs.existsSync(filePath)) return;
|
|
const normalized = normalizeComparablePath(filePath);
|
|
const paths = knownRendererPaths.get(purpose) ?? new Set<string>();
|
|
paths.delete(normalized);
|
|
paths.add(normalized);
|
|
while (paths.size > 4096) paths.delete(paths.values().next().value as string);
|
|
knownRendererPaths.set(purpose, paths);
|
|
}
|
|
|
|
function rememberQueueFilePaths(queueItems: QueueItem[]): void {
|
|
for (const item of queueItems) {
|
|
for (const filePath of item.outputFiles ?? []) {
|
|
rememberRendererPath('open-file', filePath);
|
|
rememberRendererPath('show-in-folder', filePath);
|
|
if (/\.(?:chat\.json|chat\.jsonl|events\.jsonl)$/i.test(filePath)) rememberRendererPath('chat-input', filePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isKnownRendererPath(purpose: FileCapabilityPurpose, candidate: string): boolean {
|
|
if (typeof candidate !== 'string' || !candidate || !path.isAbsolute(candidate) || !fs.existsSync(candidate)) return false;
|
|
const normalized = normalizeComparablePath(candidate);
|
|
if (purpose === 'selected-folder' && normalized === normalizeComparablePath(config.download_path)) return true;
|
|
return knownRendererPaths.get(purpose)?.has(normalized) === true;
|
|
}
|
|
|
|
ipcMain.handle('select-folder', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openDirectory']
|
|
});
|
|
const selectedPath = result.filePaths[0];
|
|
if (!selectedPath) return null;
|
|
const capability = issueFileCapability(event, 'selected-folder', selectedPath, 'directory');
|
|
return { ...capability, displayPath: selectedPath };
|
|
});
|
|
|
|
ipcMain.handle('select-video-file', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{ name: 'Video Files', extensions: VIDEO_FILE_EXTENSIONS }
|
|
]
|
|
});
|
|
return result.filePaths[0]
|
|
? issueFileCapability(event, 'cutter-input', result.filePaths[0], 'input-file', VIDEO_FILE_EXTENSIONS, CUTTER_SESSION_CAPABILITY_TTL_MS)
|
|
: null;
|
|
});
|
|
|
|
ipcMain.handle('grant-dropped-video', (event, filePath: string): FileCapabilityReference | null => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
try {
|
|
return issueFileCapability(event, 'cutter-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS, CUTTER_SESSION_CAPABILITY_TTL_MS);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('authorize-managed-path', (event, purpose: FileCapabilityPurpose, pathOrCapability: string): FileCapabilityReference | null => {
|
|
if (!isTrustedRendererEvent(event) || !['selected-folder', 'chat-input', 'open-file', 'show-in-folder'].includes(purpose)) return null;
|
|
try {
|
|
const existingPath = fileCapabilities.resolve(pathOrCapability, event.sender.id, purpose);
|
|
return { token: pathOrCapability, name: path.basename(existingPath) };
|
|
} catch { }
|
|
if (!isKnownRendererPath(purpose, pathOrCapability)) return null;
|
|
if (purpose === 'selected-folder') {
|
|
if (!fs.statSync(pathOrCapability).isDirectory()) return null;
|
|
return issueFileCapability(event, purpose, pathOrCapability, 'directory');
|
|
}
|
|
const extensions = purpose === 'chat-input' ? ['.chat.json', '.chat.jsonl', '.events.jsonl'] : [];
|
|
return issueFileCapability(event, purpose, pathOrCapability, 'input-file', extensions);
|
|
});
|
|
|
|
ipcMain.handle('open-folder', async (event, capability: string) => {
|
|
const folderPath = resolveFileCapability(event, capability, 'selected-folder', true);
|
|
if (folderPath) await shell.openPath(folderPath);
|
|
});
|
|
|
|
const SAFE_ARCHIVE_OPEN_EXTENSIONS = new Set([
|
|
'.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi',
|
|
'.aac', '.m4a', '.mp3', '.wav', '.ogg',
|
|
'.json', '.jsonl', '.txt', '.srt', '.vtt',
|
|
'.jpg', '.jpeg', '.png', '.webp'
|
|
]);
|
|
|
|
ipcMain.handle('open-file', async (event, capability: string): Promise<boolean> => {
|
|
const filePath = resolveFileCapability(event, capability, 'open-file', true);
|
|
if (!filePath) return false;
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (!SAFE_ARCHIVE_OPEN_EXTENSIONS.has(ext)) {
|
|
appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) });
|
|
return false;
|
|
}
|
|
const result = await shell.openPath(filePath);
|
|
// shell.openPath returns '' on success, an error string on failure.
|
|
return result === '';
|
|
});
|
|
|
|
ipcMain.handle('show-in-folder', (event, capability: string): boolean => {
|
|
const filePath = resolveFileCapability(event, capability, 'show-in-folder', true);
|
|
if (!filePath) return false;
|
|
shell.showItemInFolder(filePath);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('get-version', () => APP_VERSION);
|
|
|
|
ipcMain.handle('check-update', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { error: true };
|
|
try {
|
|
setupAutoUpdater();
|
|
const result = await requestUpdateCheck('manual', true);
|
|
if (result.reason === 'error') {
|
|
return { error: true };
|
|
}
|
|
|
|
return result.started
|
|
? { checking: true }
|
|
: { checking: true, skipped: result.reason };
|
|
} catch (err) {
|
|
console.error('Update check failed:', err);
|
|
return { error: true };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('download-update', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { error: true };
|
|
try {
|
|
setupAutoUpdater();
|
|
const result = await requestUpdateDownload('manual');
|
|
if (result.reason === 'error') {
|
|
return { error: true };
|
|
}
|
|
|
|
return result.started
|
|
? { downloading: true }
|
|
: { downloading: true, skipped: result.reason };
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
return { error: true };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('install-update', (event) => {
|
|
if (!isTrustedRendererEvent(event)) return;
|
|
autoUpdater.quitAndInstall(true, true);
|
|
});
|
|
|
|
ipcMain.handle('open-external', async (event, url: string) => {
|
|
if (!isTrustedRendererEvent(event)) return;
|
|
// Only allow https / http URLs — never let the renderer push a
|
|
// file://, javascript:, or shell:-style URL through to the OS
|
|
// shell.openExternal handler. The renderer is contextIsolated +
|
|
// nodeIntegration: false, but an XSS through (e.g.) a streamer name
|
|
// smuggling a payload into a template would otherwise hand the
|
|
// attacker shell.openExternal which on Windows happily resolves
|
|
// file:///C:/Windows/System32/calc.exe.
|
|
if (typeof url !== 'string') return;
|
|
const trimmed = url.trim();
|
|
if (!/^https?:\/\//i.test(trimmed)) {
|
|
appendDebugLog('open-external-rejected', { url: trimmed.slice(0, 200) });
|
|
return;
|
|
}
|
|
await shell.openExternal(trimmed);
|
|
});
|
|
|
|
// Tracks active standalone clip downloads so cancel-download / window-all-closed
|
|
// can kill them. Separate from activeDownloads (queue) because clip downloads
|
|
// don't go through the queue scheduler.
|
|
interface ActiveClipDownloadTracking {
|
|
process: ChildProcess;
|
|
output: PausableOutput;
|
|
partialFilename: string;
|
|
}
|
|
const activeClipProcesses = new Set<ActiveClipDownloadTracking>();
|
|
|
|
registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () => Promise.resolve({ success: false, error: 'File access denied' }), async (_, clipUrl: string) => {
|
|
if (appShutdownStarted) return { success: false, error: 'shutting-down' };
|
|
const policyDecision = decideStandaloneDownloadStart(config.download_policy, new Date());
|
|
if (!policyDecision.allowed) {
|
|
const nextStart = policyDecision.nextStart
|
|
? `${policyDecision.nextStart.getHours().toString().padStart(2, '0')}:${policyDecision.nextStart.getMinutes().toString().padStart(2, '0')}`
|
|
: '--:--';
|
|
return { success: false, error: tBackend('downloadOutsideWindow', { nextStart }) };
|
|
}
|
|
|
|
let clipId = '';
|
|
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
|
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/);
|
|
|
|
if (match1) clipId = match1[1];
|
|
else if (match2) clipId = match2[1];
|
|
else return { success: false, error: tBackend('invalidClipUrl') };
|
|
|
|
const clipInfo = await getClipInfo(clipId);
|
|
if (appShutdownStarted) return { success: false, error: 'shutting-down' };
|
|
if (!clipInfo) return { success: false, error: tBackend('clipNotFound') };
|
|
|
|
// Sanitize broadcaster_name for path safety — Twitch returns the display
|
|
// name which can contain unicode, spaces, or punctuation that breaks
|
|
// path joining on some Windows configurations.
|
|
const safeBroadcaster = sanitizeFilenamePart(
|
|
typeof clipInfo.broadcaster_name === 'string' ? clipInfo.broadcaster_name : '',
|
|
'unknown'
|
|
);
|
|
const folder = path.join(config.download_path, 'Clips', safeBroadcaster);
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
|
if (!clipDiskCheck.success) {
|
|
return { success: false, error: clipDiskCheck.error || tBackend('diskSpaceShortGeneric') };
|
|
}
|
|
|
|
const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : '';
|
|
const safeTitle = (rawTitle.replace(/[^a-zA-Z0-9_\- ]/g, '').trim().substring(0, 50)) || 'clip';
|
|
// Use ensureUniqueFilename so retrying a clip with the same title doesn't
|
|
// overwrite the previous download. itemId is the clipId — if the user
|
|
// cancels via cancel-download, that's the handle.
|
|
const filename = ensureUniqueFilename(path.join(folder, `${safeTitle}.mp4`), clipId);
|
|
|
|
return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => {
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
const partialFilename = partialDownloadRegistry.begin(filename);
|
|
if (appShutdownStarted) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
releaseClaimedFilenamesForItem(clipId);
|
|
resolve({ success: false, error: 'shutting-down' });
|
|
return;
|
|
}
|
|
recordManagedToolExecution('streamlink', streamlinkCmd.command);
|
|
const proc = spawn(streamlinkCmd.command, [
|
|
...streamlinkCmd.prefixArgs,
|
|
`https://clips.twitch.tv/${clipId}`,
|
|
getStreamlinkStreamArg(),
|
|
'--stdout'
|
|
], { windowsHide: true });
|
|
if (!proc.stdout) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
releaseClaimedFilenamesForItem(clipId);
|
|
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
|
return;
|
|
}
|
|
const output = createPausableOutput(
|
|
proc.stdout,
|
|
fs.createWriteStream(partialFilename, { flags: 'w' }),
|
|
createDownloadThrottleTransform(),
|
|
);
|
|
const outputFinished = output.finished.then(() => null, (error) => error);
|
|
const tracking = { process: proc, output, partialFilename };
|
|
let settled = false;
|
|
const finish = (result: { success: boolean; error?: string; filename?: string }): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
activeClipProcesses.delete(tracking);
|
|
releaseClaimedFilenamesForItem(clipId);
|
|
resolve(result);
|
|
};
|
|
|
|
activeClipProcesses.add(tracking);
|
|
appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename });
|
|
|
|
proc.on('close', async (code) => {
|
|
const outputError = await outputFinished;
|
|
if (settled) return;
|
|
|
|
if (appShutdownStarted) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
finish({ success: false, error: 'shutting-down' });
|
|
return;
|
|
}
|
|
|
|
if (outputError || code !== 0 || !fs.existsSync(partialFilename)) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
appendDebugLog('clip-download-failed', { clipId, code });
|
|
finish({ success: false, error: outputError ? String(outputError) : tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) });
|
|
return;
|
|
}
|
|
|
|
// Integrity: clips are short but should still be at least a few KB
|
|
// and parse as a video stream via ffprobe. Empty/zero-byte files
|
|
// were previously reported as "success" because exit code was 0.
|
|
const stats = fs.statSync(partialFilename);
|
|
if (stats.size < 16 * 1024) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
|
|
finish({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) });
|
|
return;
|
|
}
|
|
|
|
const integrity = validateDownloadedFileIntegrity(partialFilename, null);
|
|
if (!integrity.success) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
|
|
finish({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
partialDownloadRegistry.commit(partialFilename, filename);
|
|
} catch (error) {
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
finish({ success: false, error: String(error) });
|
|
return;
|
|
}
|
|
appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename });
|
|
finish({ success: true, filename });
|
|
});
|
|
|
|
proc.on('error', async () => {
|
|
await output.cancel();
|
|
if (settled) return;
|
|
partialDownloadRegistry.discard(partialFilename);
|
|
finish({ success: false, error: tBackend('streamlinkNotFound') });
|
|
});
|
|
});
|
|
});
|
|
|
|
registerTrustedIpcHandler(ipcMain, 'run-preflight', isTrustedRendererEvent, () => Promise.resolve(null), async (_, autoFix: boolean = false) => {
|
|
return await runPreflight(autoFix);
|
|
});
|
|
|
|
ipcMain.handle('get-managed-tool-status', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
return await getManagedToolStatuses();
|
|
});
|
|
|
|
ipcMain.handle('get-managed-tool-execution-diagnostics', (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
return managedToolExecutionTracker.snapshot();
|
|
});
|
|
|
|
ipcMain.handle('repair-managed-tools', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
return await repairManagedTools();
|
|
});
|
|
|
|
ipcMain.handle('reset-managed-tools', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
return await resetManagedTools();
|
|
});
|
|
|
|
registerTrustedIpcHandler(ipcMain, 'get-debug-log', isTrustedRendererEvent, () => Promise.resolve(''), async (_, lines: number = 200) => {
|
|
// Cap so a misbehaving renderer (or future feature) cannot ask the
|
|
// main process to slice millions of lines from a multi-MB log.
|
|
const safeLines = Number.isFinite(lines) ? Math.max(1, Math.min(5000, Math.floor(lines))) : 200;
|
|
return readDebugLog(safeLines);
|
|
});
|
|
|
|
ipcMain.handle('open-debug-log-file', (event): boolean => {
|
|
if (!isTrustedRendererEvent(event)) return false;
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) return false;
|
|
shell.showItemInFolder(DEBUG_LOG_FILE);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('get-archive-stats', (event): ArchiveStats => {
|
|
if (!isTrustedRendererEvent(event)) throw new Error('File access denied');
|
|
return computeArchiveStats();
|
|
});
|
|
|
|
ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: boolean): Promise<StreamerProfile | null> => {
|
|
return await getStreamerProfile(login, forceRefresh === true);
|
|
});
|
|
|
|
ipcMain.handle('get-streamer-display-names', async (_, logins: string[]): Promise<Record<string, string>> => {
|
|
return await getStreamerDisplayNames(Array.isArray(logins) ? logins : []);
|
|
});
|
|
|
|
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
|
|
return await getVodStoryboard(vodId);
|
|
});
|
|
|
|
ipcMain.handle('get-live-status-snapshot', (): Record<string, boolean> => {
|
|
const snap: Record<string, boolean> = {};
|
|
for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v;
|
|
return snap;
|
|
});
|
|
|
|
ipcMain.handle('search-archive', (event, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
|
if (!isTrustedRendererEvent(event)) throw new Error('File access denied');
|
|
const normalized: ArchiveSearchFilter = {
|
|
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
|
type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events')
|
|
? filter!.type as 'all' | 'live' | 'vod' | 'chat' | 'events'
|
|
: 'all',
|
|
streamer: typeof filter?.streamer === 'string' ? filter.streamer.trim() : '',
|
|
sinceMs: Number.isFinite(filter?.sinceMs as number) ? Number(filter?.sinceMs) : null,
|
|
untilMs: Number.isFinite(filter?.untilMs as number) ? Number(filter?.untilMs) : null,
|
|
sort: (['date_desc', 'date_asc', 'size_desc', 'size_asc', 'name_asc'] as const).includes(filter?.sort as 'date_desc')
|
|
? filter!.sort as 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'
|
|
: 'date_desc',
|
|
limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200
|
|
};
|
|
const result = searchArchive(normalized);
|
|
for (const hit of result.hits) {
|
|
rememberRendererPath('open-file', hit.fullPath);
|
|
rememberRendererPath('show-in-folder', hit.fullPath);
|
|
for (const sidecar of [hit.chatPath, hit.eventsPath]) {
|
|
if (!sidecar) continue;
|
|
rememberRendererPath('open-file', sidecar);
|
|
rememberRendererPath('show-in-folder', sidecar);
|
|
rememberRendererPath('chat-input', sidecar);
|
|
}
|
|
}
|
|
return result;
|
|
});
|
|
|
|
ipcMain.handle('get-storage-stats', (event): StorageStatsResult => {
|
|
if (!isTrustedRendererEvent(event)) throw new Error('File access denied');
|
|
const result = computeStorageStats();
|
|
for (const row of [...result.streamers, ...result.extras]) rememberRendererPath('selected-folder', row.folderPath);
|
|
return result;
|
|
});
|
|
|
|
ipcMain.handle('run-storage-cleanup', (event, options?: { dryRun?: boolean }): CleanupReport => {
|
|
if (!isTrustedRendererEvent(event)) throw new Error('File access denied');
|
|
return runStorageCleanup({ dryRun: options?.dryRun === true });
|
|
});
|
|
|
|
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
|
|
// return a normalized message list the renderer can display directly.
|
|
// Caps at 50k messages to stop a runaway file from killing the renderer.
|
|
ipcMain.on('cancel-chat-read', (event, requestId: string) => {
|
|
if (!isTrustedRendererEvent(event) || typeof requestId !== 'string') return;
|
|
activeChatReadControllers.get(event.sender.id)?.get(requestId)?.abort();
|
|
});
|
|
|
|
ipcMain.handle('read-chat-file', async (event, capability: string, requestId?: string) => {
|
|
const filePath = resolveFileCapability(event, capability, 'chat-input', true);
|
|
if (!filePath) return { success: false, error: 'File access denied' };
|
|
const controller = new AbortController();
|
|
const validRequestId = typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128
|
|
? requestId
|
|
: null;
|
|
if (validRequestId) {
|
|
const controllers = activeChatReadControllers.get(event.sender.id) ?? new Map<string, AbortController>();
|
|
controllers.set(validRequestId, controller);
|
|
activeChatReadControllers.set(event.sender.id, controllers);
|
|
}
|
|
try {
|
|
return await readChatFile(filePath, { signal: controller.signal });
|
|
} finally {
|
|
if (validRequestId) {
|
|
const controllers = activeChatReadControllers.get(event.sender.id);
|
|
controllers?.delete(validRequestId);
|
|
if (controllers?.size === 0) activeChatReadControllers.delete(event.sender.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('check-folder-writable', (event, capability: string): boolean => {
|
|
const folderPath = resolveFileCapability(event, capability, 'selected-folder', true);
|
|
if (!folderPath) return false;
|
|
return isDownloadPathWritable(folderPath);
|
|
});
|
|
|
|
ipcMain.handle('is-downloading', () => isDownloading && !queuePaused);
|
|
|
|
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
|
|
|
|
ipcMain.handle('export-runtime-metrics', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { success: false, error: 'File access denied' };
|
|
try {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const defaultName = `runtime-metrics-${timestamp}.json`;
|
|
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
|
|
|
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: path.join(preferredDir, defaultName),
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
|
|
if (dialogResult.canceled || !dialogResult.filePath) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
const outputCapability = issueFileCapability(event, 'runtime-export', dialogResult.filePath, 'output-file', ['json']);
|
|
const outputFile = resolveFileCapability(event, outputCapability.token, 'runtime-export', true);
|
|
if (!outputFile) return { success: false, error: 'File access denied' };
|
|
const snapshot = getRuntimeMetricsSnapshot();
|
|
// Atomic write: same fsync+rename pattern used for config/queue
|
|
// (cycle 1) so a power loss mid-export can't leave a half-written
|
|
// metrics file at the user's chosen path.
|
|
writeFileAtomicSync(outputFile, JSON.stringify(snapshot, null, 2));
|
|
return { success: true, filePath: outputFile };
|
|
} catch (e) {
|
|
appendDebugLog('runtime-metrics-export-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('mark-vod-downloaded', (event, vodId: string, mark: boolean): { success: boolean } => {
|
|
if (!isTrustedRendererEvent(event)) return { success: false };
|
|
if (typeof vodId !== 'string' || !vodId) return { success: false };
|
|
const downloadedVodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
|
const has = downloadedVodIds.includes(vodId);
|
|
let nextDownloadedVodIds: string[];
|
|
if (mark && !has) {
|
|
nextDownloadedVodIds = [...downloadedVodIds, vodId];
|
|
} else if (!mark && has) {
|
|
nextDownloadedVodIds = downloadedVodIds.filter((id) => id !== vodId);
|
|
} else {
|
|
return { success: true };
|
|
}
|
|
config = persistStateChange(config, (current) => ({ ...current, downloaded_vod_ids: nextDownloadedVodIds }), saveConfig);
|
|
appendDebugLog('mark-vod-downloaded', { vodId, mark });
|
|
return { success: true };
|
|
});
|
|
|
|
ipcMain.handle('reset-downloaded-vod-ids', (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { success: false, removedCount: 0 };
|
|
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
|
config = persistStateChange(config, (current) => ({ ...current, downloaded_vod_ids: [] }), saveConfig);
|
|
appendDebugLog('reset-downloaded-vod-ids', { previousCount: count });
|
|
return { success: true, removedCount: count };
|
|
});
|
|
|
|
ipcMain.handle('export-config', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { success: false, error: 'File access denied' };
|
|
try {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const defaultName = `twitch-vod-manager-config-${timestamp}.json`;
|
|
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
|
|
|
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: path.join(preferredDir, defaultName),
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
|
|
if (dialogResult.canceled || !dialogResult.filePath) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
// Strip the secrets from the export — Client Secret should not
|
|
// travel as plain text across machines / cloud sync. The user
|
|
// re-enters it on the new machine after import.
|
|
const outputCapability = issueFileCapability(event, 'config-export', dialogResult.filePath, 'output-file', ['json']);
|
|
const outputFile = resolveFileCapability(event, outputCapability.token, 'config-export', true);
|
|
if (!outputFile) return { success: false, error: 'File access denied' };
|
|
const exportable = createExportableConfig(config as unknown as Record<string, unknown>);
|
|
writeFileAtomicSync(outputFile, JSON.stringify(exportable, null, 2));
|
|
return { success: true, filePath: outputFile };
|
|
} catch (e) {
|
|
appendDebugLog('config-export-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('import-config', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return { success: false, error: 'File access denied' };
|
|
try {
|
|
const dialogResult = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile'],
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
if (dialogResult.canceled || !dialogResult.filePaths[0]) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
const importCapability = issueFileCapability(event, 'config-import', dialogResult.filePaths[0], 'input-file', ['json']);
|
|
const importPath = resolveFileCapability(event, importCapability.token, 'config-import', true);
|
|
if (!importPath) return { success: false, error: 'File access denied' };
|
|
const importStats = fs.statSync(importPath);
|
|
if (!importStats.isFile() || importStats.size > MAX_CONFIG_IMPORT_BYTES) {
|
|
return { success: false, error: 'Imported file is too large or invalid.' };
|
|
}
|
|
const raw = fs.readFileSync(importPath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
if (!isPlainObject(parsed)) {
|
|
return { success: false, error: 'Imported file is not a JSON object.' };
|
|
}
|
|
|
|
const previousConfig = config;
|
|
const imported = secureImportedConfigTransition(previousConfig, sanitizeImportedConfig(parsed));
|
|
const merged = normalizeConfigTemplates({ ...previousConfig, ...imported } as Config);
|
|
applyConfigTransition(previousConfig, merged);
|
|
appendDebugLog('config-import-applied', { source: importPath });
|
|
return { success: true, filePath: importPath };
|
|
} catch (e) {
|
|
appendDebugLog('config-import-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
function isTrustedRendererEvent(event: IpcMainInvokeEvent): boolean {
|
|
if (!mainWindow) return false;
|
|
const rendererUrl = pathToFileURL(path.join(__dirname, '../src/index.html')).href;
|
|
const senderUrl = event.senderFrame?.url || event.sender.getURL();
|
|
return isTrustedFileIpcSender(mainWindow.webContents.id, rendererUrl, event.sender.id, senderUrl);
|
|
}
|
|
|
|
function isPathInsideDirectory(rootDirectory: string, candidate: string): boolean {
|
|
const root = normalizeComparablePath(rootDirectory);
|
|
const target = normalizeComparablePath(candidate);
|
|
const relative = path.relative(root, target);
|
|
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
}
|
|
|
|
// Video Cutter IPC
|
|
ipcMain.handle('get-video-info', async (event, capability: string) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
if (!filePath) return null;
|
|
return await getVideoInfo(filePath, currentCutterInfoProcesses);
|
|
});
|
|
|
|
ipcMain.handle('extract-frame', async (event, capability: string, timeSeconds: number) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
if (!filePath) return null;
|
|
return await extractFrame(filePath, timeSeconds);
|
|
});
|
|
|
|
ipcMain.handle('prepare-video-editor-media', async (event, capability: string) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
if (!filePath) return null;
|
|
const media = await prepareVideoEditorMedia(filePath);
|
|
if (media && cutterMediaJob?.jobId === media.jobId) cutterPreparedInput = cutterMediaJob.identity;
|
|
return media;
|
|
});
|
|
|
|
ipcMain.handle('prepare-video-editor-waveform', async (event, capability: string, jobId: number) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
if (!filePath) return null;
|
|
return await prepareVideoEditorWaveform(filePath, jobId);
|
|
});
|
|
|
|
ipcMain.handle('prepare-video-editor-assets', async (event, capability: string, jobId: number, profile: VideoEditorAssetProfile) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
if (!filePath) return null;
|
|
return await prepareVideoEditorAssets(filePath, jobId, profile);
|
|
});
|
|
|
|
ipcMain.handle('cancel-video-editor-assets', (event, jobId: number) => {
|
|
if (!isTrustedRendererEvent(event) || !Number.isInteger(jobId) || cutterMediaJob?.jobId !== jobId) return false;
|
|
cancelCutterMediaPreparation();
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('get-cutter-project-recovery', (event, capability: string) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
const source = filePath ? getCutterProjectSource(filePath) : null;
|
|
return source ? cutterProjectAutosaves.find(source) : null;
|
|
});
|
|
|
|
ipcMain.handle('save-cutter-project', async (event, capability: string, value: unknown) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return false;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
if (!filePath || !cutterInputIdentityMatches(filePath) || !cutterMediaJob || !cutterInputIdentitiesMatch(getCutterInputIdentity(filePath), cutterMediaJob.identity)) return false;
|
|
const project = createCutterProject(filePath, cutterMediaJob.info, value);
|
|
if (!project) return false;
|
|
try {
|
|
cutterProjectAutosaves.save(project);
|
|
return true;
|
|
} catch (error) {
|
|
appendDebugLog('cutter-project-save-failed', String(error));
|
|
return false;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('discard-cutter-project', (event, capability: string) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return false;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
const source = filePath ? getCutterProjectSource(filePath) : null;
|
|
if (!source) return false;
|
|
try {
|
|
return cutterProjectAutosaves.discard(source);
|
|
} catch (error) {
|
|
appendDebugLog('cutter-project-discard-failed', String(error));
|
|
return false;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('open-cutter-project', (event, capability: string) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
const filePath = resolveFileCapability(event, capability, 'cutter-input');
|
|
const source = filePath ? getCutterProjectSource(filePath) : null;
|
|
return source ? cutterProjectAutosaves.find(source) : null;
|
|
});
|
|
|
|
ipcMain.handle('get-cutter-export-options', async (event) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted) return null;
|
|
return { profiles: CUTTER_EXPORT_PROFILES, hardwareEncoders: await getCutterHardwareEncoders() };
|
|
});
|
|
|
|
ipcMain.handle('export-video-edit', async (event, request: RendererVideoEditExportRequest) => {
|
|
if (!isTrustedRendererEvent(event) || appShutdownStarted || !request || typeof request.inputCapability !== 'string') return { success: false, outputName: null };
|
|
const inputFile = resolveFileCapability(event, request.inputCapability, 'cutter-input');
|
|
if (!inputFile || !cutterInputIdentityMatches(inputFile)) return { success: false, outputName: null };
|
|
const profile = request.profile ?? 'balanced';
|
|
const encoder = request.encoder ?? 'software';
|
|
const audioStreamIndex = request.audioStreamIndex ?? 0;
|
|
if (!isCutterExportProfile(profile) || !isCutterExportEncoder(encoder) || !Number.isInteger(audioStreamIndex) || audioStreamIndex < 0) return { success: false, outputName: null };
|
|
const profileDefinition = getCutterExportProfile(profile);
|
|
const extension = profileDefinition.container;
|
|
let outputFile: string | null = null;
|
|
const testRoot = process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT;
|
|
if (testRoot && typeof request.outputName === 'string' && path.basename(request.outputName) === request.outputName) {
|
|
const candidate = path.join(testRoot, request.outputName);
|
|
if (isPathInsideDirectory(testRoot, candidate)) {
|
|
const outputCapability = issueFileCapability(event, 'cutter-output', candidate, 'output-file', [extension]);
|
|
outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]);
|
|
}
|
|
} else {
|
|
const defaultName = path.join(path.dirname(inputFile), `${path.basename(inputFile, path.extname(inputFile))}_edited.${extension}`);
|
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: defaultName,
|
|
filters: [{ name: `${profileDefinition.label} Video`, extensions: [extension] }],
|
|
});
|
|
if (result.canceled || !result.filePath) return { success: false, outputName: null, cancelled: true };
|
|
const outputCapability = issueFileCapability(event, 'cutter-output', result.filePath, 'output-file', [extension]);
|
|
outputFile = resolveFileCapability(event, outputCapability.token, 'cutter-output', true, [inputFile]);
|
|
}
|
|
if (!outputFile) return { success: false, outputName: null };
|
|
const outcome = await exportVideoEdit({ inputFile, outputFile, trimStart: request.trimStart, trimEnd: request.trimEnd, cuts: request.cuts, profile, encoder, audioStreamIndex }, (percent) => {
|
|
mainWindow?.webContents.send('cut-progress', percent);
|
|
});
|
|
const outputCapability = outcome.success ? issueFileCapability(event, 'show-in-folder', outputFile, 'input-file', [extension]) : null;
|
|
return { success: outcome.success, outputCapability: outputCapability?.token, outputName: outcome.success ? path.basename(outputFile) : null, cancelled: outcome.cancelled || undefined };
|
|
});
|
|
|
|
ipcMain.handle('cancel-video-edit', (event) => {
|
|
if (!isTrustedRendererEvent(event) || !cutterExportActive) return false;
|
|
cutterExportCancelled = true;
|
|
for (const process of currentCutterExportProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('cut-video', async (event, inputCapability: string, startTime: number, endTime: number) => {
|
|
const inputFile = resolveFileCapability(event, inputCapability, 'cutter-input', true);
|
|
if (!inputFile) return { success: false, outputName: null };
|
|
const dir = path.dirname(inputFile);
|
|
const baseName = path.basename(inputFile, path.extname(inputFile));
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19);
|
|
const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`);
|
|
|
|
const completed = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => {
|
|
mainWindow?.webContents.send('cut-progress', percent);
|
|
});
|
|
const success = completed && !appShutdownStarted;
|
|
|
|
return { success, outputName: success ? path.basename(outputFile) : null };
|
|
});
|
|
|
|
// Merge IPC
|
|
ipcMain.handle('merge-videos', async (event, inputCapabilities: string[], outputCapability: string) => {
|
|
if (!isTrustedRendererEvent(event) || !Array.isArray(inputCapabilities) || inputCapabilities.length < 2) return { success: false, outputName: null };
|
|
const inputFiles = inputCapabilities.map((capability) => resolveFileCapability(event, capability, 'merge-input'));
|
|
const outputFile = resolveFileCapability(event, outputCapability, 'merge-output');
|
|
if (inputFiles.some((file): file is null => !file) || !outputFile) return { success: false, outputName: null };
|
|
for (const capability of inputCapabilities) {
|
|
if (!resolveFileCapability(event, capability, 'merge-input', true)) return { success: false, outputName: null };
|
|
}
|
|
if (!resolveFileCapability(event, outputCapability, 'merge-output', true, inputFiles as string[])) return { success: false, outputName: null };
|
|
const success = await publishCapabilityOutput(outputFile, async (partialFile) => {
|
|
const produced = await mergeVideos(inputFiles as string[], partialFile, (percent) => {
|
|
mainWindow?.webContents.send('merge-progress', percent);
|
|
});
|
|
return produced && !appShutdownStarted;
|
|
});
|
|
return { success, outputName: success ? path.basename(outputFile) : null };
|
|
});
|
|
|
|
ipcMain.handle('select-multiple-videos', async (event) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile', 'multiSelections'],
|
|
filters: [
|
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
|
]
|
|
});
|
|
return result.filePaths.map((filePath) => issueFileCapability(event, 'merge-input', filePath, 'input-file', VIDEO_FILE_EXTENSIONS));
|
|
});
|
|
|
|
ipcMain.handle('save-video-dialog', async (event, defaultName: string) => {
|
|
if (!isTrustedRendererEvent(event)) return null;
|
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: defaultName,
|
|
filters: [
|
|
{ name: 'MP4 Video', extensions: ['mp4'] }
|
|
]
|
|
});
|
|
return result.filePath ? issueFileCapability(event, 'merge-output', result.filePath, 'output-file', ['mp4']) : null;
|
|
});
|
|
|
|
// ==========================================
|
|
// APP LIFECYCLE
|
|
// ==========================================
|
|
// Long-lived SQLite-Handle (Plan 04b+ Voraussetzung). Wird in app.whenReady
|
|
// geoeffnet, in shutdownCleanup geschlossen. getAppDb() returnt null wenn
|
|
// Open fehlgeschlagen ist (Native-Build-Probleme) — Caller mussen das pruefen.
|
|
let appDb: DbHandle | null = null;
|
|
export function getAppDb(): DbHandle | null { return appDb; }
|
|
|
|
function cleanupStaleCutterMediaDirectories(): number {
|
|
const tempRoot = path.resolve(app.getPath('temp'));
|
|
let removed = 0;
|
|
try {
|
|
for (const name of fs.readdirSync(tempRoot)) {
|
|
if (!/^tvm-editor-(?:media|waveform|preview)-[A-Za-z0-9_-]+$/.test(name)) continue;
|
|
const candidate = path.resolve(tempRoot, name);
|
|
if (path.dirname(candidate) !== tempRoot) continue;
|
|
const processMatch = name.match(/^tvm-editor-(?:media|waveform|preview)-(\d+)-/);
|
|
if (processMatch) {
|
|
const ownerPid = Number(processMatch[1]);
|
|
if (ownerPid === process.pid) continue;
|
|
try {
|
|
process.kill(ownerPid, 0);
|
|
continue;
|
|
} catch { }
|
|
} else {
|
|
const ageMs = Date.now() - fs.statSync(candidate).mtimeMs;
|
|
if (ageMs < 24 * 60 * 60 * 1000) continue;
|
|
}
|
|
fs.rmSync(candidate, { recursive: true, force: true });
|
|
removed += 1;
|
|
}
|
|
} catch { }
|
|
return removed;
|
|
}
|
|
|
|
app.whenReady().then(() => {
|
|
const removedPartialDownloads = partialDownloadRegistry.cleanup();
|
|
if (removedPartialDownloads.length > 0) {
|
|
appendDebugLog('partial-downloads-cleaned-on-startup', { count: removedPartialDownloads.length });
|
|
}
|
|
const removedCutterMediaDirectories = cleanupStaleCutterMediaDirectories();
|
|
if (removedCutterMediaDirectories > 0) appendDebugLog('cutter-media-cleaned-on-startup', { count: removedCutterMediaDirectories });
|
|
refreshBundledToolPaths(true);
|
|
startMetadataCacheCleanup();
|
|
startDebugLogFlushTimer();
|
|
|
|
try {
|
|
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
|
const database: DbHandle = openDatabase(dbPath);
|
|
appDb = database;
|
|
const secureStorage = createElectronSecureStorage();
|
|
appSecretStore = createSecretStore(database, secureStorage);
|
|
const result = migrateJsonToSqlite({
|
|
db: database,
|
|
appDataDir: APPDATA_DIR,
|
|
secrets: appSecretStore,
|
|
requireEncryption: true,
|
|
});
|
|
appendDebugLog('sqlite-migrator', result);
|
|
const fatalMigrationErrors = result.errors.filter((entry: { source: string }) => entry.source !== 'legacy-config-scrub');
|
|
if (fatalMigrationErrors.length > 0) {
|
|
throw new Error(fatalMigrationErrors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; '));
|
|
}
|
|
appStateStore = createAppStateStore(database);
|
|
config = loadConfig();
|
|
lastPersistedConfig = cloneConfig(config);
|
|
const queueLoad = config.persist_queue_on_restart === false
|
|
? { queue: [] as QueueItem[], interruptedMergeItemIds: new Set<string>() }
|
|
: loadQueue();
|
|
downloadQueue = queueLoad.queue;
|
|
for (const item of downloadQueue) {
|
|
if (item.mergeRecoveryBlocked) queueLoad.interruptedMergeItemIds.add(item.id);
|
|
}
|
|
const mergeRecovery = recoverInterruptedMergeArtifacts(downloadQueue, config.download_path, queueLoad.interruptedMergeItemIds);
|
|
if (mergeRecovery.changed) {
|
|
downloadQueue = mergeRecovery.queue;
|
|
for (const item of downloadQueue) {
|
|
if (item.mergeRecoveryBlocked) item.last_error = tBackend('mergeRecoveryBlocked');
|
|
}
|
|
appStateStore.saveQueue(downloadQueue);
|
|
appendDebugLog('merge-recovery-reset', {
|
|
removedFiles: mergeRecovery.removedFiles.length,
|
|
failedFiles: mergeRecovery.failedFiles.length,
|
|
});
|
|
}
|
|
if (config.persist_queue_on_restart === false) appStateStore.saveQueue([]);
|
|
lastPersistedQueueSnapshot = cloneQueue(downloadQueue);
|
|
twitchClientSecret = readSecretSafely(appSecretStore, 'twitch_client_secret', (error) => {
|
|
appendDebugLog('secret-load-failed', { key: 'twitch_client_secret', error: String(error) });
|
|
});
|
|
discordWebhookUrl = readSecretSafely(appSecretStore, 'discord_webhook_url', (error) => {
|
|
appendDebugLog('secret-load-failed', { key: 'discord_webhook_url', error: String(error) });
|
|
});
|
|
} catch (e) {
|
|
appendDebugLog('sqlite-open-failed', {
|
|
error: e instanceof Error ? e.message : String(e),
|
|
});
|
|
try { appDb?.close(); } catch { }
|
|
appDb = null;
|
|
appStateStore = null;
|
|
appSecretStore = null;
|
|
config = normalizeConfigTemplates(defaultConfig);
|
|
lastPersistedConfig = cloneConfig(config);
|
|
downloadQueue = [];
|
|
lastPersistedQueueSnapshot = [];
|
|
twitchClientSecret = '';
|
|
discordWebhookUrl = '';
|
|
}
|
|
|
|
restartAutoRecordPoller();
|
|
restartAutoVodPoller();
|
|
restartLiveStatusPoller();
|
|
restartAutoCleanupTimer();
|
|
createWindow();
|
|
startDevelopmentReload();
|
|
if (app.isPackaged) {
|
|
startupToolsProvisionTimer = setTimeout(() => {
|
|
startupToolsProvisionTimer = null;
|
|
if (appShutdownStarted) return;
|
|
appendDebugLog('startup-tools-provisioning-start');
|
|
void (async () => {
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
if (appShutdownStarted) return;
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
appendDebugLog('startup-tools-provisioning-finished', { streamlinkReady, ffmpegReady });
|
|
})().catch((error) => {
|
|
appendDebugLog('startup-tools-provisioning-failed', String(error));
|
|
});
|
|
}, STARTUP_TOOLS_PROVISION_DELAY_MS);
|
|
} else {
|
|
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
|
}
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Both window-all-closed and before-quit ran nearly identical cleanup blocks
|
|
// before, with slight drift (only window-all-closed killed children, only
|
|
// window-all-closed did anything platform-specific). Consolidating them into
|
|
// a single idempotent helper means any future tweak (e.g. flushing a new
|
|
// debug stream) lands once and applies on every quit path.
|
|
let shutdownCleanupDone = false;
|
|
let quitAfterCleanup = false;
|
|
let shutdownPromise: Promise<void> | null = null;
|
|
|
|
async function waitForAllChildProcessesExit(processes: ChildProcess[]): Promise<void> {
|
|
const results = await Promise.allSettled(processes.map((process) => waitForChildProcessExit(process)));
|
|
const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
if (failed) throw failed.reason;
|
|
}
|
|
|
|
async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise<void> {
|
|
if (shutdownCleanupDone) return;
|
|
shutdownCleanupDone = true;
|
|
appShutdownStarted = true;
|
|
clearDownloadPolicyWakeTimer();
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
pendingQueueSnapshot = downloadQueue;
|
|
|
|
appendDebugLog('shutdown-cleanup', { reason });
|
|
|
|
const cleanupError = (step: string, error: unknown): void => {
|
|
appendDebugLog('shutdown-step-failed', { step, error: String(error) });
|
|
};
|
|
const clipProcesses = [...activeClipProcesses];
|
|
const editorProcesses = [...currentEditorProcesses];
|
|
const exportProcesses = [...currentCutterExportProcesses];
|
|
const mediaProcesses = [...currentCutterMediaProcesses, ...currentCutterWaveformProcesses, ...currentCutterProbeProcesses, ...currentCutterInfoProcesses, ...currentCutterPreviewProcesses];
|
|
const frameProcesses = [...currentCutterFrameProcesses];
|
|
const frameFiles = [...currentCutterFrameFiles];
|
|
let exportProcessesExited = exportProcesses.length === 0;
|
|
let mediaProcessesExited = mediaProcesses.length === 0;
|
|
let frameProcessesExited = frameProcesses.length === 0;
|
|
|
|
await runResilientSteps([
|
|
['metadata-cache-timer', () => stopMetadataCacheCleanup()],
|
|
['metadata-cache-files', () => cleanupMetadataCaches('shutdown')],
|
|
['auto-update-poller', () => stopAutoUpdatePolling()],
|
|
['auto-record-poller', () => stopAutoRecordPoller()],
|
|
['auto-vod-poller', () => stopAutoVodPoller()],
|
|
['live-status-poller', () => stopLiveStatusPoller()],
|
|
['auto-cleanup-timer', () => stopAutoCleanupTimer()],
|
|
['startup-tools-provision-timer', () => stopStartupToolsProvisionTimer()],
|
|
['queue-lifecycle', async () => {
|
|
await queueRunLifecycle.shutdown(
|
|
() => {
|
|
isDownloading = false;
|
|
queuePaused = false;
|
|
for (const id of queueProcessRegistry.activeItemIds()) cancelledItemIds.add(id);
|
|
},
|
|
() => runResilientSteps([
|
|
['persist-config', () => saveConfig(config)],
|
|
['persist-queue', () => flushQueueSave()],
|
|
], cleanupError),
|
|
(error) => cleanupError('queue-persist', error),
|
|
(error) => cleanupError('queue-run-exit', error),
|
|
);
|
|
}],
|
|
['queue-tracking', () => activeDownloads.clear()],
|
|
['clip-processes', async () => {
|
|
const results = await Promise.allSettled(clipProcesses.map(async (tracking) => {
|
|
try { tracking.process.kill(); } catch { }
|
|
await waitForChildProcessExit(tracking.process);
|
|
await runResilientSteps([
|
|
['clip-output', () => tracking.output.cancel()],
|
|
['clip-partial', () => partialDownloadRegistry.discard(tracking.partialFilename)],
|
|
], cleanupError);
|
|
activeClipProcesses.delete(tracking);
|
|
}));
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') cleanupError('clip-process-exit', result.reason);
|
|
}
|
|
}],
|
|
['editor-processes', async () => {
|
|
for (const process of editorProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
await waitForAllChildProcessesExit(editorProcesses);
|
|
for (const process of editorProcesses) currentEditorProcesses.delete(process);
|
|
}],
|
|
['cutter-export-cancel', () => {
|
|
if (cutterExportActive) cutterExportCancelled = true;
|
|
for (const process of exportProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}],
|
|
['cutter-export-wait', async () => {
|
|
await waitForAllChildProcessesExit(exportProcesses);
|
|
exportProcessesExited = true;
|
|
}],
|
|
['cutter-export-release', () => {
|
|
if (!exportProcessesExited) return;
|
|
if (currentCutterProcess && exportProcesses.includes(currentCutterProcess)) currentCutterProcess = null;
|
|
}],
|
|
['cutter-media-cancel', () => cancelCutterMediaPreparation()],
|
|
['cutter-waveform-cancel', () => cancelCutterWaveformPreparation()],
|
|
['cutter-metadata-cancel', () => cancelCutterMetadataPreparation()],
|
|
['cutter-preview-cancel', () => cancelCutterPreviewPreparation()],
|
|
['cutter-media-kill', () => {
|
|
for (const process of mediaProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}],
|
|
['cutter-media-wait', async () => {
|
|
await waitForAllChildProcessesExit(mediaProcesses);
|
|
mediaProcessesExited = true;
|
|
}],
|
|
['cutter-frame-kill', () => {
|
|
for (const process of frameProcesses) {
|
|
try { process.kill(); } catch { }
|
|
}
|
|
}],
|
|
['cutter-frame-wait', async () => {
|
|
await waitForAllChildProcessesExit(frameProcesses);
|
|
frameProcessesExited = true;
|
|
}],
|
|
['cutter-frame-release', () => {
|
|
if (frameProcessesExited) currentCutterFrameProcesses.clear();
|
|
}],
|
|
['cutter-frame-files', async () => {
|
|
if (!frameProcessesExited) return;
|
|
await runResilientSteps(frameFiles.map((filePath, index) => [
|
|
`cutter-frame-file-${index}`,
|
|
() => fs.rmSync(filePath, { force: true }),
|
|
] as const), cleanupError);
|
|
currentCutterFrameFiles.clear();
|
|
}],
|
|
['cutter-preview-directory', () => {
|
|
if (mediaProcessesExited) removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null);
|
|
}],
|
|
['cutter-partial', () => {
|
|
if (!exportProcessesExited || !currentCutterPartialFile) return;
|
|
fs.rmSync(currentCutterPartialFile, { force: true });
|
|
currentCutterPartialFile = null;
|
|
}],
|
|
['database-close', () => {
|
|
const database = appDb;
|
|
appDb = null;
|
|
database?.close();
|
|
}],
|
|
['debug-log-flush', () => stopDebugLogFlushTimer(true)],
|
|
], cleanupError);
|
|
}
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on('before-quit', (event) => {
|
|
if (quitAfterCleanup) return;
|
|
event.preventDefault();
|
|
if (shutdownPromise) return;
|
|
shutdownPromise = shutdownCleanup('before-quit').finally(() => {
|
|
quitAfterCleanup = true;
|
|
app.quit();
|
|
});
|
|
});
|