Twitch-VOD-Manager/src/main.ts
xRangerDE 2c40bbf66e feat: live recording health indicator (green/amber dot per item)
In-flight live recordings now show a small coloured dot before the
title indicating whether bytes are still flowing.

The health state is derived from byte-progress liveness: each time
the byte counter advances, we stamp lastBytesAdvancedAt; if more
than 30s pass without an advance we flip the badge to amber to tell
the user the streamlink subprocess has gone quiet (dropped segments,
network blip, or the stream just ended). Until the first segment
arrives we report "unknown" so we don't claim health prematurely on
a streamlink that's still negotiating playlists.

Critical wrinkle: streamlink emits progress events on byte boundaries,
so a hung process emits NO events at all. A pure event-driven badge
would never update from "ok" to "stale" — it'd stay frozen at the
last known good state. To avoid that, downloadLiveStream now runs a
10s health-tick interval that re-emits the most recent progress
event with a fresh health computation. The interval is killed in a
finally block so process termination doesn't leak it.

DownloadProgress + QueueItem in both src/types.ts and the renderer
declaration shadow get the new optional recordingHealth field. The
renderer queue handler copies it onto the item; the queue render
function shows a coloured dot before the title for in-flight live
items only (status === 'downloading' && isLive). Three states:
green pulsing (ok), amber flashing (stale), grey static (unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:53 +02:00

6223 lines
228 KiB
TypeScript

import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } 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 axios from 'axios';
import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
import {
setDebugLogFn, initToolDirs,
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled,
canExecute, canExecuteCommand,
cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand,
cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands,
invalidateVerifiedToolCaches
} from './tools';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = app.getVersion();
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://git.24-music.de').replace(/\/+$/, '');
const GITEA_REPO_OWNER = process.env.GITEA_REPO_OWNER || 'Administrator';
const GITEA_REPO_NAME = process.env.GITEA_REPO_NAME || 'Twitch-VOD-Manager';
const GITEA_RELEASES_API_LATEST_URL = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/latest`;
const GITEA_RELEASES_DOWNLOAD_BASE_URL = `${GITEA_BASE_URL}/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/download`;
// Paths
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
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';
const DEFAULT_METADATA_CACHE_MINUTES = 10;
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
const QUEUE_SAVE_DEBOUNCE_MS = 250;
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;
// Timeouts
const API_TIMEOUT = 10000;
const DEFAULT_RETRY_DELAY_SECONDS = 5;
const MIN_FILE_BYTES = 256 * 1024;
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
type PerformanceMode = 'stability' | 'balanced' | 'speed';
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 {
const isEnglish = config.language === 'en';
switch (phase) {
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
default: return phase;
}
}
// ==========================================
// BACKEND I18N
// ==========================================
// User-visible messages produced in main.ts. Keep keys stable — the renderer
// no longer translates these (renderer.ts:downloadClip used to translate a
// hardcoded set, which was brittle as the strings drifted). Internal
// debug log messages stay English-only since they're developer-facing.
const BACKEND_MESSAGES = {
de: {
invalidVodUrl: 'Ungueltige VOD-URL',
invalidClipUrl: 'Ungueltige Clip-URL',
clipNotFound: 'Clip nicht gefunden',
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
streamlinkMissing: 'Streamlink fehlt.',
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
streamlinkExitCode: 'Streamlink Fehlercode {code}',
ffmpegMissing: 'FFmpeg fehlt.',
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
downloadCancelled: 'Download wurde abgebrochen.',
downloadPaused: 'Download wurde pausiert.',
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
unknownDownloadError: 'Unbekannter Fehler beim Download',
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
statusCheckingTools: 'Prufe Download-Tools...',
statusDownloadStarted: 'Download gestartet',
statusBytesDownloaded: '{bytes} heruntergeladen',
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
preflightNoInternet: 'Keine Internetverbindung erkannt.',
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
},
en: {
invalidVodUrl: 'Invalid VOD URL',
invalidClipUrl: 'Invalid clip URL',
clipNotFound: 'Clip not found',
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
streamlinkMissing: 'Streamlink is missing.',
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
streamlinkExitCode: 'Streamlink exit code {code}',
ffmpegMissing: 'FFmpeg is missing.',
ffmpegMergeFailed: 'FFmpeg merge failed.',
ffmpegSplitFailed: 'FFmpeg split failed.',
fileTooSmall: 'File too small ({bytes} bytes)',
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
integrityNoVideo: 'Integrity check failed: no video stream found.',
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
integrityFailedGeneric: 'Integrity check failed.',
downloadCancelled: 'Download was cancelled.',
downloadPaused: 'Download was paused.',
downloadFailedExitCode: 'Download failed (exit code {code})',
unknownDownloadError: 'Unknown download error',
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
notAllPartsDownloaded: 'Not all parts could be downloaded.',
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
diskSpaceShortGeneric: 'Not enough disk space.',
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
statusCheckingTools: 'Checking download tools...',
statusDownloadStarted: 'Download started',
statusBytesDownloaded: '{bytes} downloaded',
statusFetchingChatReplay: 'Fetching chat replay...',
statusChatMessagesFetched: 'Chat messages fetched: {count}',
preflightNoInternet: 'No internet connection detected.',
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
preflightDownloadPathNotWritable: 'Download folder is not writable.'
}
} as const;
type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
const lang: 'de' | 'en' = config.language === 'en' ? 'en' : 'de';
let template: string = BACKEND_MESSAGES[lang][key];
if (params) {
for (const [k, v] of Object.entries(params)) {
template = template.replace(`{${k}}`, String(v));
}
}
return template;
}
// Ensure directories exist
if (!fs.existsSync(APPDATA_DIR)) {
fs.mkdirSync(APPDATA_DIR, { recursive: true });
}
// ==========================================
// INTERFACES
// ==========================================
interface Config {
client_id: string;
client_secret: string;
download_path: string;
streamers: string[];
theme: string;
download_mode: 'parts' | 'full';
part_minutes: number;
language: 'de' | 'en';
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;
auto_record_streamers: string[];
auto_record_poll_seconds: number;
download_chat_replay: boolean;
capture_live_chat: boolean;
discord_webhook_url: string;
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;
}
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;
}
interface VOD {
id: string;
title: string;
created_at: string;
duration: string;
thumbnail_url: string;
url: string;
view_count: number;
stream_id: string;
}
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;
}
interface ReleaseUpdateInfo {
tagName?: string;
version?: string;
releaseDate?: string;
releaseName?: string;
releaseNotes?: string;
}
// ==========================================
// CONFIG MANAGEMENT
// ==========================================
const defaultConfig: Config = {
client_id: '',
client_secret: '',
download_path: DEFAULT_DOWNLOAD_PATH,
streamers: [],
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
language: 'en',
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,
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false,
discord_webhook_url: '',
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
};
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
function normalizeAutoRecordPollSeconds(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 90;
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
}
function normalizeAutoRecordList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const v of value) {
if (typeof v !== 'string') continue;
const cleaned = normalizeLogin(v);
if (cleaned && !seen.has(cleaned)) {
seen.add(cleaned);
out.push(cleaned);
}
}
return out;
}
// Whitelist of streamlink stream specifiers we surface in Settings. The
// user's choice is passed to streamlink with "best" appended as a fallback
// (streamlink supports comma-separated stream lists, picks the first match)
// so a missing quality on the source stream still produces a download.
const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
function normalizeStreamlinkQuality(value: unknown): string {
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
return value;
}
return 'best';
}
function getStreamlinkStreamArg(): string {
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
if (choice === 'best') return 'best';
// Fall back to "best" if the chosen rendition isn't offered (e.g. an
// older stream archived before that resolution existed).
return `${choice},best`;
}
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
const value = (template || '').trim();
return value || fallback;
}
function normalizeMetadataCacheMinutes(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return DEFAULT_METADATA_CACHE_MINUTES;
}
return Math.max(1, Math.min(120, Math.floor(parsed)));
}
function normalizePerformanceMode(mode: unknown): PerformanceMode {
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
return mode;
}
return DEFAULT_PERFORMANCE_MODE;
}
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;
return {
...input,
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),
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,
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,
// Webhook URL is stored but never validated server-side — invalid
// URLs just cause the post to fail (logged, non-fatal). Users with
// accidental whitespace are saved by the .trim().
discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '',
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)));
})()
};
}
function recordDownloadedVodId(vodId: string): void {
if (!vodId) return;
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
if (config.downloaded_vod_ids.includes(vodId)) return;
config.downloaded_vod_ids.push(vodId);
// Cap to keep config size bounded — drop oldest first.
const DOWNLOADED_IDS_MAX = 4096;
if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) {
config.downloaded_vod_ids = config.downloaded_vod_ids.slice(
config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX
);
}
saveConfig(config);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function loadConfig(): Config {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
const parsed = JSON.parse(data);
if (!isPlainObject(parsed)) {
console.error('Config file is not a JSON object — using defaults');
return normalizeConfigTemplates(defaultConfig);
}
return normalizeConfigTemplates({ ...defaultConfig, ...parsed });
}
} catch (e) {
console.error('Error loading config:', e);
}
return normalizeConfigTemplates(defaultConfig);
}
function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
const tmpPath = targetPath + '.tmp';
let fd: number | null = null;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, buffer, 0, buffer.length, 0);
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { }
}
}
try {
fs.renameSync(tmpPath, targetPath);
} catch {
// On Windows, rename can fail if target exists or is locked. Fall back to copy.
fs.copyFileSync(tmpPath, targetPath);
try { fs.unlinkSync(tmpPath); } catch { }
}
}
function saveConfig(config: Config): void {
try {
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
} catch (e) {
console.error('Error saving config:', e);
}
}
// ==========================================
// QUEUE MANAGEMENT
// ==========================================
const VALID_QUEUE_STATUSES: ReadonlyArray<QueueItem['status']> = ['pending', 'downloading', 'paused', 'completed', 'error'];
const VALID_MERGE_PHASES: ReadonlyArray<MergeGroup['mergePhase']> = ['downloading', 'merging', 'splitting', 'cleanup', 'done'];
function isValidQueueStatus(status: unknown): status is QueueItem['status'] {
return typeof status === 'string' && (VALID_QUEUE_STATUSES as readonly string[]).includes(status);
}
function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined {
if (!isPlainObject(raw)) return undefined;
if (!Array.isArray(raw.items) || raw.items.length < 2) return undefined;
const items: MergeGroupItem[] = [];
for (const mi of raw.items) {
if (!isPlainObject(mi)) continue;
if (typeof mi.url !== 'string' || typeof mi.title !== 'string'
|| typeof mi.date !== 'string' || typeof mi.streamer !== 'string'
|| typeof mi.duration_str !== 'string') continue;
items.push({ url: mi.url, title: mi.title, date: mi.date, streamer: mi.streamer, duration_str: mi.duration_str });
}
if (items.length < 2) return undefined;
const phase: MergeGroup['mergePhase'] = (VALID_MERGE_PHASES as readonly string[]).includes(String(raw.mergePhase))
? raw.mergePhase as MergeGroup['mergePhase']
: 'downloading';
const downloadedFiles: Record<number, string> = {};
if (isPlainObject(raw.downloadedFiles)) {
for (const [k, v] of Object.entries(raw.downloadedFiles)) {
const idx = Number(k);
if (Number.isFinite(idx) && typeof v === 'string') downloadedFiles[idx] = v;
}
}
return {
items,
mergePhase: phase,
currentItemIndex: typeof raw.currentItemIndex === 'number' && Number.isFinite(raw.currentItemIndex) ? raw.currentItemIndex : 0,
downloadedFiles,
mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined,
splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined,
totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined
};
}
function sanitizeCustomClip(raw: unknown): CustomClip | undefined {
if (!isPlainObject(raw)) return undefined;
const startSec = Number(raw.startSec);
const durationSec = Number(raw.durationSec);
const startPart = Number(raw.startPart);
if (!Number.isFinite(startSec) || !Number.isFinite(durationSec) || durationSec <= 0 || !Number.isFinite(startPart)) return undefined;
const filenameFormat = raw.filenameFormat;
if (filenameFormat !== 'simple' && filenameFormat !== 'timestamp' && filenameFormat !== 'template' && 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.id !== 'string' || !raw.id) return null;
if (typeof raw.url !== 'string' || !raw.url) return null;
if (!isValidQueueStatus(raw.status)) return null;
// 'downloading' on cold start is stale — no download is actually running
// and the user expects to resume from start, so map it back to 'pending'
const isStaleDownloading = raw.status === 'downloading';
const finalStatus: QueueItem['status'] = isStaleDownloading ? 'pending' : raw.status;
const progressNum = Number(raw.progress);
const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0;
const item: QueueItem = {
id: raw.id,
url: raw.url,
title: typeof raw.title === 'string' ? raw.title : '',
date: typeof raw.date === 'string' ? raw.date : '',
streamer: typeof raw.streamer === 'string' ? raw.streamer : '',
duration_str: typeof raw.duration_str === 'string' ? raw.duration_str : '0s',
status: finalStatus,
progress: isStaleDownloading ? 0 : safeProgress
};
if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart;
if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts;
if (typeof raw.speed === 'string') item.speed = raw.speed;
if (typeof raw.eta === 'string') item.eta = raw.eta;
if (typeof raw.last_error === 'string') item.last_error = raw.last_error;
if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes;
if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes;
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;
return item;
}
function loadQueue(): QueueItem[] {
if (config.persist_queue_on_restart === false) {
return [];
}
try {
if (fs.existsSync(QUEUE_FILE)) {
const data = fs.readFileSync(QUEUE_FILE, 'utf-8');
const parsed = JSON.parse(data);
if (!Array.isArray(parsed)) {
console.error('Queue file is not a JSON array — ignoring');
return [];
}
const items: QueueItem[] = [];
let droppedCount = 0;
for (const raw of parsed) {
const sanitized = sanitizeQueueItem(raw);
if (sanitized) items.push(sanitized);
else droppedCount++;
}
if (droppedCount > 0) {
console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`);
}
return items;
}
} catch (e) {
console.error('Error loading queue:', e);
}
return [];
}
let queueSaveTimer: NodeJS.Timeout | null = null;
let pendingQueueSnapshot: QueueItem[] | null = null;
function clearQueueFileFromDisk(): void {
try {
if (fs.existsSync(QUEUE_FILE)) {
fs.unlinkSync(QUEUE_FILE);
}
} catch (e) {
console.error('Error clearing queue file:', e);
}
}
function writeQueueToDisk(queue: QueueItem[]): void {
if (config.persist_queue_on_restart === false) {
clearQueueFileFromDisk();
return;
}
try {
writeFileAtomicSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
} catch (e) {
console.error('Error saving queue:', e);
}
}
function saveQueue(queue: QueueItem[], force = false): void {
if (config.persist_queue_on_restart === false) {
pendingQueueSnapshot = null;
if (queueSaveTimer) {
clearTimeout(queueSaveTimer);
queueSaveTimer = null;
}
clearQueueFileFromDisk();
return;
}
pendingQueueSnapshot = queue;
if (force) {
if (queueSaveTimer) {
clearTimeout(queueSaveTimer);
queueSaveTimer = null;
}
writeQueueToDisk(pendingQueueSnapshot);
pendingQueueSnapshot = null;
return;
}
if (queueSaveTimer) {
return;
}
queueSaveTimer = setTimeout(() => {
queueSaveTimer = null;
if (pendingQueueSnapshot) {
writeQueueToDisk(pendingQueueSnapshot);
pendingQueueSnapshot = null;
}
}, QUEUE_SAVE_DEBOUNCE_MS);
}
function flushQueueSave(): void {
if (pendingQueueSnapshot) {
saveQueue(pendingQueueSnapshot, true);
} else {
saveQueue(downloadQueue, true);
}
}
// ==========================================
// GLOBAL STATE
// ==========================================
let mainWindow: BrowserWindow | null = null;
let config = loadConfig();
let accessToken: string | null = null;
let downloadQueue: QueueItem[] = loadQueue();
let queueIdCounter = 0;
let lastQueueBroadcastFingerprint = '';
let isDownloading = false;
// Process handle for the standalone video editor pipeline (cutter / merger /
// splitter). Queue downloads track their own children via activeDownloads,
// and clip downloads via activeClipProcesses. Keeping these separate
// prevents cancel-download from killing an unrelated cutter ffmpeg.
let currentEditorProcess: ChildProcess | null = null;
// 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 pauseRequested = false;
let activeQueueItemId: string | null = null;
let downloadStartTime = 0;
let downloadedBytes = 0;
// Per-item tracking for parallel downloads
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
const cancelledItemIds = new Set<string>();
// 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 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 autoUpdateCheckInProgress = false;
let autoUpdateReadyToInstall = false;
let autoUpdateDownloadInProgress = false;
let lastAutoUpdateCheckAt = 0;
let latestKnownUpdateVersion: string | null = null;
let downloadedUpdateVersion: string | null = null;
let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null;
let twitchLoginInFlight: Promise<boolean> | 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 payload = details === undefined
? ''
: ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`;
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'));
// ==========================================
// DURATION HELPERS
// ==========================================
function parseDuration(duration: string): number {
let seconds = 0;
const hours = duration.match(/(\d+)h/);
const minutes = duration.match(/(\d+)m/);
const secs = duration.match(/(\d+)s/);
if (hours) seconds += parseInt(hours[1]) * 3600;
if (minutes) seconds += parseInt(minutes[1]) * 60;
if (secs) seconds += parseInt(secs[1]);
return seconds;
}
function formatDuration(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
function formatDurationDashed(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
}
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 sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
const cleaned = (input || '')
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
.replace(/[\\/]/g, '_')
.trim();
return cleaned || fallback;
}
function formatDateWithPattern(date: Date, pattern: string): string {
const tokenMap: Record<string, string> = {
yyyy: date.getFullYear().toString(),
yy: date.getFullYear().toString().slice(-2),
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
M: (date.getMonth() + 1).toString(),
dd: date.getDate().toString().padStart(2, '0'),
d: date.getDate().toString(),
HH: date.getHours().toString().padStart(2, '0'),
H: date.getHours().toString(),
hh: date.getHours().toString().padStart(2, '0'),
h: date.getHours().toString(),
mm: date.getMinutes().toString().padStart(2, '0'),
m: date.getMinutes().toString(),
ss: date.getSeconds().toString().padStart(2, '0'),
s: date.getSeconds().toString()
};
return pattern
.replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
.replace(/\\(.)/g, '$1');
}
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('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')) return 'auth';
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) 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('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';
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 || ''
].join(':')).join('|');
}
function emitQueueUpdated(force = false): void {
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
return;
}
lastQueueBroadcastFingerprint = nextFingerprint;
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 {
const p = Number(progress.progress);
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 normalizeQueueUrlForFingerprint(url: string): string {
return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
}
function getQueueItemFingerprint(item: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): string {
const clip = item.customClip;
const clipFingerprint = clip
? [
'clip',
clip.startSec,
clip.durationSec,
clip.startPart,
clip.filenameFormat,
(clip.filenameTemplate || '').trim().toLowerCase()
].join(':')
: 'vod';
return [
normalizeQueueUrlForFingerprint(item.url),
(item.streamer || '').trim().toLowerCase(),
(item.date || '').trim(),
clipFingerprint
].join('|');
}
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 = Number(item.id) || 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;
}
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 twitchLogin(): Promise<boolean> {
if (!config.client_id || !config.client_secret) {
return false;
}
try {
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
params: {
client_id: config.client_id,
client_secret: config.client_secret,
grant_type: 'client_credentials'
},
timeout: API_TIMEOUT
});
accessToken = response.data.access_token;
return true;
} catch (e) {
console.error('Login error:', e);
return false;
}
}
function requestTwitchLogin(): Promise<boolean> {
if (twitchLoginInFlight) {
return twitchLoginInFlight;
}
const loginPromise: Promise<boolean> = twitchLogin().finally(() => {
if (twitchLoginInFlight === loginPromise) {
twitchLoginInFlight = null;
}
});
twitchLoginInFlight = loginPromise;
return loginPromise;
}
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
if (!config.client_id || !config.client_secret) {
accessToken = null;
return false;
}
if (!forceRefresh && accessToken) {
return true;
}
return await requestTwitchLogin();
}
function normalizeLogin(input: string): string {
return input.trim().replace(/^@+/, '').toLowerCase();
}
function formatTwitchDurationFromSeconds(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h${m}m${s}s`;
if (m > 0) return `${m}m${s}s`;
return `${s}s`;
}
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
// 4xx (other than 408/429) are application errors and not retried.
function isTransientAxiosError(err: unknown): boolean {
if (!axios.isAxiosError(err)) {
// Non-axios errors thrown from axios.post are typically network-layer
// failures (DNS, ECONNRESET, socket hangup) — retry those too.
return true;
}
const status = err.response?.status;
if (status === undefined) {
// No response means the request never reached / never returned —
// treat as transient (network blip, timeout).
return true;
}
return status === 408 || status === 429 || (status >= 500 && status < 600);
}
const TWITCH_GQL_RETRY_ATTEMPTS = 3;
const TWITCH_GQL_RETRY_BASE_DELAY_MS = 400;
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= TWITCH_GQL_RETRY_ATTEMPTS; attempt++) {
try {
const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>(
'https://gql.twitch.tv/gql',
{ query, variables },
{
headers: {
'Client-ID': TWITCH_WEB_CLIENT_ID,
'Content-Type': 'application/json'
},
timeout: API_TIMEOUT
}
);
// GraphQL errors (in `errors[]`) are application-level and not
// retried — the query itself is rejected.
if (response.data.errors?.length) {
const messages = response.data.errors.map((err) => err.message).join('; ');
appendDebugLog('public-gql-errors', { messages, attempt });
console.error('Public Twitch GQL errors:', messages);
return null;
}
if (attempt > 1) {
appendDebugLog('public-gql-recovered', { attempt });
}
return response.data.data || null;
} catch (e) {
lastError = e;
const transient = isTransientAxiosError(e);
const willRetry = transient && attempt < TWITCH_GQL_RETRY_ATTEMPTS;
appendDebugLog('public-gql-failed', {
attempt,
maxAttempts: TWITCH_GQL_RETRY_ATTEMPTS,
transient,
willRetry,
error: String(e)
});
if (!willRetry) {
break;
}
// Exponential backoff with jitter
const delay = TWITCH_GQL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250);
await sleep(delay);
}
}
console.error('Public Twitch GQL request failed:', lastError);
return 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<VOD[]> {
const login = normalizeLogin(loginName);
if (!login) return [];
type VideoNode = {
id: string;
title: string;
publishedAt: string;
lengthSeconds: number;
viewCount: number;
previewThumbnailURL: string;
};
type VodsQueryResult = {
user: {
videos: {
edges: Array<{ node: VideoNode }>;
};
} | null;
};
const data = await fetchPublicTwitchGql<VodsQueryResult>(
'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }',
{ login, first: 100 }
);
const edges = data?.user?.videos?.edges || [];
return edges
.map(({ node }) => {
const id = node?.id;
if (!id) return null;
return {
id,
title: node.title || 'Untitled VOD',
created_at: node.publishedAt || new Date(0).toISOString(),
duration: formatTwitchDurationFromSeconds(node.lengthSeconds || 0),
thumbnail_url: node.previewThumbnailURL || '',
url: `https://www.twitch.tv/videos/${id}`,
view_count: node.viewCount || 0,
stream_id: ''
} as VOD;
})
.filter((vod): vod is VOD => Boolean(vod));
}
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);
};
if (!(await ensureTwitchAuth())) return await getUserViaPublicApi();
const fetchUser = async () => {
return await axios.get('https://api.twitch.tv/helix/users', {
params: { login },
headers: {
'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}`
},
timeout: API_TIMEOUT
});
};
try {
const response = await fetchUser();
const user = response.data.data[0];
if (!user?.id) return await getUserViaPublicApi();
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
setUserIdLogin(user.id, user.login || login);
return user.id;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try {
const retryResponse = await fetchUser();
const user = retryResponse.data.data[0];
if (!user?.id) return await getUserViaPublicApi();
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
setUserIdLogin(user.id, user.login || login);
return user.id;
} catch (retryError) {
console.error('Error getting user after relogin:', retryError);
return await getUserViaPublicApi();
}
}
console.error('Error getting user:', e);
return await getUserViaPublicApi();
}
});
}
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;
}
}
const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`;
return await withInFlightDedup(inFlightVodRequests, requestKey, async () => {
if (!forceRefresh) {
const refreshedCachedVods = getCachedValue(vodListCache, cacheKey);
if (refreshedCachedVods !== undefined) {
runtimeMetrics.cacheHits += 1;
return refreshedCachedVods;
}
}
runtimeMetrics.cacheMisses += 1;
const getVodsViaPublicApi = async () => {
const login = userIdLoginCache.get(userId);
if (!login) return [];
const vods = await getPublicVODsByLogin(login);
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
return vods;
};
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
const MAX_VOD_PAGES = 50; // 50 pages x 100 per page = 5000 VODs max
const fetchVodsPage = async (cursor?: string) => {
const params: Record<string, string | number> = {
user_id: userId,
type: 'archive',
first: 100
};
if (cursor) params.after = cursor;
return await axios.get('https://api.twitch.tv/helix/videos', {
params,
headers: {
'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}`
},
timeout: API_TIMEOUT
});
};
const fetchAllVodPages = async (): Promise<VOD[]> => {
const allVods: VOD[] = [];
let cursor: string | undefined;
let pageCount = 0;
do {
const response = await fetchVodsPage(cursor);
const pageVods = response.data.data || [];
allVods.push(...pageVods);
if (pageCount === 0) {
const login = pageVods[0]?.user_login;
if (login) {
setUserIdLogin(userId, normalizeLogin(login));
}
}
cursor = response.data.pagination?.cursor;
pageCount++;
} while (cursor && pageCount < MAX_VOD_PAGES);
return allVods;
};
try {
const vods = await fetchAllVodPages();
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
return vods;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try {
const vods = await fetchAllVodPages();
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
return vods;
} catch (retryError) {
console.error('Error getting VODs after relogin:', retryError);
return await getVodsViaPublicApi();
}
}
console.error('Error getting VODs:', e);
return await getVodsViaPublicApi();
}
});
}
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;
if (await ensureTwitchAuth()) {
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 ${accessToken}`
},
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: String(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
};
}
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;
if (!(await ensureTwitchAuth())) 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 ${accessToken}`
},
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) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
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:', retryError);
return null;
}
}
console.error('Error getting clip:', e);
return null;
}
});
}
// ==========================================
// VIDEO INFO (for cutter)
// ==========================================
async function getVideoInfo(filePath: string): 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
];
const proc = spawn(ffprobe, args, { windowsHide: true });
let output = '';
proc.stdout?.on('data', (data) => {
output += data.toString();
});
proc.on('close', (code) => {
if (code !== 0) {
resolve(null);
return;
}
try {
const info = JSON.parse(output);
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
resolve({
duration: parseFloat(info.format?.duration || '0'),
width: videoStream?.width || 0,
height: videoStream?.height || 0,
fps: parseFrameRate(videoStream?.r_frame_rate)
});
} catch {
resolve(null);
}
});
proc.on('error', () => resolve(null));
});
}
// ==========================================
// VIDEO CUTTER
// ==========================================
async function extractFrame(filePath: string, timeSeconds: number): Promise<string | null> {
const ffmpegReady = await ensureFfmpegInstalled();
if (!ffmpegReady) {
appendDebugLog('extract-frame-missing-ffmpeg');
return null;
}
return new Promise((resolve) => {
const ffmpeg = getFFmpegPath();
const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`);
const args = [
'-ss', timeSeconds.toString(),
'-i', filePath,
'-vframes', '1',
'-q:v', '2',
'-y',
tempFile
];
const proc = spawn(ffmpeg, args, { windowsHide: true });
proc.on('close', (code) => {
if (code === 0 && fs.existsSync(tempFile)) {
const imageData = fs.readFileSync(tempFile);
const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`;
fs.unlinkSync(tempFile);
resolve(base64);
} else {
resolve(null);
}
});
proc.on('error', () => resolve(null));
});
}
async function cutVideo(
inputFile: string,
outputFile: string,
startTime: number,
endTime: number,
onProgress: (percent: number) => void
): Promise<boolean> {
const ffmpegReady = await ensureFfmpegInstalled();
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> => {
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) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentEditorProcess = 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) => {
currentEditorProcess = null;
if (code === 0 && fs.existsSync(outputFile)) {
const stats = fs.statSync(outputFile);
if (stats.size <= 256) {
appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size });
resolve(false);
return;
}
resolve(true);
} else {
resolve(false);
}
});
proc.on('error', () => {
currentEditorProcess = null;
resolve(false);
});
});
};
const copySuccess = await runCutAttempt(true);
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
): Promise<boolean> {
const ffmpegReady = await ensureFfmpegInstalled();
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 {
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> => {
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) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentEditorProcess = 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) => {
currentEditorProcess = null;
const success = code === 0 && fs.existsSync(outputFile);
if (success) {
onProgress(100);
}
resolve(success);
});
proc.on('error', () => {
currentEditorProcess = null;
resolve(false);
});
});
};
try {
const copySuccess = await runMergeAttempt(true);
if (copySuccess) {
return true;
}
appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length });
try {
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
} catch { }
return await runMergeAttempt(false);
} 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,
itemId: string | null = null
): Promise<{ success: boolean; files: string[] }> {
const ffmpegReady = await ensureFfmpegInstalled();
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);
onProgress(i + 1, numParts);
const args = [
'-ss', formatDuration(startSec),
'-i', inputFile,
'-t', formatDuration(thisDuration),
'-c', 'copy',
'-y', outputFile
];
appendDebugLog('split-merged-part', { part: i + 1, total: numParts, startSec, duration: thisDuration });
const success = await new Promise<boolean>((resolve) => {
const proc = spawn(ffmpeg, args, { windowsHide: true });
currentEditorProcess = proc;
proc.on('close', (code) => {
currentEditorProcess = null;
resolve(code === 0 && fs.existsSync(outputFile));
});
proc.on('error', () => {
currentEditorProcess = null;
resolve(false);
});
});
if (!success) {
appendDebugLog('split-merged-part-failed', { part: i + 1, outputFile });
return { success: false, files: splitFiles };
}
splitFiles.push(outputFile);
}
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
): Promise<DownloadResult> {
return new Promise((resolve) => {
const streamlinkCmd = getStreamlinkCommand();
const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '-o', filename, '--force'];
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');
}
let lastErrorLine = '';
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
let lastStreamlinkPercent = 0;
if (startTime) {
args.push('--hls-start-offset', startTime);
}
if (endTime) {
args.push('--hls-duration', endTime);
}
console.log('Starting download:', streamlinkCmd.command, args);
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
// Register in per-item tracking map for parallel downloads
// (no longer mirrored on a global — currentEditorProcess is editor-only)
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
activeDownloads.set(itemId, itemTracking);
downloadStartTime = itemTracking.startTime;
downloadedBytes = 0;
let lastBytes = 0;
let lastTime = Date.now();
// Monitor file size for progress
const progressInterval = setInterval(() => {
if (fs.existsSync(filename)) {
try {
const stats = fs.statSync(filename);
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);
}
}
}
onProgress({
id: itemId,
progress: -1, // Unknown total
speed: formatSpeed(speed),
eta: etaStr,
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
currentPart: partNum,
totalParts: totalParts,
downloadedBytes: downloadedBytes,
speedBytesPerSec: speed
});
} catch { }
}
}, 1000);
proc.stdout?.on('data', (data: Buffer) => {
const line = data.toString();
console.log('Streamlink:', line);
// Parse progress
const match = line.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
});
}
});
proc.stderr?.on('data', (data: Buffer) => {
const message = data.toString().trim();
if (message) {
lastErrorLine = message.split('\n').pop() || message;
appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine });
console.error('Streamlink error:', message);
}
});
proc.on('close', async (code) => {
clearInterval(progressInterval);
activeDownloads.delete(itemId);
if (cancelledItemIds.has(itemId)) {
cancelledItemIds.delete(itemId);
appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: tBackend('downloadCancelled') });
return;
}
if (code === 0 && fs.existsSync(filename)) {
const stats = fs.statSync(filename);
if (stats.size <= MIN_FILE_BYTES) {
const tooSmall = tBackend('fileTooSmall', { bytes: String(stats.size) });
appendDebugLog('download-part-failed-small-file', { itemId, filename, bytes: stats.size });
resolve({ success: false, error: tooSmall });
return;
}
const integrityResult = validateDownloadedFileIntegrity(filename, expectedDurationSeconds);
if (!integrityResult.success) {
appendDebugLog('download-part-failed-integrity', {
itemId,
filename,
bytes: stats.size,
error: integrityResult.error
});
resolve(integrityResult);
return;
}
runtimeMetrics.downloadedBytesTotal += stats.size;
appendDebugLog('download-part-success', { itemId, filename, bytes: stats.size });
resolve({ success: true });
return;
}
const genericError = lastErrorLine || tBackend('streamlinkExitCode', { code: String(code ?? -1) });
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
resolve({ success: false, error: genericError });
});
proc.on('error', (err) => {
clearInterval(progressInterval);
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 autoRecordPollInFlight = false;
function stopAutoRecordPoller(): void {
if (autoRecordPollTimer) {
clearInterval(autoRecordPollTimer);
autoRecordPollTimer = 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?.();
// Kick off an immediate first poll so a freshly-enabled streamer that's
// already live gets picked up without waiting a full interval.
setTimeout(() => { void runAutoRecordPoll(); }, 1500);
}
async function runAutoRecordPoll(): Promise<void> {
if (autoRecordPollInFlight) return;
autoRecordPollInFlight = true;
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(),
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
};
downloadQueue.push(liveItem);
saveQueue(downloadQueue);
emitQueueUpdated();
appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title });
if (!isDownloading) {
void processQueue();
}
}
} catch (e) {
appendDebugLog('auto-record-poll-failed', String(e));
} finally {
autoRecordPollInFlight = false;
}
}
// ==========================================
// 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 autoVodPollInFlight = false;
function stopAutoVodPoller(): void {
if (autoVodPollTimer) {
clearInterval(autoVodPollTimer);
autoVodPollTimer = 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?.();
setTimeout(() => { void runAutoVodPoll(); }, 5000);
}
async function runAutoVodPoll(): Promise<void> {
if (autoVodPollInFlight) return;
autoVodPollInFlight = true;
try {
const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
if (list.length === 0) return;
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 : []);
const queuedUrls = new Set(downloadQueue.map((it) => it.url));
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;
if (queuedUrls.has(vod.url)) continue;
const createdMs = Date.parse(vod.created_at || '');
if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue;
const queueItem: QueueItem = {
id: generateQueueItemId(),
title: vod.title || `${streamer} VOD ${vod.id}`,
url: vod.url,
date: vod.created_at,
streamer,
duration_str: vod.duration || '',
status: 'pending',
progress: 0
};
downloadQueue.push(queueItem);
queuedUrls.add(vod.url);
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 */ }
}
}
}
saveQueue(downloadQueue);
emitQueueUpdated();
if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) {
void processQueue();
}
} catch (e) {
appendDebugLog('auto-vod-poll-failed', String(e));
} finally {
autoVodPollInFlight = false;
}
}
// ==========================================
// 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 lastAutoCleanupAt = 0;
function stopAutoCleanupTimer(): void {
if (autoCleanupTimer) {
clearInterval(autoCleanupTimer);
autoCleanupTimer = null;
}
}
function restartAutoCleanupTimer(): void {
stopAutoCleanupTimer();
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 (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.
setTimeout(() => {
if (!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;
}
// ==========================================
// 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 = (config.discord_webhook_url || '').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> {
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 filename = 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.
let chatSession: LiveChatSession | null = null;
if (config.capture_live_chat) {
const chatPath = liveChatPathFor(filename);
chatSession = startLiveChatCapture(item.streamer, chatPath);
}
// Stream-events tracker — records title/game changes that happen
// while we're capturing. Cheap (one Helix/GQL hit per minute) and
// useful for long archives where the user later wants to seek.
let eventsTracker: LiveEventTracker | null = null;
if (config.log_stream_events) {
// Best-effort initial metadata snapshot. If the call fails the
// tracker still starts with empty title/game and the first
// successful poll shows them as a change.
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, filename, 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(filename), inline: false }
]
});
}
const recordingStartedAt = Date.now();
// Health is derived from byte-progress liveness: each time the byte
// counter advances, we stamp lastBytesAdvancedAt; if we go BYTES_FRESH_MS
// without an advance we flip to 'stale'. Until the first byte arrives
// we report 'unknown' so the UI doesn't claim health prematurely on a
// streamlink that hasn't even hit a segment yet.
const BYTES_FRESH_MS = 30_000;
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';
};
// Wrap onProgress so live recordings get a useful meta line. Without
// this the queue meta only shows raw bytes ("4.7 GB heruntergeladen")
// which doesn't tell the user how long the recording has been running
// or whether the bitrate is healthy. Substitutes:
// "{HH:MM:SS} · {size} · {avg Mbps}"
// and clears speed/eta so the renderer doesn't double-up on data.
const wrappedProgress = (p: DownloadProgress): void => {
const bytes = Number(p.downloadedBytes) || 0;
if (bytes > lastBytesValue) {
lastBytesValue = bytes;
lastBytesAdvancedAt = Date.now();
}
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000;
const parts: string[] = [formatDuration(elapsed)];
if (bytes > 0) parts.push(formatBytes(bytes));
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 — defeats the point of the badge.
const healthTick = setInterval(() => {
if (!lastEmittedProgress) return;
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
lastEmittedProgress = updated;
onProgress(updated);
}, 10_000);
healthTick.unref?.();
// No start/end times for live streams — streamlink records until the
// stream actually ends or we kill it. downloadVODPart already handles
// null start/end correctly.
let result: DownloadResult;
try {
result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1);
} finally {
clearInterval(healthTick);
}
if (chatSession) {
stopLiveChatCapture(chatSession);
}
if (eventsTracker) {
stopLiveEventsTracker(item.id, {
success: result.success,
durationMs: Date.now() - recordingStartedAt,
error: result.error
});
}
if (config.discord_notify_live_end) {
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
const sizeBytes = fs.existsSync(filename) ? (fs.statSync(filename).size || 0) : 0;
void sendDiscordWebhook({
title: result.success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
description: item.title || `${item.streamer}`,
color: result.success ? 'success' : 'info',
fields: [
{ name: 'Duration', value: formatDuration(durationSec), inline: true },
{ name: 'Size', value: formatBytes(sizeBytes), inline: true },
{ name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true },
{ name: 'Output', value: path.basename(filename), inline: false }
]
});
}
if (!result.success) return result;
const outputs = [filename];
if (chatSession && fs.existsSync(chatSession.outputPath)) {
outputs.push(chatSession.outputPath);
}
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
outputs.push(eventsTracker.eventsPath);
}
return { ...result, outputFiles: outputs };
}
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')
};
}
const streamlinkCmd = getStreamlinkCommand();
const streamlinkVersionArgs = [...streamlinkCmd.prefixArgs, '--version'];
const streamlinkAlreadyVerified = isVerifiedStreamlinkCommand(streamlinkCmd.command, streamlinkVersionArgs);
if (!streamlinkAlreadyVerified) {
onProgress({
id: item.id,
progress: -1,
speed: '',
eta: '',
status: tBackend('statusCheckingTools'),
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
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);
return result.success ? { ...result, outputFiles: [filename] } : result;
} else {
// Part-based download
const partDuration = config.part_minutes * 60;
const numParts = Math.ceil(totalDuration / partDuration);
const downloadedFiles: string[] = [];
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),
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('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 totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0);
mg.totalDurationSec = totalDurationSec;
// ---- PHASE 1: DOWNLOADING ----
if (mg.mergePhase === 'downloading') {
const streamlinkReady = await ensureStreamlinkInstalled();
if (!streamlinkReady) {
return { success: false, error: tBackend('streamlinkMissing') };
}
const ffmpegReady = await ensureFfmpegInstalled();
if (!ffmpegReady) {
return { success: false, error: tBackend('ffmpegMissing') };
}
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(config.download_path, 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;
const result = await downloadVODPart(
vodItem.url,
tmpFilename,
null, // startTime: null = full VOD
null, // endTime: null = full VOD
(progress) => {
// Weighted progress: download phase = 0-70%
const vodProgress = progress.progress > 0 ? progress.progress : 0;
const overallProgress = (priorWeight + vodWeight * (vodProgress / 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;
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(config.download_path, streamer, dateStr);
const mergedFilePath = path.join(folder, `merged_${Date.now()}.mp4`);
// 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)]);
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
);
if (!mergeSuccess) {
return { success: false, error: tBackend('ffmpegMergeFailed') };
}
mg.mergedFile = mergedFilePath;
saveQueue(downloadQueue);
}
// ---- 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(config.download_path, streamer, dateStr);
const vodId = parseVodId(mg.items[0].url) || 'merged';
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
});
},
item.id
);
if (!splitResult.success) {
// Clean up any partial split files
for (const partFile of splitResult.files) {
try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { }
}
return { success: false, error: tBackend('ffmpegSplitFailed') };
}
mg.splitFiles = splitResult.files;
// ---- PHASE 4: CLEANUP ----
mg.mergePhase = 'cleanup';
saveQueue(downloadQueue);
// Delete individual downloads
for (const key of Object.keys(mg.downloadedFiles)) {
const filePath = mg.downloadedFiles[Number(key)];
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch { }
}
// Delete merged file
if (mg.mergedFile) {
try {
if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile);
} catch { }
}
mg.mergePhase = 'done';
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> {
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);
item.status = 'downloading';
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++) {
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
const result = item.mergeGroup
? await processDownloadMergeGroup(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress);
})
: await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress);
});
if (result.success) {
finalResult = result;
break;
}
finalResult = result;
if (!isDownloading || cancelledItemIds.has(item.id) || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? tBackend('downloadPaused') : 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') });
mainWindow?.webContents.send('download-progress', {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }),
currentPart: item.currentPart,
totalParts: item.totalParts
} as DownloadProgress);
saveQueue(downloadQueue);
emitQueueUpdated();
await sleep(retryDelaySeconds * 1000);
} else {
runtimeMetrics.retriesExhausted += 1;
}
}
if (!hasQueueItemId(item.id)) {
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
return;
}
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (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}`
});
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));
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 if (!wasPaused) {
runtimeMetrics.downloadsFailed += 1;
}
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
error: item.last_error
});
saveQueue(downloadQueue);
emitQueueUpdated();
} finally {
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);
}
}
async function processQueue(): Promise<void> {
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
appendDebugLog('queue-start', {
items: downloadQueue.length,
smartScheduler: config.smart_queue_scheduler,
performanceMode: config.performance_mode,
parallelDownloads: config.parallel_downloads || 1
});
isDownloading = true;
pauseRequested = 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 && !pauseRequested) {
// Clean up finished promises
for (const [id] of activePromises) {
const queueItem = downloadQueue.find(i => i.id === id);
if (!queueItem || queueItem.status !== 'downloading') {
activePromises.delete(id);
}
}
// Fill available slots
while (activePromises.size < maxSlots && !pauseRequested) {
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;
pauseRequested = false;
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null;
activeDownloads.clear();
cancelledItemIds.clear();
saveQueue(downloadQueue);
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`
});
// 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 createWindow(): void {
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1200,
minHeight: 700,
title: `Twitch VOD Manager [v${APP_VERSION}]`,
backgroundColor: '#0e0e10',
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
if (process.platform !== 'darwin') {
mainWindow.removeMenu();
}
mainWindow.loadFile(path.join(__dirname, '../src/index.html'));
mainWindow.webContents.on('did-finish-load', () => {
emitQueueUpdated(true);
if (isDownloading) {
mainWindow?.webContents.send('download-started');
}
if (autoUpdateReadyToInstall && downloadedUpdateVersion) {
mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion));
}
// 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 (config.auto_resume_queue_on_startup && !isDownloading
&& downloadQueue.some((it) => it.status === 'pending')) {
void processQueue();
}
}, 5000);
}
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
// Setup auto-updater after window is ready
setTimeout(() => {
setupAutoUpdater();
}, 3000);
}
// ==========================================
// AUTO-UPDATER (electron-updater)
// ==========================================
function hasNewerKnownUpdateThanDownloaded(): boolean {
if (!latestKnownUpdateVersion || !downloadedUpdateVersion) {
return false;
}
return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion);
}
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 (autoUpdateCheckInProgress) {
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' };
}
autoUpdateCheckInProgress = true;
lastAutoUpdateCheckAt = now;
appendDebugLog('update-check-start', { source });
try {
try {
const giteaRes = await axios.get(GITEA_RELEASES_API_LATEST_URL, {
timeout: 5000,
headers: {
'Accept': 'application/json',
'User-Agent': 'Twitch-VOD-Manager'
}
});
cacheLatestReleaseUpdateInfo(giteaRes.data);
const tagName = latestReleaseUpdateInfo?.tagName || giteaRes.data?.tag_name;
if (tagName) {
autoUpdater.setFeedURL({
provider: 'generic',
url: `${GITEA_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
});
appendDebugLog('gitea-feed-url-set', { tagName, owner: GITEA_REPO_OWNER, repo: GITEA_REPO_NAME });
}
} catch (apiErr) {
appendDebugLog('gitea-api-failed', String(apiErr));
}
let timeoutHandle: NodeJS.Timeout | null = null;
try {
await Promise.race([
autoUpdater.checkForUpdates(),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`));
}, AUTO_UPDATE_CHECK_TIMEOUT_MS);
})
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
return { started: true };
} catch (err) {
appendDebugLog('update-check-failed', { source, error: String(err) });
console.error('Update check failed:', err);
return { started: false, reason: 'error' };
} finally {
autoUpdateCheckInProgress = false;
}
}
async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> {
if (autoUpdateReadyToInstall && !hasNewerKnownUpdateThanDownloaded()) {
return { started: false, reason: 'ready-to-install' };
}
if (autoUpdateDownloadInProgress) {
return { started: false, reason: 'in-progress' };
}
autoUpdateDownloadInProgress = true;
appendDebugLog('update-download-start', { source });
try {
await autoUpdater.downloadUpdate();
return { started: true };
} catch (err) {
appendDebugLog('update-download-failed', { source, error: String(err) });
console.error('Download failed:', err);
return { started: false, reason: 'error' };
} finally {
autoUpdateDownloadInProgress = false;
}
}
function stopAutoUpdatePolling(): void {
if (autoUpdateCheckTimer) {
clearInterval(autoUpdateCheckTimer);
autoUpdateCheckTimer = null;
}
if (autoUpdateStartupTimer) {
clearTimeout(autoUpdateStartupTimer);
autoUpdateStartupTimer = null;
}
}
function startAutoUpdatePolling(): void {
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 (autoUpdaterInitialized) {
startAutoUpdatePolling();
return;
}
autoUpdaterInitialized = true;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.on('checking-for-update', () => {
console.log('Checking for updates...');
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) {
appendDebugLog('update-available-ignored-older', {
incomingVersion: displayVersion,
knownVersion: latestKnownUpdateVersion
});
return;
}
latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion;
const hasAlreadyDownloadedThisVersion = Boolean(
autoUpdateReadyToInstall &&
downloadedUpdateVersion &&
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
);
console.log('Update available:', displayVersion);
if (!hasAlreadyDownloadedThisVersion) {
autoUpdateReadyToInstall = false;
}
autoUpdateDownloadInProgress = false;
if (hasAlreadyDownloadedThisVersion) {
if (mainWindow) {
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate));
}
return;
}
if (mainWindow) {
mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate));
}
if (AUTO_UPDATE_AUTO_DOWNLOAD) {
void requestUpdateDownload('auto');
}
});
autoUpdater.on('update-not-available', () => {
console.log('No updates available');
mainWindow?.webContents.send('update-not-available');
});
autoUpdater.on('download-progress', (progress) => {
console.log(`Download progress: ${progress.percent.toFixed(1)}%`);
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;
console.log('Update downloaded:', downloadedVersion);
autoUpdateReadyToInstall = true;
autoUpdateDownloadInProgress = false;
downloadedUpdateVersion = downloadedVersion;
if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) {
latestKnownUpdateVersion = downloadedVersion;
}
if (mainWindow) {
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate));
}
});
autoUpdater.on('error', (err) => {
autoUpdateCheckInProgress = false;
autoUpdateDownloadInProgress = false;
const message = String(err);
appendDebugLog('auto-updater-error', message);
mainWindow?.webContents.send('update-error', { message });
console.error('Auto-updater error:', err);
});
startAutoUpdatePolling();
}
// ==========================================
// IPC HANDLERS
// ==========================================
ipcMain.handle('get-config', () => config);
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousClientId = config.client_id;
const previousClientSecret = config.client_secret;
const previousCacheMinutes = config.metadata_cache_minutes;
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
const previousTheme = config.theme;
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
config = normalizeConfigTemplates({ ...config, ...newConfig });
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
accessToken = null;
twitchLoginInFlight = null;
}
if (config.metadata_cache_minutes !== previousCacheMinutes) {
clearMetadataCaches();
}
if (config.theme !== previousTheme) {
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
}
saveConfig(config);
if (config.persist_queue_on_restart === false) {
pendingQueueSnapshot = null;
if (queueSaveTimer) {
clearTimeout(queueSaveTimer);
queueSaveTimer = null;
}
clearQueueFileFromDisk();
} else if (previousPersistQueueOnRestart === false) {
saveQueue(downloadQueue, true);
}
// Restart auto-record poller if its inputs changed (added/removed
// streamers or interval changed). Drop transition state for any
// streamer no longer being watched so re-enabling them later doesn't
// suppress an immediate first-poll trigger.
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();
}
// Same dance for the auto-VOD poller — independent cadence from
// auto-record because VOD listings are heavier to fetch.
const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) {
restartAutoVodPoller();
}
// Restart cleanup timer when the toggle flips; harmless to call when
// unchanged because restartAutoCleanupTimer just resets the interval.
restartAutoCleanupTimer();
return config;
});
ipcMain.handle('login', async () => {
return await twitchLogin();
});
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', () => downloadQueue);
ipcMain.handle('start-live-recording', async (_, streamerName: string) => {
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 (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(),
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 };
}
downloadQueue.push(liveItem);
saveQueue(downloadQueue);
emitQueueUpdated();
if (!isDownloading) void processQueue();
appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title });
return { success: true, streamer: login, title: liveInfo.title || login };
});
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
runtimeMetrics.duplicateSkips += 1;
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
});
return downloadQueue;
}
const queueItem: QueueItem = {
...item,
id: generateQueueItemId(),
status: 'pending',
progress: 0
};
downloadQueue.push(queueItem);
saveQueue(downloadQueue);
emitQueueUpdated();
return downloadQueue;
});
ipcMain.handle('remove-from-queue', (_, id: string) => {
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
if (wasActiveItem) {
cancelledItemIds.add(id);
const tracking = activeDownloads.get(id);
if (tracking?.process) {
tracking.process.kill();
}
activeDownloads.delete(id);
activeQueueItemId = null;
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
appendDebugLog('queue-item-removed-active-cancelled', { id });
}
// Clean up merge-group temp files (must run for any merge group, not just active)
const removedItem = downloadQueue.find(item => item.id === id);
if (removedItem?.mergeGroup) {
const mg = removedItem.mergeGroup;
for (const key of Object.keys(mg.downloadedFiles)) {
try { if (fs.existsSync(mg.downloadedFiles[Number(key)])) fs.unlinkSync(mg.downloadedFiles[Number(key)]); } catch { }
}
if (mg.mergedFile) {
try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { }
}
}
downloadQueue = downloadQueue.filter(item => item.id !== id);
saveQueue(downloadQueue);
emitQueueUpdated();
return downloadQueue;
});
ipcMain.handle('clear-completed', () => {
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
saveQueue(downloadQueue);
emitQueueUpdated();
return downloadQueue;
});
ipcMain.handle('reorder-queue', (_, orderIds: string[]) => {
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 = withOrder;
saveQueue(downloadQueue);
emitQueueUpdated();
return downloadQueue;
});
ipcMain.handle('retry-failed-downloads', () => {
downloadQueue = downloadQueue.map((item) => {
if (item.status !== 'error') return item;
return {
...item,
status: 'pending',
progress: 0,
last_error: ''
};
});
saveQueue(downloadQueue);
emitQueueUpdated();
if (!isDownloading) {
void processQueue();
}
return downloadQueue;
});
ipcMain.handle('retry-queue-item', (_, id: string) => {
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') return downloadQueue;
downloadQueue[idx] = {
...item,
status: 'pending',
progress: 0,
last_error: ''
};
saveQueue(downloadQueue);
emitQueueUpdated();
appendDebugLog('queue-item-retry-single', { id, title: item.title });
if (!isDownloading) {
void processQueue();
}
return downloadQueue;
});
ipcMain.handle('create-merge-group', (_, itemIds: string[]) => {
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(),
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
downloadQueue = downloadQueue.filter(item => !itemIds.includes(item.id));
downloadQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, downloadQueue.length) : downloadQueue.length, 0, mergedItem);
saveQueue(downloadQueue);
emitQueueUpdated();
return downloadQueue;
});
ipcMain.handle('start-download', async () => {
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
const hasPendingItems = downloadQueue.some(item => item.status === 'pending');
if (!hasPendingItems) {
emitQueueUpdated();
return false;
}
saveQueue(downloadQueue);
emitQueueUpdated();
if (!isDownloading) {
void processQueue();
}
return true;
});
ipcMain.handle('pause-download', () => {
if (!isDownloading) return false;
pauseRequested = true;
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
// and aren't affected by pause-download. Per-item cancel state lives in
// cancelledItemIds — every active item gets added below.
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
return true;
});
ipcMain.handle('cancel-download', () => {
isDownloading = false;
pauseRequested = false;
// Kill queue downloads only — see pause-download note above.
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
return true;
});
ipcMain.handle('select-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory']
});
return result.filePaths[0] || null;
});
ipcMain.handle('select-video-file', async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile'],
filters: [
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
]
});
return result.filePaths[0] || null;
});
ipcMain.handle('open-folder', (_, folderPath: string) => {
if (fs.existsSync(folderPath)) {
shell.openPath(folderPath);
}
});
ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => {
if (typeof filePath !== 'string' || !filePath) return false;
if (!fs.existsSync(filePath)) 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', (_, filePath: string): boolean => {
if (typeof filePath !== 'string' || !filePath) return false;
if (!fs.existsSync(filePath)) return false;
shell.showItemInFolder(filePath);
return true;
});
ipcMain.handle('get-version', () => APP_VERSION);
ipcMain.handle('check-update', async () => {
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 () => {
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', () => {
autoUpdater.quitAndInstall(true, true);
});
ipcMain.handle('open-external', async (_, url: string) => {
await shell.openExternal(url);
});
// 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.
const activeClipProcesses = new Map<string, ChildProcess>();
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
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 (!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 proc = spawn(streamlinkCmd.command, [
...streamlinkCmd.prefixArgs,
`https://clips.twitch.tv/${clipId}`,
getStreamlinkStreamArg(),
'-o', filename,
'--force'
], { windowsHide: true });
activeClipProcesses.set(clipId, proc);
appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename });
proc.on('close', (code) => {
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
if (code !== 0 || !fs.existsSync(filename)) {
appendDebugLog('clip-download-failed', { clipId, code });
resolve({ success: false, error: 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(filename);
if (stats.size < 16 * 1024) {
try { fs.unlinkSync(filename); } catch { }
appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size });
resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) });
return;
}
const integrity = validateDownloadedFileIntegrity(filename, null);
if (!integrity.success) {
try { fs.unlinkSync(filename); } catch { }
appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error });
resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') });
return;
}
appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename });
resolve({ success: true, filename });
});
proc.on('error', () => {
activeClipProcesses.delete(clipId);
releaseClaimedFilenamesForItem(clipId);
resolve({ success: false, error: tBackend('streamlinkNotFound') });
});
});
});
ipcMain.handle('run-preflight', async (_, autoFix: boolean = false) => {
return await runPreflight(autoFix);
});
ipcMain.handle('get-debug-log', 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', (): boolean => {
if (!fs.existsSync(DEBUG_LOG_FILE)) return false;
shell.showItemInFolder(DEBUG_LOG_FILE);
return true;
});
ipcMain.handle('get-storage-stats', (): StorageStatsResult => {
return computeStorageStats();
});
ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): CleanupReport => {
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.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' };
if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' };
const MAX_MESSAGES = 50000;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
if (filePath.toLowerCase().endsWith('.jsonl')) {
// JSON Lines (live chat): one object per line, first line may be header
const messages: Array<Record<string, unknown>> = [];
let truncated = false;
const lines = raw.split('\n');
let total = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
if (obj && typeof obj === 'object' && obj.type !== 'header') {
total++;
if (messages.length < MAX_MESSAGES) messages.push(obj);
else truncated = true;
}
} catch { /* skip bad lines */ }
}
return { success: true, format: 'live', messages, truncated, total };
}
// .chat.json (VOD replay) — single object with messages array
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) {
return { success: false, error: 'Unsupported chat file format' };
}
const total = parsed.messages.length;
const messages = parsed.messages.length > MAX_MESSAGES
? parsed.messages.slice(0, MAX_MESSAGES)
: parsed.messages;
return {
success: true,
format: 'replay',
messages,
truncated: total > MAX_MESSAGES,
total
};
} catch (e) {
return { success: false, error: String(e) };
}
});
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false;
return isDownloadPathWritable(folderPath);
});
ipcMain.handle('is-downloading', () => isDownloading);
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
ipcMain.handle('export-runtime-metrics', async () => {
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 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(dialogResult.filePath, JSON.stringify(snapshot, null, 2));
return { success: true, filePath: dialogResult.filePath };
} catch (e) {
appendDebugLog('runtime-metrics-export-failed', String(e));
return { success: false, error: String(e) };
}
});
ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => {
if (typeof vodId !== 'string' || !vodId) return { success: false };
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
const has = config.downloaded_vod_ids.includes(vodId);
if (mark && !has) {
config.downloaded_vod_ids.push(vodId);
} else if (!mark && has) {
config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId);
} else {
return { success: true };
}
saveConfig(config);
appendDebugLog('mark-vod-downloaded', { vodId, mark });
return { success: true };
});
ipcMain.handle('reset-downloaded-vod-ids', () => {
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
config.downloaded_vod_ids = [];
saveConfig(config);
appendDebugLog('reset-downloaded-vod-ids', { previousCount: count });
return { success: true, removedCount: count };
});
ipcMain.handle('export-config', async () => {
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 exportable = {
...config,
client_secret: '',
__exportVersion: 1,
__exportedAt: new Date().toISOString()
};
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2));
return { success: true, filePath: dialogResult.filePath };
} catch (e) {
appendDebugLog('config-export-failed', String(e));
return { success: false, error: String(e) };
}
});
ipcMain.handle('import-config', async () => {
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 importPath = dialogResult.filePaths[0];
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.' };
}
// Merge over current config so unknown / missing keys keep their
// existing values. Then run normalizeConfigTemplates so any
// out-of-range field falls back to defaults.
const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config);
// Preserve the existing client_secret if the import stripped it
// (export does this on purpose) — the user shouldn't lose creds.
if (!merged.client_secret && config.client_secret) {
merged.client_secret = config.client_secret;
}
config = merged;
saveConfig(config);
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) };
}
});
// Video Cutter IPC
ipcMain.handle('get-video-info', async (_, filePath: string) => {
return await getVideoInfo(filePath);
});
ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => {
return await extractFrame(filePath, timeSeconds);
});
ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => {
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`);
let lastProgress = 0;
const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => {
lastProgress = percent;
mainWindow?.webContents.send('cut-progress', percent);
});
return { success, outputFile: success ? outputFile : null };
});
// Merge IPC
ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => {
const success = await mergeVideos(inputFiles, outputFile, (percent) => {
mainWindow?.webContents.send('merge-progress', percent);
});
return { success, outputFile: success ? outputFile : null };
});
ipcMain.handle('select-multiple-videos', async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
]
});
return result.filePaths;
});
ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
const result = await dialog.showSaveDialog(mainWindow!, {
defaultPath: defaultName,
filters: [
{ name: 'MP4 Video', extensions: ['mp4'] }
]
});
return result.filePath || null;
});
// ==========================================
// APP LIFECYCLE
// ==========================================
app.whenReady().then(() => {
app.setAppUserModelId('com.twitch.vodmanager');
refreshBundledToolPaths(true);
startMetadataCacheCleanup();
startDebugLogFlushTimer();
restartAutoRecordPoller();
restartAutoVodPoller();
restartAutoCleanupTimer();
createWindow();
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;
function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
if (shutdownCleanupDone) return;
shutdownCleanupDone = true;
appendDebugLog('shutdown-cleanup', { reason });
stopMetadataCacheCleanup();
cleanupMetadataCaches('shutdown');
stopAutoUpdatePolling();
stopAutoRecordPoller();
stopAutoVodPoller();
stopAutoCleanupTimer();
// Kill all active children: queue downloads, standalone clip downloads,
// and any in-flight cutter/merger/splitter ffmpeg. before-quit used to
// skip this entirely; window-all-closed did it but only via direct
// kill() (no try/catch around the queue process kill).
for (const [, tracking] of activeDownloads) {
if (tracking.process) {
try { tracking.process.kill(); } catch { /* already exited */ }
}
}
activeDownloads.clear();
for (const [, proc] of activeClipProcesses) {
try { proc.kill(); } catch { /* already exited */ }
}
activeClipProcesses.clear();
if (currentEditorProcess) {
try { currentEditorProcess.kill(); } catch { /* already exited */ }
currentEditorProcess = null;
}
saveConfig(config);
flushQueueSave();
// Flush debug log AFTER persisting state so any errors saving config /
// queue land in the log before the timer is gone.
stopDebugLogFlushTimer(true);
}
app.on('window-all-closed', () => {
shutdownCleanup('window-all-closed');
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
shutdownCleanup('before-quit');
});