Twitch-VOD-Manager/src/main.ts
xRangerDE 8634834d16 feat: auto-cleanup — archive or delete old recordings, keep disk under control
Closes the Storage-Management loop. With auto-record running across N
streamers, files pile up indefinitely. Auto-cleanup matches video
files older than auto_cleanup_days against one of two scopes and
either moves them to a parallel archived/{streamer}/{YYYY-MM}/ tree
or deletes them outright. Sidecar .chat.json/.chat.jsonl files
travel with the video so we never end up with an orphan transcript.

Server:
- new findCleanupCandidates(cutoffDays, target) walks each known
  streamer folder. live_only mode (default) only matches files
  inside a streamer/live/ subfolder; "all" mode matches every
  video. Files matched by mtime against the cutoff. Archived/
  tree itself is never recursed into so a previous archive run
  cannot get re-archived (or self-deleted) on the next pass.
- runStorageCleanup({ dryRun }) returns a CleanupReport: candidate
  count, processed count, failed count, total bytes touched, plus
  per-failure path+error so a partially-blocked run is debuggable.
  Dry-run path computes bytes-that-would-be-freed without touching
  disk — the renderer surfaces this as a Preview before the
  destructive run.
- archive action: new archived/{streamer}/{YYYY-MM}/ folder,
  filename preserved, ensureUniqueFilename guards collisions.
  delete action: fs.unlinkSync the video and every sidecar.
- Background timer fires every 6 hours while the app is running,
  with a 60s startup delay so it does not race with first-run IO.
  Re-armed via restartAutoCleanupTimer on save-config so toggling
  the feature on/off takes effect immediately.

Renderer:
- Storage settings card extended with the Auto-Cleanup section:
  enable toggle, days threshold, scope (live_only/all), action
  (archive/delete), Preview + Run-now buttons. Preview is
  destructive-action insurance — user can see "would touch N
  files" before pressing Run.
- After a destructive run, the panel auto-refreshes the storage
  stats list so the freed bytes are reflected immediately.
- DE + EN locale strings for every label, button, and report
  message; locale switch live-updates everything.

Settings autosave: enable/days/target/action all included in the
fingerprint so each change persists. autoCleanupDays goes through
the debounced text-input path; the rest are immediate-save
toggles/selects.

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

5779 lines
210 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;
auto_cleanup_enabled: boolean;
auto_cleanup_days: number;
auto_cleanup_target: 'live_only' | 'all';
auto_cleanup_action: 'delete' | 'archive';
}
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,
auto_cleanup_enabled: false,
auto_cleanup_days: 30,
auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive'
};
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,
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'
};
}
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;
}
}
// ==========================================
// 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 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);
}
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();
// 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.
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
if (chatSession) {
stopLiveChatCapture(chatSession);
}
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);
}
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;
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();
}
// 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 });
});
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();
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();
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');
});