Migrator runs on app.whenReady before pollers/createWindow. Lazy require keeps native better-sqlite3 errors from blocking app startup. Result is logged via appendDebugLog for diagnosis. Verified via npm run test:e2e (0 issues, app starts cleanly). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7309 lines
270 KiB
TypeScript
7309 lines
270 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 './main/domain/update-version-utils';
|
|
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
|
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
|
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
|
import {
|
|
normalizeLogin,
|
|
normalizeAutoRecordPollSeconds,
|
|
normalizeAutoRecordList,
|
|
normalizeStreamlinkQuality,
|
|
normalizeFilenameTemplate,
|
|
normalizeMetadataCacheMinutes,
|
|
normalizePerformanceMode,
|
|
isPlainObject,
|
|
VALID_STREAMLINK_QUALITIES,
|
|
type PerformanceMode,
|
|
} from './main/domain/config-normalize';
|
|
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 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
|
|
// ==========================================
|
|
// Backend-Messages sind in src/main/domain/i18n-backend.ts.
|
|
// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language
|
|
// als 3. Parameter, der hier aus config.language injected wird.
|
|
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
|
return tBackendCore(key, params, config?.language ?? 'de');
|
|
}
|
|
|
|
// Ensure directories exist
|
|
if (!fs.existsSync(APPDATA_DIR)) {
|
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
|
}
|
|
|
|
// ==========================================
|
|
// INTERFACES
|
|
// ==========================================
|
|
interface Config {
|
|
client_id: string;
|
|
client_secret: string;
|
|
download_path: string;
|
|
streamers: string[];
|
|
theme: string;
|
|
download_mode: 'parts' | 'full';
|
|
part_minutes: number;
|
|
language: 'de' | 'en';
|
|
filename_template_vod: string;
|
|
filename_template_parts: string;
|
|
filename_template_clip: string;
|
|
smart_queue_scheduler: boolean;
|
|
performance_mode: PerformanceMode;
|
|
prevent_duplicate_downloads: boolean;
|
|
persist_queue_on_restart: boolean;
|
|
metadata_cache_minutes: number;
|
|
parallel_downloads: number;
|
|
auto_resume_queue_on_startup: boolean;
|
|
downloaded_vod_ids: string[];
|
|
streamlink_quality: string;
|
|
notify_on_each_completion: boolean;
|
|
streamlink_disable_ads: boolean;
|
|
auto_record_streamers: string[];
|
|
auto_record_poll_seconds: number;
|
|
download_chat_replay: boolean;
|
|
capture_live_chat: boolean;
|
|
discord_webhook_url: string;
|
|
discord_notify_live_start: boolean;
|
|
discord_notify_live_end: boolean;
|
|
discord_notify_vod_complete: boolean;
|
|
discord_notify_vod_auto_queued: boolean;
|
|
auto_cleanup_enabled: boolean;
|
|
auto_cleanup_days: number;
|
|
auto_cleanup_target: 'live_only' | 'all';
|
|
auto_cleanup_action: 'delete' | 'archive';
|
|
log_stream_events: boolean;
|
|
auto_vod_download_streamers: string[];
|
|
auto_vod_download_poll_minutes: number;
|
|
auto_vod_max_age_hours: number;
|
|
auto_resume_live_recording: boolean;
|
|
auto_merge_resumed_parts: boolean;
|
|
delete_parts_after_merge: boolean;
|
|
}
|
|
|
|
interface RuntimeMetrics {
|
|
cacheHits: number;
|
|
cacheMisses: number;
|
|
duplicateSkips: number;
|
|
retriesScheduled: number;
|
|
retriesExhausted: number;
|
|
integrityFailures: number;
|
|
downloadsStarted: number;
|
|
downloadsCompleted: number;
|
|
downloadsFailed: number;
|
|
downloadedBytesTotal: number;
|
|
lastSpeedBytesPerSec: number;
|
|
avgSpeedBytesPerSec: number;
|
|
activeItemId: string | null;
|
|
activeItemTitle: string | null;
|
|
lastErrorClass: RetryErrorClass | null;
|
|
lastRetryDelaySeconds: number;
|
|
}
|
|
|
|
interface RuntimeMetricsSnapshot extends RuntimeMetrics {
|
|
timestamp: string;
|
|
queue: {
|
|
pending: number;
|
|
downloading: number;
|
|
paused: number;
|
|
completed: number;
|
|
error: number;
|
|
total: number;
|
|
};
|
|
caches: {
|
|
loginToUserId: number;
|
|
vodList: number;
|
|
clipInfo: number;
|
|
};
|
|
config: {
|
|
performanceMode: PerformanceMode;
|
|
smartScheduler: boolean;
|
|
metadataCacheMinutes: number;
|
|
duplicatePrevention: boolean;
|
|
};
|
|
}
|
|
|
|
interface CacheEntry<T> {
|
|
value: T;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface VOD {
|
|
id: string;
|
|
title: string;
|
|
created_at: string;
|
|
duration: string;
|
|
thumbnail_url: string;
|
|
url: string;
|
|
view_count: number;
|
|
stream_id: string;
|
|
}
|
|
|
|
interface PreflightChecks {
|
|
internet: boolean;
|
|
streamlink: boolean;
|
|
ffmpeg: boolean;
|
|
ffprobe: boolean;
|
|
downloadPathWritable: boolean;
|
|
}
|
|
|
|
interface PreflightResult {
|
|
ok: boolean;
|
|
autoFixApplied: boolean;
|
|
checks: PreflightChecks;
|
|
messages: string[];
|
|
timestamp: string;
|
|
}
|
|
|
|
interface VideoInfo {
|
|
duration: number;
|
|
width: number;
|
|
height: number;
|
|
fps: number;
|
|
}
|
|
|
|
interface ReleaseUpdateInfo {
|
|
tagName?: string;
|
|
version?: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
}
|
|
|
|
// ==========================================
|
|
// CONFIG MANAGEMENT
|
|
// ==========================================
|
|
const defaultConfig: Config = {
|
|
client_id: '',
|
|
client_secret: '',
|
|
download_path: DEFAULT_DOWNLOAD_PATH,
|
|
streamers: [],
|
|
theme: 'twitch',
|
|
download_mode: 'full',
|
|
part_minutes: 120,
|
|
language: 'en',
|
|
filename_template_vod: DEFAULT_FILENAME_TEMPLATE_VOD,
|
|
filename_template_parts: DEFAULT_FILENAME_TEMPLATE_PARTS,
|
|
filename_template_clip: DEFAULT_FILENAME_TEMPLATE_CLIP,
|
|
smart_queue_scheduler: true,
|
|
performance_mode: DEFAULT_PERFORMANCE_MODE,
|
|
prevent_duplicate_downloads: true,
|
|
persist_queue_on_restart: true,
|
|
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
|
|
parallel_downloads: 1,
|
|
auto_resume_queue_on_startup: false,
|
|
downloaded_vod_ids: [],
|
|
streamlink_quality: 'best',
|
|
notify_on_each_completion: false,
|
|
streamlink_disable_ads: true,
|
|
auto_record_streamers: [],
|
|
auto_record_poll_seconds: 90,
|
|
download_chat_replay: false,
|
|
capture_live_chat: false,
|
|
discord_webhook_url: '',
|
|
discord_notify_live_start: false,
|
|
discord_notify_live_end: false,
|
|
discord_notify_vod_complete: false,
|
|
discord_notify_vod_auto_queued: false,
|
|
auto_cleanup_enabled: false,
|
|
auto_cleanup_days: 30,
|
|
auto_cleanup_target: 'live_only',
|
|
auto_cleanup_action: 'archive',
|
|
log_stream_events: true,
|
|
auto_vod_download_streamers: [],
|
|
auto_vod_download_poll_minutes: 15,
|
|
auto_vod_max_age_hours: 24,
|
|
auto_resume_live_recording: true,
|
|
auto_merge_resumed_parts: false,
|
|
delete_parts_after_merge: false
|
|
};
|
|
|
|
// normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin
|
|
// kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt
|
|
// hier, da es config liest.
|
|
function getStreamlinkStreamArg(): string {
|
|
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
|
|
if (choice === 'best') return 'best';
|
|
return `${choice},best`;
|
|
}
|
|
|
|
function normalizeConfigTemplates(input: Config): Config {
|
|
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
|
// an unbounded list across years of downloads. Latest entries kept.
|
|
const DOWNLOADED_IDS_MAX = 4096;
|
|
const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : [];
|
|
const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX
|
|
? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX)
|
|
: cleanIds;
|
|
|
|
return {
|
|
...input,
|
|
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
|
|
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
|
|
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
|
|
smart_queue_scheduler: input.smart_queue_scheduler !== false,
|
|
performance_mode: normalizePerformanceMode(input.performance_mode),
|
|
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
|
|
persist_queue_on_restart: input.persist_queue_on_restart !== false,
|
|
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
|
|
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
|
|
downloaded_vod_ids: trimmedIds,
|
|
streamlink_quality: normalizeStreamlinkQuality(input.streamlink_quality),
|
|
notify_on_each_completion: input.notify_on_each_completion === true,
|
|
// Default-true on first launch (most users hit this), but respect
|
|
// an explicit `false` from the loaded config.
|
|
streamlink_disable_ads: input.streamlink_disable_ads !== false,
|
|
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
|
|
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
|
download_chat_replay: input.download_chat_replay === true,
|
|
capture_live_chat: input.capture_live_chat === true,
|
|
// Webhook URL is stored but never validated server-side — invalid
|
|
// URLs just cause the post to fail (logged, non-fatal). Users with
|
|
// accidental whitespace are saved by the .trim().
|
|
discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '',
|
|
discord_notify_live_start: input.discord_notify_live_start === true,
|
|
discord_notify_live_end: input.discord_notify_live_end === true,
|
|
discord_notify_vod_complete: input.discord_notify_vod_complete === true,
|
|
discord_notify_vod_auto_queued: input.discord_notify_vod_auto_queued === true,
|
|
auto_cleanup_enabled: input.auto_cleanup_enabled === true,
|
|
auto_cleanup_days: (() => {
|
|
const n = Number(input.auto_cleanup_days);
|
|
if (!Number.isFinite(n) || n < 1) return 30;
|
|
return Math.min(3650, Math.floor(n));
|
|
})(),
|
|
auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only',
|
|
auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
|
|
log_stream_events: input.log_stream_events !== false,
|
|
auto_vod_download_streamers: normalizeAutoRecordList(input.auto_vod_download_streamers),
|
|
auto_vod_download_poll_minutes: (() => {
|
|
const n = Number(input.auto_vod_download_poll_minutes);
|
|
if (!Number.isFinite(n)) return 15;
|
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
|
})(),
|
|
auto_vod_max_age_hours: (() => {
|
|
const n = Number(input.auto_vod_max_age_hours);
|
|
if (!Number.isFinite(n)) return 24;
|
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
|
})(),
|
|
auto_resume_live_recording: input.auto_resume_live_recording !== false,
|
|
auto_merge_resumed_parts: input.auto_merge_resumed_parts === true,
|
|
delete_parts_after_merge: input.delete_parts_after_merge === true
|
|
};
|
|
}
|
|
|
|
function recordDownloadedVodId(vodId: string): void {
|
|
if (!vodId) return;
|
|
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 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 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'));
|
|
|
|
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 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
|
|
};
|
|
}
|
|
|
|
// ==========================================
|
|
// STREAMER PROFILE — display-name, avatar, follower count, etc.
|
|
// ==========================================
|
|
// User-facing channel header data. Combines Helix /users (display name,
|
|
// avatar, bio, broadcaster type), public GQL (follower total — Helix
|
|
// requires moderator scope we don't have), the already-cached VOD list
|
|
// (vodCount + lastStreamAt come for free), and the live-status cache
|
|
// (isLive + currentTitle + currentGame). Cached for 30 min per login.
|
|
interface StreamerProfile {
|
|
login: string;
|
|
displayName: string;
|
|
avatarUrl: string;
|
|
bannerUrl: string;
|
|
description: string;
|
|
broadcasterType: '' | 'partner' | 'affiliate';
|
|
followerCount: number | null;
|
|
vodCount: number;
|
|
lastStreamAt: string | null;
|
|
isLive: boolean;
|
|
currentTitle: string | null;
|
|
currentGame: string | null;
|
|
currentStreamPreviewUrl: string;
|
|
currentStreamViewers: number | null;
|
|
twitchUrl: string;
|
|
fetchedAt: number;
|
|
}
|
|
|
|
const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512;
|
|
const streamerProfileCache = new Map<string, CacheEntry<StreamerProfile>>();
|
|
const inFlightProfileRequests = new Map<string, Promise<StreamerProfile | null>>();
|
|
|
|
// Avatar bytes get embedded as data URLs in the profile so the renderer
|
|
// doesn't have to do its own HTTPS fetch (Electron's renderer img loader
|
|
// has a habit of failing silently against the Twitch CDN — undocumented,
|
|
// but reproducibly: the same URL works in DevTools but not in the live
|
|
// page). Cached by source URL so a single avatar change across multiple
|
|
// streamer entries only downloads once.
|
|
const avatarDataUrlCache = new Map<string, string>();
|
|
const MAX_AVATAR_DATA_URL_CACHE = 256;
|
|
|
|
async function fetchAvatarAsDataUrl(url: string): Promise<string> {
|
|
if (!url) return '';
|
|
const cached = avatarDataUrlCache.get(url);
|
|
if (cached !== undefined) return cached;
|
|
try {
|
|
const response = await axios.get<ArrayBuffer>(url, {
|
|
responseType: 'arraybuffer',
|
|
timeout: 8000,
|
|
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
|
|
});
|
|
const buf = Buffer.from(response.data);
|
|
// Twitch CDN almost always serves PNG or JPEG. Detect from the
|
|
// response content-type when available, fall back to PNG which is
|
|
// the default for profile_image_url.
|
|
const contentType = (response.headers['content-type'] as string | undefined)?.split(';')[0]?.trim() || 'image/png';
|
|
const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`;
|
|
avatarDataUrlCache.set(url, dataUrl);
|
|
if (avatarDataUrlCache.size > MAX_AVATAR_DATA_URL_CACHE) {
|
|
// FIFO eviction — Map preserves insertion order.
|
|
const firstKey = avatarDataUrlCache.keys().next().value as string | undefined;
|
|
if (firstKey) avatarDataUrlCache.delete(firstKey);
|
|
}
|
|
return dataUrl;
|
|
} catch (e) {
|
|
appendDebugLog('avatar-fetch-failed', { url, error: String(e) });
|
|
return '';
|
|
}
|
|
}
|
|
|
|
interface HelixUser {
|
|
id: string;
|
|
login: string;
|
|
display_name: string;
|
|
description: string;
|
|
profile_image_url: string;
|
|
broadcaster_type: string;
|
|
}
|
|
|
|
async function fetchHelixUserInfo(login: string): Promise<HelixUser | null> {
|
|
if (!(await ensureTwitchAuth())) return null;
|
|
try {
|
|
const response = await axios.get('https://api.twitch.tv/helix/users', {
|
|
params: { login },
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
const u = response.data?.data?.[0];
|
|
if (!u?.id) return null;
|
|
return u as HelixUser;
|
|
} catch (e) {
|
|
appendDebugLog('helix-user-info-failed', { login, error: String(e) });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface PublicProfileQueryResult {
|
|
user: {
|
|
id: string;
|
|
login: string;
|
|
displayName: string;
|
|
description: string | null;
|
|
profileImageURL: string | null;
|
|
bannerImageURL: string | null;
|
|
roles?: { isPartner: boolean; isAffiliate: boolean } | null;
|
|
followers?: { totalCount: number } | null;
|
|
stream?: {
|
|
id: string;
|
|
type: string;
|
|
title: string | null;
|
|
viewersCount: number | null;
|
|
previewImageURL: string | null;
|
|
game: { name: string } | null;
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
interface PublicStreamerProfileResult {
|
|
displayName: string;
|
|
avatarUrl: string;
|
|
bannerUrl: string;
|
|
description: string;
|
|
broadcasterType: '' | 'partner' | 'affiliate';
|
|
followerCount: number | null;
|
|
stream: PublicStreamInfo | null;
|
|
}
|
|
|
|
interface PublicStreamInfo {
|
|
previewUrl: string;
|
|
viewers: number | null;
|
|
title: string | null;
|
|
game: string | null;
|
|
}
|
|
|
|
async function fetchPublicStreamerProfile(login: string): Promise<PublicStreamerProfileResult | null> {
|
|
// Same query also pulls bannerImageURL and the current stream's
|
|
// preview + viewer count when live — saves a separate roundtrip.
|
|
const data = await fetchPublicTwitchGql<PublicProfileQueryResult>(
|
|
`query($login: String!) {
|
|
user(login: $login) {
|
|
id
|
|
login
|
|
displayName
|
|
description
|
|
profileImageURL(width: 150)
|
|
bannerImageURL
|
|
roles { isPartner isAffiliate }
|
|
followers { totalCount }
|
|
stream {
|
|
id
|
|
type
|
|
title
|
|
viewersCount
|
|
previewImageURL(width: 640, height: 360)
|
|
game { name }
|
|
}
|
|
}
|
|
}`,
|
|
{ login }
|
|
);
|
|
if (!data?.user) return null;
|
|
const roles = data.user.roles;
|
|
const broadcasterType: '' | 'partner' | 'affiliate' = roles?.isPartner
|
|
? 'partner'
|
|
: (roles?.isAffiliate ? 'affiliate' : '');
|
|
const s = data.user.stream;
|
|
const stream = (s && s.type === 'live') ? {
|
|
previewUrl: s.previewImageURL || '',
|
|
viewers: typeof s.viewersCount === 'number' ? s.viewersCount : null,
|
|
title: s.title || null,
|
|
game: s.game?.name || null
|
|
} : null;
|
|
return {
|
|
displayName: data.user.displayName || login,
|
|
avatarUrl: data.user.profileImageURL || '',
|
|
bannerUrl: data.user.bannerImageURL || '',
|
|
description: data.user.description || '',
|
|
broadcasterType,
|
|
followerCount: typeof data.user.followers?.totalCount === 'number' ? data.user.followers.totalCount : null,
|
|
stream
|
|
};
|
|
}
|
|
|
|
async function getStreamerProfile(login: string, forceRefresh = false): Promise<StreamerProfile | null> {
|
|
const normalized = normalizeLogin(login);
|
|
if (!normalized) return null;
|
|
|
|
if (!forceRefresh) {
|
|
const cached = getCachedValue(streamerProfileCache, normalized);
|
|
if (cached !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightProfileRequests, normalized, async () => {
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
// Public GQL is now the SOURCE for everything except some of the
|
|
// core text fields when Helix is authenticated — because public
|
|
// GQL is the only route that gives us the banner image + current
|
|
// stream preview in one shot, and skipping it would mean two
|
|
// extra roundtrips. Helix takes precedence for displayName /
|
|
// description (those fields are sometimes richer there).
|
|
let displayName = normalized;
|
|
let avatarUrl = '';
|
|
let bannerUrl = '';
|
|
let description = '';
|
|
let broadcasterType: '' | 'partner' | 'affiliate' = '';
|
|
let streamFromPublic: PublicStreamInfo | null = null;
|
|
let followerCountFromPublic: number | null = null;
|
|
|
|
const publicProfile = await fetchPublicStreamerProfile(normalized);
|
|
if (publicProfile) {
|
|
displayName = publicProfile.displayName;
|
|
avatarUrl = publicProfile.avatarUrl;
|
|
bannerUrl = publicProfile.bannerUrl;
|
|
description = publicProfile.description;
|
|
broadcasterType = publicProfile.broadcasterType;
|
|
followerCountFromPublic = publicProfile.followerCount;
|
|
streamFromPublic = publicProfile.stream;
|
|
}
|
|
|
|
const helixUser = await fetchHelixUserInfo(normalized);
|
|
if (helixUser) {
|
|
displayName = helixUser.display_name || displayName;
|
|
if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url;
|
|
if (helixUser.description) description = helixUser.description;
|
|
const bt = (helixUser.broadcaster_type || '').toLowerCase();
|
|
if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt;
|
|
}
|
|
|
|
// followerCountFromPublic comes from the public profile query
|
|
// above — no separate follower roundtrip needed.
|
|
const followerCount = followerCountFromPublic;
|
|
|
|
// Derive vod count + last stream from the already-cached VOD list
|
|
// when we have an id. No extra network hit.
|
|
let vodCount = 0;
|
|
let lastStreamAt: string | null = null;
|
|
const userId = await getUserId(normalized);
|
|
if (userId) {
|
|
try {
|
|
const vods = await getVODs(userId);
|
|
vodCount = vods.length;
|
|
// VOD list is sorted by Twitch newest-first; pick element 0.
|
|
const newest = vods[0];
|
|
if (newest?.created_at) lastStreamAt = newest.created_at;
|
|
} catch (e) {
|
|
appendDebugLog('profile-vod-derive-failed', { login: normalized, error: String(e) });
|
|
}
|
|
}
|
|
|
|
let isLive = false;
|
|
let currentTitle: string | null = null;
|
|
let currentGame: string | null = null;
|
|
let currentStreamPreviewRemoteUrl = '';
|
|
let currentStreamViewers: number | null = null;
|
|
|
|
if (streamFromPublic) {
|
|
// Public-GQL already told us this user is live and gave us a
|
|
// preview frame URL + viewer count + game/title. Don't double-
|
|
// call getLiveStreamInfo when we already have a fresh answer.
|
|
isLive = true;
|
|
currentTitle = streamFromPublic.title;
|
|
currentGame = streamFromPublic.game;
|
|
currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl;
|
|
currentStreamViewers = streamFromPublic.viewers;
|
|
} else {
|
|
try {
|
|
const live = await getLiveStreamInfo(normalized);
|
|
if (live) {
|
|
isLive = live.isLive;
|
|
currentTitle = live.title || null;
|
|
currentGame = live.gameName || null;
|
|
}
|
|
} catch (_) { /* best-effort */ }
|
|
}
|
|
|
|
// Embed the avatar AND banner bytes as data URLs in parallel.
|
|
// Renderer can't reliably fetch Twitch CDN images directly from
|
|
// an Electron renderer process, plus the data URL approach skips
|
|
// any CSP/referer/CORS quirks. Live preview also goes through
|
|
// this path — adds a cache-busting query string so a returning
|
|
// user gets a fresh frame each time the profile refreshes.
|
|
const livePreviewUrlForFetch = currentStreamPreviewRemoteUrl
|
|
? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}`
|
|
: '';
|
|
const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([
|
|
avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''),
|
|
bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''),
|
|
livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('')
|
|
]);
|
|
|
|
const profile: StreamerProfile = {
|
|
login: normalized,
|
|
displayName,
|
|
avatarUrl: avatarDataUrl || avatarUrl,
|
|
bannerUrl: bannerDataUrl || bannerUrl,
|
|
description,
|
|
broadcasterType,
|
|
followerCount,
|
|
vodCount,
|
|
lastStreamAt,
|
|
isLive,
|
|
currentTitle,
|
|
currentGame,
|
|
currentStreamPreviewUrl: livePreviewDataUrl || currentStreamPreviewRemoteUrl,
|
|
currentStreamViewers,
|
|
twitchUrl: `https://www.twitch.tv/${normalized}`,
|
|
fetchedAt: Date.now()
|
|
};
|
|
|
|
setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES);
|
|
return profile;
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VOD STORYBOARD — animated hover preview
|
|
// ==========================================
|
|
// Twitch publishes a "storyboard" JSON per VOD with sprite-sheet URLs
|
|
// containing N preview thumbnails covering the full length of the
|
|
// recording. We pull the JSON via public GQL (seekPreviewsURL), then
|
|
// hand the renderer the first high-quality sprite as a data URL plus
|
|
// the grid metadata. The renderer animates background-position across
|
|
// 4 cells to produce a scrub-preview effect on hover, twitch.tv-style.
|
|
interface VodStoryboard {
|
|
vodId: string;
|
|
spriteDataUrl: string;
|
|
cols: number;
|
|
rows: number;
|
|
cellWidth: number;
|
|
cellHeight: number;
|
|
framesInSprite: number;
|
|
}
|
|
|
|
const MAX_VOD_STORYBOARD_CACHE_ENTRIES = 1024;
|
|
const vodStoryboardCache = new Map<string, CacheEntry<VodStoryboard | null>>();
|
|
const inFlightStoryboardRequests = new Map<string, Promise<VodStoryboard | null>>();
|
|
|
|
interface StoryboardManifestEntry {
|
|
count: number;
|
|
width: number;
|
|
height: number;
|
|
cols: number;
|
|
rows: number;
|
|
images: string[];
|
|
quality: string;
|
|
interval: number;
|
|
}
|
|
|
|
async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
|
|
if (!vodId) return null;
|
|
|
|
const cached = getCachedValue(vodStoryboardCache, vodId);
|
|
if (cached !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cached;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightStoryboardRequests, vodId, async () => {
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
// Step 1: GQL gives us the seekPreviewsURL pointing at a JSON
|
|
// manifest. The manifest lists sprite images at multiple quality
|
|
// levels; we pick the high-quality first sprite (covers the
|
|
// beginning of the VOD with the most detail).
|
|
const data = await fetchPublicTwitchGql<{ video: { seekPreviewsURL: string | null } | null }>(
|
|
`query($id: ID!) { video(id: $id) { seekPreviewsURL } }`,
|
|
{ id: vodId }
|
|
);
|
|
const manifestUrl = data?.video?.seekPreviewsURL;
|
|
if (!manifestUrl) {
|
|
// Cache the negative result so a VOD without a storyboard
|
|
// (private/unlisted/expired) doesn't get re-queried on every
|
|
// subsequent hover.
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
let manifest: StoryboardManifestEntry[] | null = null;
|
|
try {
|
|
const manifestResp = await axios.get<StoryboardManifestEntry[]>(manifestUrl, {
|
|
timeout: 6000,
|
|
responseType: 'json',
|
|
headers: { 'User-Agent': 'TwitchVODManager/1.0' }
|
|
});
|
|
manifest = manifestResp.data;
|
|
} catch (e) {
|
|
appendDebugLog('storyboard-manifest-failed', { vodId, error: String(e) });
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
if (!Array.isArray(manifest) || manifest.length === 0) {
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
// Prefer the "high" quality entry — Twitch ships both "low" and
|
|
// "high" alongside each other. Falls back to whichever is present.
|
|
const entry = manifest.find((m) => m.quality === 'high') || manifest[0];
|
|
if (!entry?.images?.length) {
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
// The manifest URL points at e.g. .../storyboards/2767872722-info.json
|
|
// and sprite filenames are relative (e.g. "2767872722-high-0.jpg").
|
|
// Strip the JSON filename to get the base, then append the sprite.
|
|
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
|
|
const firstSpriteUrl = baseUrl + entry.images[0];
|
|
|
|
const spriteDataUrl = await fetchAvatarAsDataUrl(firstSpriteUrl);
|
|
if (!spriteDataUrl) {
|
|
setCachedValue(vodStoryboardCache, vodId, null, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return null;
|
|
}
|
|
|
|
const storyboard: VodStoryboard = {
|
|
vodId,
|
|
spriteDataUrl,
|
|
cols: entry.cols,
|
|
rows: entry.rows,
|
|
cellWidth: entry.width,
|
|
cellHeight: entry.height,
|
|
framesInSprite: entry.cols * entry.rows
|
|
};
|
|
setCachedValue(vodStoryboardCache, vodId, storyboard, MAX_VOD_STORYBOARD_CACHE_ENTRIES);
|
|
return storyboard;
|
|
});
|
|
}
|
|
|
|
async function getClipInfo(clipId: string): Promise<any | null> {
|
|
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (cachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedClip;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightClipRequests, clipId, async () => {
|
|
const refreshedCachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (refreshedCachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedClip;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
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));
|
|
});
|
|
}
|
|
|
|
// Concatenates same-codec mp4 files into a single output via ffmpeg's
|
|
// concat demuxer. No re-encoding — purely a container stitch, which is
|
|
// what we want for resumed-recording parts (same streamlink, same codec
|
|
// settings, just split across files). Returns false on any error so the
|
|
// caller can keep the original parts.
|
|
async function concatVideoFiles(inputFiles: string[], outputFile: string): Promise<boolean> {
|
|
if (inputFiles.length < 2) return false;
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) return false;
|
|
|
|
for (const f of inputFiles) {
|
|
if (!fs.existsSync(f)) {
|
|
appendDebugLog('concat-missing-part', { missing: f });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const listFile = path.join(path.dirname(outputFile), `.concat-${Date.now()}.txt`);
|
|
try {
|
|
// ffmpeg concat demuxer escaping: paths go in single quotes, embedded
|
|
// single quotes need '\''. Backslashes are fine on Windows.
|
|
const lines = inputFiles
|
|
.map((f) => `file '${f.replace(/'/g, "'\\''")}'`)
|
|
.join('\n');
|
|
fs.writeFileSync(listFile, lines, 'utf8');
|
|
} catch (e) {
|
|
appendDebugLog('concat-listfile-write-failed', String(e));
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const args = [
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', listFile,
|
|
'-c', 'copy',
|
|
'-y',
|
|
outputFile
|
|
];
|
|
|
|
return await new Promise<boolean>((resolve) => {
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
let stderrBuf = '';
|
|
proc.stderr?.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); });
|
|
proc.on('close', (code) => {
|
|
try { fs.unlinkSync(listFile); } catch { /* ignore */ }
|
|
if (code === 0 && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) {
|
|
appendDebugLog('concat-ok', { output: outputFile, parts: inputFiles.length });
|
|
resolve(true);
|
|
} else {
|
|
appendDebugLog('concat-failed', { code, stderrTail: stderrBuf.slice(-400) });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { /* ignore */ }
|
|
resolve(false);
|
|
}
|
|
});
|
|
proc.on('error', (err) => {
|
|
try { fs.unlinkSync(listFile); } catch { /* ignore */ }
|
|
appendDebugLog('concat-spawn-error', String(err));
|
|
resolve(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// download-part-start in the debug log captures the same info
|
|
// for support / forensics — no need to flood stdout too.
|
|
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
|
|
|
|
const 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();
|
|
// No per-line stdout — streamlink emits 10-100 lines/sec during
|
|
// an active download, which floods the terminal in dev and the
|
|
// electron-launched console in prod. Progress + tag parsing
|
|
// below extracts everything we need; failures get logged via
|
|
// appendDebugLog from the consumer side.
|
|
|
|
// 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;
|
|
let autoRecordLastRunAt = 0;
|
|
let autoRecordNextRunAt = 0;
|
|
let autoRecordLastTriggerCount = 0;
|
|
|
|
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?.();
|
|
autoRecordNextRunAt = Date.now() + seconds * 1000;
|
|
// Kick off an immediate first poll so a freshly-enabled streamer that's
|
|
// already live gets picked up without waiting a full interval.
|
|
setTimeout(() => { void runAutoRecordPoll(); }, 1500);
|
|
}
|
|
|
|
async function runAutoRecordPoll(): Promise<number> {
|
|
if (autoRecordPollInFlight) return 0;
|
|
autoRecordPollInFlight = true;
|
|
let triggered = 0;
|
|
try {
|
|
const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : [];
|
|
for (const streamer of list) {
|
|
// Check if list still contains streamer (config may have changed
|
|
// mid-iteration via save-config from the renderer).
|
|
if (!config.auto_record_streamers.includes(streamer)) continue;
|
|
|
|
const info = await getLiveStreamInfo(streamer);
|
|
if (info === null) {
|
|
// Couldn't determine live state — skip this streamer this
|
|
// round. Don't update lastLiveState so a subsequent successful
|
|
// poll can still detect an offline->live transition cleanly.
|
|
continue;
|
|
}
|
|
|
|
const wasLive = autoRecordLastLiveState.get(streamer) === true;
|
|
autoRecordLastLiveState.set(streamer, info.isLive);
|
|
|
|
if (!info.isLive || wasLive) continue;
|
|
|
|
// offline -> live transition. Don't double-record if a live item
|
|
// already exists in the queue (e.g. user manually triggered it).
|
|
const alreadyRecording = downloadQueue.some((it) =>
|
|
it.isLive && it.streamer === streamer
|
|
&& (it.status === 'pending' || it.status === 'downloading')
|
|
);
|
|
if (alreadyRecording) {
|
|
appendDebugLog('auto-record-skip-already', { streamer });
|
|
continue;
|
|
}
|
|
|
|
const liveItem: QueueItem = {
|
|
id: generateQueueItemId(),
|
|
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();
|
|
triggered++;
|
|
appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title });
|
|
|
|
if (!isDownloading) {
|
|
void processQueue();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('auto-record-poll-failed', String(e));
|
|
} finally {
|
|
autoRecordPollInFlight = false;
|
|
autoRecordLastRunAt = Date.now();
|
|
autoRecordLastTriggerCount = triggered;
|
|
const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds);
|
|
autoRecordNextRunAt = Date.now() + seconds * 1000;
|
|
}
|
|
return triggered;
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-VOD-DOWNLOAD POLLER
|
|
// ==========================================
|
|
// Periodically scans VOD listings of opted-in streamers and auto-queues
|
|
// any VOD that's (a) recent enough to be in scope, (b) not already
|
|
// downloaded, and (c) not already in the active queue. Cadence is
|
|
// minutes, not seconds — a VOD-listing scan is much heavier than a
|
|
// live-status check, and new VODs only appear after a stream ends, so
|
|
// minute-level lag is fine.
|
|
let autoVodPollTimer: NodeJS.Timeout | null = null;
|
|
let autoVodPollInFlight = false;
|
|
let autoVodLastRunAt = 0;
|
|
let autoVodNextRunAt = 0;
|
|
let autoVodLastQueuedCount = 0;
|
|
|
|
function stopAutoVodPoller(): void {
|
|
if (autoVodPollTimer) {
|
|
clearInterval(autoVodPollTimer);
|
|
autoVodPollTimer = null;
|
|
}
|
|
}
|
|
|
|
function restartAutoVodPoller(): void {
|
|
stopAutoVodPoller();
|
|
const list = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
|
if (list.length === 0) {
|
|
appendDebugLog('auto-vod-poller-idle', { reason: 'no streamers' });
|
|
return;
|
|
}
|
|
const minutes = (() => {
|
|
const n = Number(config.auto_vod_download_poll_minutes);
|
|
if (!Number.isFinite(n)) return 15;
|
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
|
})();
|
|
appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes });
|
|
autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000);
|
|
autoVodPollTimer.unref?.();
|
|
autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
|
|
setTimeout(() => { void runAutoVodPoll(); }, 5000);
|
|
}
|
|
|
|
async function runAutoVodPoll(): Promise<number> {
|
|
if (autoVodPollInFlight) return 0;
|
|
autoVodPollInFlight = true;
|
|
let queuedCount = 0;
|
|
try {
|
|
const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
|
|
if (list.length === 0) return 0;
|
|
|
|
const maxAgeHours = (() => {
|
|
const n = Number(config.auto_vod_max_age_hours);
|
|
if (!Number.isFinite(n)) return 24;
|
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
|
})();
|
|
const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000;
|
|
|
|
const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []);
|
|
const queuedUrls = new Set(downloadQueue.map((it) => it.url));
|
|
|
|
for (const streamer of list) {
|
|
if (!config.auto_vod_download_streamers.includes(streamer)) continue;
|
|
|
|
const userId = await getUserId(streamer);
|
|
if (!userId) {
|
|
appendDebugLog('auto-vod-skip-no-user', { streamer });
|
|
continue;
|
|
}
|
|
|
|
let vods: VOD[] = [];
|
|
try {
|
|
vods = await getVODs(userId, true);
|
|
} catch (e) {
|
|
appendDebugLog('auto-vod-list-failed', { streamer, error: String(e) });
|
|
continue;
|
|
}
|
|
if (!Array.isArray(vods) || vods.length === 0) continue;
|
|
|
|
for (const vod of vods) {
|
|
if (!vod || !vod.id || !vod.url) continue;
|
|
if (downloadedSet.has(vod.id)) continue;
|
|
if (queuedUrls.has(vod.url)) continue;
|
|
|
|
const createdMs = Date.parse(vod.created_at || '');
|
|
if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue;
|
|
|
|
const queueItem: QueueItem = {
|
|
id: generateQueueItemId(),
|
|
title: vod.title || `${streamer} VOD ${vod.id}`,
|
|
url: vod.url,
|
|
date: vod.created_at,
|
|
streamer,
|
|
duration_str: vod.duration || '',
|
|
status: 'pending',
|
|
progress: 0
|
|
};
|
|
downloadQueue.push(queueItem);
|
|
queuedUrls.add(vod.url);
|
|
queuedCount++;
|
|
appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title });
|
|
|
|
if (config.discord_notify_vod_auto_queued) {
|
|
try {
|
|
await sendDiscordWebhook({
|
|
title: 'New VOD auto-queued',
|
|
description: `\`${streamer}\` published a new VOD — queued for download.`,
|
|
color: 'info',
|
|
fields: [
|
|
{ name: 'Title', value: queueItem.title, inline: false },
|
|
{ name: 'VOD ID', value: String(vod.id), inline: true },
|
|
{ name: 'URL', value: vod.url, inline: false }
|
|
]
|
|
});
|
|
} catch (_) { /* ignore webhook errors */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
|
|
if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) {
|
|
void processQueue();
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('auto-vod-poll-failed', String(e));
|
|
} finally {
|
|
autoVodPollInFlight = false;
|
|
autoVodLastRunAt = Date.now();
|
|
autoVodLastQueuedCount = queuedCount;
|
|
const minutes = (() => {
|
|
const n = Number(config.auto_vod_download_poll_minutes);
|
|
if (!Number.isFinite(n)) return 15;
|
|
return Math.max(5, Math.min(360, Math.floor(n)));
|
|
})();
|
|
autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
|
|
if (queuedCount > 0 && mainWindow) {
|
|
mainWindow.webContents.send('auto-vod-scan-completed', { queuedCount });
|
|
}
|
|
}
|
|
return queuedCount;
|
|
}
|
|
|
|
// ==========================================
|
|
// LIVE STATUS BATCH POLLER — for the sidebar live indicators
|
|
// ==========================================
|
|
// Background poller that asks "which of these streamers are live right
|
|
// now?" for every streamer in the user's list, in a single GQL roundtrip
|
|
// (per chunk of 50). Results are stamped into liveStatusByLogin and
|
|
// pushed to the renderer so the sidebar gets a red pulsing dot next to
|
|
// anyone currently broadcasting. Independent from the auto-record
|
|
// poller — that one only watches a small subset and needs title/game,
|
|
// this one just needs the boolean and covers everyone.
|
|
const liveStatusByLogin = new Map<string, boolean>();
|
|
let liveStatusPollTimer: NodeJS.Timeout | null = null;
|
|
let liveStatusPollInFlight = false;
|
|
const LIVE_STATUS_POLL_INTERVAL_MS = 60_000;
|
|
const LIVE_STATUS_BATCH_CHUNK_SIZE = 50;
|
|
|
|
async function fetchLiveStatusBatch(logins: string[]): Promise<Map<string, boolean>> {
|
|
const result = new Map<string, boolean>();
|
|
if (logins.length === 0) return result;
|
|
|
|
for (let i = 0; i < logins.length; i += LIVE_STATUS_BATCH_CHUNK_SIZE) {
|
|
const chunk = logins.slice(i, i + LIVE_STATUS_BATCH_CHUNK_SIZE);
|
|
const vars: Record<string, string> = {};
|
|
const varDecls: string[] = [];
|
|
const aliases: string[] = [];
|
|
chunk.forEach((login, idx) => {
|
|
const varName = `l${idx}`;
|
|
vars[varName] = login;
|
|
varDecls.push(`$${varName}:String!`);
|
|
aliases.push(`u${idx}:user(login:$${varName}){login stream{type}}`);
|
|
});
|
|
const query = `query(${varDecls.join(',')}){${aliases.join(' ')}}`;
|
|
try {
|
|
const data = await fetchPublicTwitchGql<Record<string, { login: string; stream: { type: string } | null } | null>>(
|
|
query, vars
|
|
);
|
|
if (!data) continue;
|
|
for (const key of Object.keys(data)) {
|
|
const user = data[key];
|
|
if (!user || !user.login) continue;
|
|
result.set(normalizeLogin(user.login), user.stream?.type === 'live');
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('live-status-batch-failed', { chunkStart: i, error: String(e) });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runLiveStatusBatchPoll(): Promise<void> {
|
|
if (liveStatusPollInFlight) return;
|
|
liveStatusPollInFlight = true;
|
|
try {
|
|
const logins = ((config.streamers as string[]) || [])
|
|
.map((s) => normalizeLogin(s))
|
|
.filter((s): s is string => Boolean(s));
|
|
|
|
const changes: Array<{ login: string; isLive: boolean }> = [];
|
|
const watchedSet = new Set(logins);
|
|
|
|
// Always run the eviction pass FIRST — entries left over from a
|
|
// streamer that's no longer in the watch list must go regardless
|
|
// of whether we're about to fetch fresh data. Previously this
|
|
// ran inside the fetch branch only, so removing the last
|
|
// streamer left ghost entries in liveStatusByLogin until the
|
|
// next add.
|
|
for (const oldLogin of Array.from(liveStatusByLogin.keys())) {
|
|
if (!watchedSet.has(oldLogin)) {
|
|
liveStatusByLogin.delete(oldLogin);
|
|
changes.push({ login: oldLogin, isLive: false });
|
|
}
|
|
}
|
|
|
|
if (logins.length > 0) {
|
|
const fresh = await fetchLiveStatusBatch(logins);
|
|
for (const [login, isLive] of fresh.entries()) {
|
|
const prev = liveStatusByLogin.get(login);
|
|
if (prev !== isLive) changes.push({ login, isLive });
|
|
liveStatusByLogin.set(login, isLive);
|
|
}
|
|
}
|
|
|
|
if (mainWindow && changes.length > 0) {
|
|
// Renderer only consumes `changes` — initial state comes via
|
|
// the get-live-status-snapshot IPC at boot. Don't ship the
|
|
// full map on every tick (was ~1.5KB JSON per 60s with zero
|
|
// consumer-side use). Also skip the broadcast entirely when
|
|
// nothing actually changed.
|
|
mainWindow.webContents.send('live-status-batch-update', { changes });
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('live-status-poll-failed', String(e));
|
|
} finally {
|
|
liveStatusPollInFlight = false;
|
|
}
|
|
}
|
|
|
|
function stopLiveStatusPoller(): void {
|
|
if (liveStatusPollTimer) {
|
|
clearInterval(liveStatusPollTimer);
|
|
liveStatusPollTimer = null;
|
|
}
|
|
}
|
|
|
|
function restartLiveStatusPoller(): void {
|
|
stopLiveStatusPoller();
|
|
liveStatusPollTimer = setInterval(() => { void runLiveStatusBatchPoll(); }, LIVE_STATUS_POLL_INTERVAL_MS);
|
|
liveStatusPollTimer.unref?.();
|
|
setTimeout(() => { void runLiveStatusBatchPoll(); }, 1500);
|
|
}
|
|
|
|
// ==========================================
|
|
// CHAT REPLAY DOWNLOAD
|
|
// ==========================================
|
|
// Twitch retains chat replay alongside the VOD itself — same 7-60 day TTL.
|
|
// Anyone archiving the video usually wants the chat too. fetchVodChatReplay
|
|
// pulls the entire chat for a VOD via the public GQL endpoint, paginated
|
|
// via edge cursors (Twitch returns ~100 comments per page).
|
|
interface ChatReplayMessage {
|
|
id: string;
|
|
offset: number; // contentOffsetSeconds — when in the VOD
|
|
createdAt: string; // ISO timestamp
|
|
user: string; // display name
|
|
login: string; // login (lowercase)
|
|
color: string; // user chat color
|
|
text: string; // assembled message text
|
|
}
|
|
|
|
interface ChatReplayResult {
|
|
messages: ChatReplayMessage[];
|
|
truncated: boolean;
|
|
pages: number;
|
|
}
|
|
|
|
async function fetchVodChatReplay(
|
|
videoId: string,
|
|
onProgress?: (count: number) => void,
|
|
cancelCheck?: () => boolean
|
|
): Promise<ChatReplayResult> {
|
|
const messages: ChatReplayMessage[] = [];
|
|
let cursor: string | null = null;
|
|
let pages = 0;
|
|
let truncated = false;
|
|
// Hard cap to keep one runaway stream from filling memory. 200 pages =
|
|
// ~20k messages which covers typical 6-hour streams. Above that we
|
|
// stop and mark truncated.
|
|
const MAX_PAGES = 500;
|
|
|
|
type CommentNode = {
|
|
id: string;
|
|
contentOffsetSeconds: number;
|
|
createdAt: string;
|
|
message?: { fragments?: Array<{ text?: string }>; userColor?: string };
|
|
commenter?: { displayName?: string; login?: string };
|
|
};
|
|
type CommentEdge = { node: CommentNode; cursor: string };
|
|
type CommentsPage = {
|
|
video: { comments: { edges: CommentEdge[]; pageInfo: { hasNextPage: boolean } } } | null;
|
|
};
|
|
|
|
const query = 'query($videoID:ID!,$cursor:Cursor){video(id:$videoID){comments(contentOffsetSeconds:0,cursor:$cursor){edges{node{id contentOffsetSeconds createdAt message{fragments{text} userColor} commenter{displayName login}} cursor} pageInfo{hasNextPage}}}}';
|
|
|
|
while (pages < MAX_PAGES) {
|
|
if (cancelCheck && cancelCheck()) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
const data: CommentsPage | null = await fetchPublicTwitchGql<CommentsPage>(query, {
|
|
videoID: videoId,
|
|
cursor
|
|
});
|
|
if (!data || !data.video || !data.video.comments) break;
|
|
|
|
const edges: CommentEdge[] = Array.isArray(data.video.comments.edges) ? data.video.comments.edges : [];
|
|
for (const edge of edges) {
|
|
const node = edge.node;
|
|
const fragments = node.message?.fragments || [];
|
|
const text = fragments.map((f: { text?: string }) => (typeof f.text === 'string' ? f.text : '')).join('');
|
|
messages.push({
|
|
id: node.id,
|
|
offset: Number(node.contentOffsetSeconds) || 0,
|
|
createdAt: node.createdAt || '',
|
|
user: node.commenter?.displayName || '',
|
|
login: node.commenter?.login || '',
|
|
color: node.message?.userColor || '',
|
|
text
|
|
});
|
|
}
|
|
|
|
pages += 1;
|
|
if (onProgress) onProgress(messages.length);
|
|
|
|
const last: CommentEdge | undefined = edges[edges.length - 1];
|
|
if (!data.video.comments.pageInfo.hasNextPage || !last) break;
|
|
cursor = last.cursor;
|
|
}
|
|
|
|
if (pages >= MAX_PAGES) truncated = true;
|
|
return { messages, truncated, pages };
|
|
}
|
|
|
|
function chatReplayPathFor(vodFilePath: string): string {
|
|
// Strip the final extension and append .chat.json so the chat file
|
|
// lives next to the video and is easy to find.
|
|
const ext = path.extname(vodFilePath);
|
|
const base = ext ? vodFilePath.slice(0, -ext.length) : vodFilePath;
|
|
return `${base}.chat.json`;
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-CLEANUP
|
|
// ==========================================
|
|
// Targets old recording artifacts (.mp4/.ts/.mkv plus their sibling
|
|
// .chat.json/.chat.jsonl) older than auto_cleanup_days. Two scopes —
|
|
// live_only (only files inside a streamer/live/ subfolder, set-and-
|
|
// forget for auto-record users) or all (everything under the streamer
|
|
// folders). Two actions — delete or archive (move to a parallel
|
|
// archived/{streamer}/{YYYY-MM}/ tree). Archive is the safer default.
|
|
// Sibling chat files travel with the video so we don't end up with
|
|
// an orphan transcript.
|
|
interface CleanupCandidate {
|
|
videoPath: string;
|
|
sidecarPaths: string[];
|
|
streamer: string;
|
|
bytes: number;
|
|
ageDays: number;
|
|
}
|
|
interface CleanupReport {
|
|
enabled: boolean;
|
|
dryRun: boolean;
|
|
cutoffDays: number;
|
|
target: 'live_only' | 'all';
|
|
action: 'delete' | 'archive';
|
|
scannedAt: string;
|
|
candidates: number;
|
|
processed: number;
|
|
failed: number;
|
|
bytesFreed: number;
|
|
failures: Array<{ path: string; error: string }>;
|
|
}
|
|
|
|
const VIDEO_FILE_REGEX = /\.(mp4|ts|mkv|mov|avi)$/i;
|
|
|
|
function findCleanupCandidates(cutoffDays: number, target: 'live_only' | 'all'): CleanupCandidate[] {
|
|
const out: CleanupCandidate[] = [];
|
|
const root = config.download_path;
|
|
if (!root || !fs.existsSync(root)) return out;
|
|
const cutoffMs = Date.now() - cutoffDays * 24 * 60 * 60 * 1000;
|
|
const knownStreamers = new Set<string>(((config.streamers as string[]) || []).map((s) => s.toLowerCase()));
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return out;
|
|
}
|
|
|
|
const visit = (dir: string, streamer: string, mustBeUnderLive: boolean): void => {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
// Never walk back into the archived/ tree we own.
|
|
if (entry.name === 'archived') continue;
|
|
const enteringLive = entry.name === 'live';
|
|
visit(full, streamer, mustBeUnderLive && !enteringLive);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
if (!VIDEO_FILE_REGEX.test(entry.name)) continue;
|
|
if (mustBeUnderLive) continue; // live_only mode + we're not under live/
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(full);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (stat.mtimeMs > cutoffMs) continue;
|
|
|
|
// Find sibling chat files (same basename, .chat.json / .chat.jsonl)
|
|
const ext = path.extname(full);
|
|
const base = ext ? full.slice(0, -ext.length) : full;
|
|
const sidecars: string[] = [];
|
|
for (const sidecarExt of ['.chat.json', '.chat.jsonl']) {
|
|
const candidate = base + sidecarExt;
|
|
if (fs.existsSync(candidate)) sidecars.push(candidate);
|
|
}
|
|
|
|
out.push({
|
|
videoPath: full,
|
|
sidecarPaths: sidecars,
|
|
streamer,
|
|
bytes: stat.size,
|
|
ageDays: Math.floor((Date.now() - stat.mtimeMs) / (24 * 60 * 60 * 1000))
|
|
});
|
|
}
|
|
};
|
|
|
|
for (const top of topEntries) {
|
|
if (!top.isDirectory()) continue;
|
|
if (top.name === 'archived') continue; // never recurse into the archive tree
|
|
const lowered = top.name.toLowerCase();
|
|
const isKnown = knownStreamers.has(lowered) || top.name === 'Clips';
|
|
if (!isKnown) continue;
|
|
const folderPath = path.join(root, top.name);
|
|
// For live_only mode, we descend with mustBeUnderLive=true; the
|
|
// visit() call flips it to false the moment we enter a "live"
|
|
// subfolder. For "all" mode, mustBeUnderLive is false from the
|
|
// top so every video matches.
|
|
visit(folderPath, top.name, target === 'live_only');
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function archivePathForCleanup(streamer: string, originalPath: string, mtimeMs: number): string {
|
|
const root = config.download_path;
|
|
const date = new Date(mtimeMs);
|
|
const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
|
|
const dir = path.join(root, 'archived', streamer, monthKey);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return ensureUniqueFilename(path.join(dir, path.basename(originalPath)), null);
|
|
}
|
|
|
|
function runStorageCleanup(opts: { dryRun: boolean }): CleanupReport {
|
|
const report: CleanupReport = {
|
|
enabled: config.auto_cleanup_enabled === true,
|
|
dryRun: opts.dryRun,
|
|
cutoffDays: Number(config.auto_cleanup_days) || 30,
|
|
target: config.auto_cleanup_target === 'all' ? 'all' : 'live_only',
|
|
action: config.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
|
|
scannedAt: new Date().toISOString(),
|
|
candidates: 0,
|
|
processed: 0,
|
|
failed: 0,
|
|
bytesFreed: 0,
|
|
failures: []
|
|
};
|
|
|
|
const candidates = findCleanupCandidates(report.cutoffDays, report.target);
|
|
report.candidates = candidates.length;
|
|
if (opts.dryRun) {
|
|
for (const c of candidates) {
|
|
report.bytesFreed += c.bytes;
|
|
for (const sc of c.sidecarPaths) {
|
|
try { report.bytesFreed += fs.statSync(sc).size; } catch { /* ignore */ }
|
|
}
|
|
}
|
|
appendDebugLog('storage-cleanup-dry-run', { candidates: report.candidates, bytes: report.bytesFreed });
|
|
return report;
|
|
}
|
|
|
|
for (const c of candidates) {
|
|
const allPaths = [c.videoPath, ...c.sidecarPaths];
|
|
try {
|
|
if (report.action === 'delete') {
|
|
for (const p of allPaths) {
|
|
let bytes = 0;
|
|
try { bytes = fs.statSync(p).size; } catch { /* ignore */ }
|
|
fs.unlinkSync(p);
|
|
report.bytesFreed += bytes;
|
|
}
|
|
} else {
|
|
// Archive: keep the same basename, group by streamer + month.
|
|
const stat = fs.statSync(c.videoPath);
|
|
const archived = archivePathForCleanup(c.streamer, c.videoPath, stat.mtimeMs);
|
|
fs.renameSync(c.videoPath, archived);
|
|
report.bytesFreed += stat.size;
|
|
// Move sidecars to the same archive folder.
|
|
const archDir = path.dirname(archived);
|
|
for (const sc of c.sidecarPaths) {
|
|
try {
|
|
const dest = ensureUniqueFilename(path.join(archDir, path.basename(sc)), null);
|
|
fs.renameSync(sc, dest);
|
|
} catch (err) {
|
|
report.failures.push({ path: sc, error: String(err) });
|
|
}
|
|
}
|
|
}
|
|
report.processed += 1;
|
|
} catch (err) {
|
|
report.failed += 1;
|
|
report.failures.push({ path: c.videoPath, error: String(err) });
|
|
}
|
|
}
|
|
|
|
appendDebugLog('storage-cleanup-run', {
|
|
candidates: report.candidates,
|
|
processed: report.processed,
|
|
failed: report.failed,
|
|
bytes: report.bytesFreed,
|
|
action: report.action,
|
|
target: report.target
|
|
});
|
|
return report;
|
|
}
|
|
|
|
let autoCleanupTimer: NodeJS.Timeout | null = null;
|
|
let 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;
|
|
}
|
|
|
|
// ==========================================
|
|
// ARCHIVE STATS — DASHBOARD AGGREGATION
|
|
// ==========================================
|
|
interface ArchiveStatsTopStreamer {
|
|
streamer: string;
|
|
bytes: number;
|
|
fileCount: number;
|
|
liveBytes: number;
|
|
vodBytes: number;
|
|
chatBytes: number;
|
|
}
|
|
interface ArchiveStatsDay { date: string; count: number; bytes: number }
|
|
interface ArchiveStatsBucket { label: string; count: number; bytes: number }
|
|
interface ArchiveStats {
|
|
totalFiles: number;
|
|
totalBytes: number;
|
|
liveCount: number;
|
|
liveBytes: number;
|
|
vodCount: number;
|
|
vodBytes: number;
|
|
chatCount: number;
|
|
chatBytes: number;
|
|
eventsCount: number;
|
|
streamerCount: number;
|
|
avgRecordingSizeBytes: number;
|
|
topStreamers: ArchiveStatsTopStreamer[];
|
|
dailyActivity: ArchiveStatsDay[];
|
|
sizeBuckets: ArchiveStatsBucket[];
|
|
scannedAt: string;
|
|
downloadPath: string;
|
|
rootExists: boolean;
|
|
}
|
|
|
|
const SIZE_BUCKETS: Array<{ label: string; min: number; max: number }> = [
|
|
{ label: '< 100 MB', min: 0, max: 100 * 1024 * 1024 },
|
|
{ label: '100 MB - 500 MB', min: 100 * 1024 * 1024, max: 500 * 1024 * 1024 },
|
|
{ label: '500 MB - 1 GB', min: 500 * 1024 * 1024, max: 1024 * 1024 * 1024 },
|
|
{ label: '1 GB - 5 GB', min: 1024 * 1024 * 1024, max: 5 * 1024 * 1024 * 1024 },
|
|
{ label: '5 GB - 10 GB', min: 5 * 1024 * 1024 * 1024, max: 10 * 1024 * 1024 * 1024 },
|
|
{ label: '> 10 GB', min: 10 * 1024 * 1024 * 1024, max: Number.POSITIVE_INFINITY }
|
|
];
|
|
|
|
type ArchiveFileType = 'live' | 'vod' | 'chat' | 'events' | 'other';
|
|
|
|
function classifyArchiveFile(relativePath: string): ArchiveFileType {
|
|
if (/\.chat\.jsonl?$/i.test(relativePath)) return 'chat';
|
|
if (/\.events\.jsonl$/i.test(relativePath)) return 'events';
|
|
const norm = relativePath.replace(/\\/g, '/').toLowerCase();
|
|
if (norm.startsWith('live/')) return 'live';
|
|
if (/\.(mp4|mkv|ts|m4v)$/i.test(relativePath)) return 'vod';
|
|
return 'other';
|
|
}
|
|
|
|
function extractFilenameDate(name: string): string | null {
|
|
const m = /(\d{4})-(\d{2})-(\d{2})/.exec(name);
|
|
if (!m) return null;
|
|
return `${m[1]}-${m[2]}-${m[3]}`;
|
|
}
|
|
|
|
function bucketIndexForSize(bytes: number): number {
|
|
for (let i = 0; i < SIZE_BUCKETS.length; i++) {
|
|
if (bytes < SIZE_BUCKETS[i].max) return i;
|
|
}
|
|
return SIZE_BUCKETS.length - 1;
|
|
}
|
|
|
|
interface ArchiveFileRecord { size: number; mtimeMs: number; type: ArchiveFileType; date: string }
|
|
|
|
function walkForArchiveStats(
|
|
folderPath: string,
|
|
relPrefix: string,
|
|
accum: { files: ArchiveFileRecord[] }
|
|
): void {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(folderPath, entry.name);
|
|
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
try {
|
|
if (entry.isDirectory()) {
|
|
walkForArchiveStats(full, rel, accum);
|
|
} else if (entry.isFile()) {
|
|
const st = fs.statSync(full);
|
|
const type = classifyArchiveFile(rel);
|
|
const dateFromName = extractFilenameDate(entry.name);
|
|
const date = dateFromName || new Date(st.mtimeMs).toISOString().slice(0, 10);
|
|
accum.files.push({ size: st.size, mtimeMs: st.mtimeMs, type, date });
|
|
}
|
|
} catch { /* permission blip — skip */ }
|
|
}
|
|
}
|
|
|
|
// Search a single file matches the live query. Empty query matches all.
|
|
// streamerFolder is the top-level directory under root (which we equate
|
|
// with the channel name); relativePath is everything below that.
|
|
interface ArchiveSearchFilter {
|
|
query: string;
|
|
type: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
|
streamer: string;
|
|
sinceMs: number | null;
|
|
untilMs: number | null;
|
|
sort: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
|
limit: number;
|
|
}
|
|
|
|
interface ArchiveSearchHit {
|
|
fullPath: string;
|
|
fileName: string;
|
|
streamer: string;
|
|
type: ArchiveFileType;
|
|
size: number;
|
|
mtimeMs: number;
|
|
chatPath: string | null;
|
|
eventsPath: string | null;
|
|
}
|
|
|
|
interface ArchiveSearchResult {
|
|
totalScanned: number;
|
|
matchCount: number;
|
|
truncated: boolean;
|
|
hits: ArchiveSearchHit[];
|
|
scannedAt: string;
|
|
rootExists: boolean;
|
|
}
|
|
|
|
function matchSearchFilter(
|
|
streamerFolder: string,
|
|
relativePath: string,
|
|
fileName: string,
|
|
fileSize: number,
|
|
mtimeMs: number,
|
|
type: ArchiveFileType,
|
|
filter: ArchiveSearchFilter
|
|
): boolean {
|
|
if (filter.type !== 'all' && filter.type !== type) return false;
|
|
if (filter.streamer && streamerFolder.toLowerCase() !== filter.streamer.toLowerCase()) return false;
|
|
if (filter.sinceMs !== null && mtimeMs < filter.sinceMs) return false;
|
|
if (filter.untilMs !== null && mtimeMs > filter.untilMs) return false;
|
|
if (filter.query) {
|
|
const q = filter.query.toLowerCase();
|
|
const hay = `${fileName} ${streamerFolder} ${relativePath}`.toLowerCase();
|
|
if (!hay.includes(q)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function searchArchive(filter: ArchiveSearchFilter): ArchiveSearchResult {
|
|
const root = config.download_path;
|
|
const result: ArchiveSearchResult = {
|
|
totalScanned: 0,
|
|
matchCount: 0,
|
|
truncated: false,
|
|
hits: [],
|
|
scannedAt: new Date().toISOString(),
|
|
rootExists: false
|
|
};
|
|
if (!root || !fs.existsSync(root)) return result;
|
|
result.rootExists = true;
|
|
|
|
const maxHits = Math.max(10, Math.min(2000, Math.floor(filter.limit) || 200));
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
|
|
// To attach chat/events sibling paths to a recording hit, we collect
|
|
// every file in a streamer's tree first, then make a second pass to
|
|
// pair up companions by stripping the .mp4 base.
|
|
for (const entry of topEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const streamerFolder = entry.name;
|
|
const streamerRoot = path.join(root, streamerFolder);
|
|
const filesInTree: Array<{ fullPath: string; rel: string; name: string; size: number; mtimeMs: number; type: ArchiveFileType }> = [];
|
|
const accum: { files: ArchiveFileRecord[] } = { files: [] };
|
|
// We re-walk here instead of reusing walkForArchiveStats because
|
|
// we need the full path + rel path on each file, not just the
|
|
// type/size aggregates. The cost is one redundant tree walk per
|
|
// search; acceptable for an interactive search.
|
|
const walkWithPaths = (folderPath: string, relPrefix: string): void => {
|
|
let entries2: fs.Dirent[];
|
|
try {
|
|
entries2 = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
} catch { return; }
|
|
for (const e2 of entries2) {
|
|
const full = path.join(folderPath, e2.name);
|
|
const rel = relPrefix ? `${relPrefix}/${e2.name}` : e2.name;
|
|
try {
|
|
if (e2.isDirectory()) {
|
|
walkWithPaths(full, rel);
|
|
} else if (e2.isFile()) {
|
|
const st = fs.statSync(full);
|
|
const type = classifyArchiveFile(rel);
|
|
filesInTree.push({ fullPath: full, rel, name: e2.name, size: st.size, mtimeMs: st.mtimeMs, type });
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
};
|
|
walkWithPaths(streamerRoot, '');
|
|
|
|
if (filesInTree.length === 0) continue;
|
|
result.totalScanned += filesInTree.length;
|
|
|
|
// Build a quick lookup so a recording file can attach its sibling
|
|
// .chat.* and .events.jsonl by stripping the .mp4/.mkv extension.
|
|
const companionByBase = new Map<string, { chat: string | null; events: string | null }>();
|
|
for (const f of filesInTree) {
|
|
if (f.type !== 'chat' && f.type !== 'events') continue;
|
|
// Strip companion suffix to get the base name shared with the
|
|
// recording: foo.mp4 + foo.chat.jsonl + foo.events.jsonl.
|
|
const base = f.fullPath.replace(/\.chat\.jsonl?$/i, '').replace(/\.events\.jsonl$/i, '');
|
|
const existing = companionByBase.get(base) || { chat: null, events: null };
|
|
if (f.type === 'chat') existing.chat = f.fullPath;
|
|
else if (f.type === 'events') existing.events = f.fullPath;
|
|
companionByBase.set(base, existing);
|
|
}
|
|
|
|
for (const f of filesInTree) {
|
|
// We only surface recordings (live/vod) as search hits — chat
|
|
// and events files attach as companions and don't appear as
|
|
// standalone rows. Users searching for chat usually want the
|
|
// recording it belongs to anyway.
|
|
if (f.type !== 'live' && f.type !== 'vod') continue;
|
|
if (!matchSearchFilter(streamerFolder, f.rel, f.name, f.size, f.mtimeMs, f.type, filter)) continue;
|
|
|
|
const recordingBase = f.fullPath.replace(/\.(mp4|mkv|ts|m4v)$/i, '');
|
|
const companions = companionByBase.get(recordingBase) || { chat: null, events: null };
|
|
|
|
result.hits.push({
|
|
fullPath: f.fullPath,
|
|
fileName: f.name,
|
|
streamer: streamerFolder,
|
|
type: f.type,
|
|
size: f.size,
|
|
mtimeMs: f.mtimeMs,
|
|
chatPath: companions.chat,
|
|
eventsPath: companions.events
|
|
});
|
|
result.matchCount++;
|
|
}
|
|
}
|
|
|
|
// Sort then truncate. We sort the FULL match set (not the truncated
|
|
// one) so the user gets the genuinely largest/newest results, not
|
|
// arbitrary order.
|
|
const cmp: Record<typeof filter.sort, (a: ArchiveSearchHit, b: ArchiveSearchHit) => number> = {
|
|
date_desc: (a, b) => b.mtimeMs - a.mtimeMs,
|
|
date_asc: (a, b) => a.mtimeMs - b.mtimeMs,
|
|
size_desc: (a, b) => b.size - a.size,
|
|
size_asc: (a, b) => a.size - b.size,
|
|
name_asc: (a, b) => a.fileName.localeCompare(b.fileName)
|
|
};
|
|
result.hits.sort(cmp[filter.sort] || cmp.date_desc);
|
|
if (result.hits.length > maxHits) {
|
|
result.truncated = true;
|
|
result.hits = result.hits.slice(0, maxHits);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function computeArchiveStats(): ArchiveStats {
|
|
const root = config.download_path;
|
|
const stats: ArchiveStats = {
|
|
totalFiles: 0,
|
|
totalBytes: 0,
|
|
liveCount: 0,
|
|
liveBytes: 0,
|
|
vodCount: 0,
|
|
vodBytes: 0,
|
|
chatCount: 0,
|
|
chatBytes: 0,
|
|
eventsCount: 0,
|
|
streamerCount: 0,
|
|
avgRecordingSizeBytes: 0,
|
|
topStreamers: [],
|
|
dailyActivity: [],
|
|
sizeBuckets: SIZE_BUCKETS.map((b) => ({ label: b.label, count: 0, bytes: 0 })),
|
|
scannedAt: new Date().toISOString(),
|
|
downloadPath: root || '',
|
|
rootExists: false
|
|
};
|
|
if (!root || !fs.existsSync(root)) return stats;
|
|
stats.rootExists = true;
|
|
|
|
let topEntries: fs.Dirent[];
|
|
try {
|
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return stats;
|
|
}
|
|
|
|
const perStreamer = new Map<string, ArchiveStatsTopStreamer>();
|
|
const dailyMap = new Map<string, ArchiveStatsDay>();
|
|
let recordingCount = 0;
|
|
let recordingBytes = 0;
|
|
|
|
for (const entry of topEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const streamerFolder = entry.name;
|
|
const full = path.join(root, streamerFolder);
|
|
const accum: { files: ArchiveFileRecord[] } = { files: [] };
|
|
walkForArchiveStats(full, '', accum);
|
|
if (accum.files.length === 0) continue;
|
|
|
|
const ts: ArchiveStatsTopStreamer = {
|
|
streamer: streamerFolder,
|
|
bytes: 0,
|
|
fileCount: 0,
|
|
liveBytes: 0,
|
|
vodBytes: 0,
|
|
chatBytes: 0
|
|
};
|
|
|
|
for (const f of accum.files) {
|
|
stats.totalFiles++;
|
|
stats.totalBytes += f.size;
|
|
ts.fileCount++;
|
|
ts.bytes += f.size;
|
|
|
|
if (f.type === 'live') {
|
|
stats.liveCount++;
|
|
stats.liveBytes += f.size;
|
|
ts.liveBytes += f.size;
|
|
recordingCount++;
|
|
recordingBytes += f.size;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].count++;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size;
|
|
} else if (f.type === 'vod') {
|
|
stats.vodCount++;
|
|
stats.vodBytes += f.size;
|
|
ts.vodBytes += f.size;
|
|
recordingCount++;
|
|
recordingBytes += f.size;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].count++;
|
|
stats.sizeBuckets[bucketIndexForSize(f.size)].bytes += f.size;
|
|
} else if (f.type === 'chat') {
|
|
stats.chatCount++;
|
|
stats.chatBytes += f.size;
|
|
ts.chatBytes += f.size;
|
|
} else if (f.type === 'events') {
|
|
stats.eventsCount++;
|
|
}
|
|
|
|
if (f.type === 'live' || f.type === 'vod') {
|
|
const cur = dailyMap.get(f.date) || { date: f.date, count: 0, bytes: 0 };
|
|
cur.count++;
|
|
cur.bytes += f.size;
|
|
dailyMap.set(f.date, cur);
|
|
}
|
|
}
|
|
|
|
perStreamer.set(streamerFolder, ts);
|
|
}
|
|
|
|
stats.streamerCount = perStreamer.size;
|
|
stats.avgRecordingSizeBytes = recordingCount > 0 ? Math.round(recordingBytes / recordingCount) : 0;
|
|
stats.topStreamers = Array.from(perStreamer.values())
|
|
.sort((a, b) => b.bytes - a.bytes)
|
|
.slice(0, 10);
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const days: ArchiveStatsDay[] = [];
|
|
for (let i = 29; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const key = d.toISOString().slice(0, 10);
|
|
days.push(dailyMap.get(key) || { date: key, count: 0, bytes: 0 });
|
|
}
|
|
stats.dailyActivity = days;
|
|
|
|
return stats;
|
|
}
|
|
|
|
// ==========================================
|
|
// DISCORD WEBHOOK NOTIFICATIONS
|
|
// ==========================================
|
|
// Fire-and-forget webhook for "stream went live", "recording finished",
|
|
// "VOD download complete". Useful when the user runs the app on a
|
|
// dedicated archival machine and isn't checking it directly.
|
|
type DiscordEmbedColor = 'live' | 'success' | 'info';
|
|
const DISCORD_EMBED_COLORS: Record<DiscordEmbedColor, number> = {
|
|
live: 0xE91916, // red — recording started
|
|
success: 0x00C853, // green — completed cleanly
|
|
info: 0x9146FF // twitch purple — neutral
|
|
};
|
|
|
|
function isAcceptableDiscordWebhook(url: string): boolean {
|
|
const trimmed = (url || '').trim();
|
|
if (!trimmed) return false;
|
|
return /^https:\/\/(?:[a-z]+\.)?discord(?:app)?\.com\/api\/webhooks\//i.test(trimmed);
|
|
}
|
|
|
|
async function sendDiscordWebhook(payload: {
|
|
title: string;
|
|
description: string;
|
|
color: DiscordEmbedColor;
|
|
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
}): Promise<void> {
|
|
const url = (config.discord_webhook_url || '').trim();
|
|
if (!isAcceptableDiscordWebhook(url)) return;
|
|
|
|
const body = {
|
|
username: 'Twitch VOD Manager',
|
|
embeds: [
|
|
{
|
|
title: payload.title.slice(0, 256),
|
|
description: payload.description.slice(0, 4096),
|
|
color: DISCORD_EMBED_COLORS[payload.color],
|
|
fields: (payload.fields || []).slice(0, 25).map((f) => ({
|
|
name: (f.name || '').slice(0, 256),
|
|
value: (f.value || '').slice(0, 1024),
|
|
inline: f.inline === true
|
|
})),
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
]
|
|
};
|
|
|
|
try {
|
|
await axios.post(url, body, { timeout: 8000, headers: { 'Content-Type': 'application/json' } });
|
|
appendDebugLog('discord-webhook-ok', { title: payload.title, color: payload.color });
|
|
} catch (e) {
|
|
appendDebugLog('discord-webhook-failed', { title: payload.title, error: String(e) });
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// LIVE RECORDING EVENTS LOG
|
|
// ==========================================
|
|
// Sibling .events.jsonl file alongside each live recording. Tracks
|
|
// recording start/end + Twitch metadata changes (title / game) that
|
|
// happen while the stream is being captured. Useful when seeking
|
|
// inside a long archived stream — tells you "at minute 142 he switched
|
|
// from Just Chatting to Counter-Strike". Independent of chat capture
|
|
// (lives even if capture_live_chat is off) and uses JSON Lines for
|
|
// the same crash-safety reason.
|
|
interface LiveEventTracker {
|
|
itemId: string;
|
|
streamer: string;
|
|
eventsPath: string;
|
|
fileHandle: number | null;
|
|
startedAt: number; // Date.now() when recording started
|
|
lastTitle: string;
|
|
lastGame: string;
|
|
closing: boolean;
|
|
}
|
|
|
|
const liveEventTrackers = new Map<string, LiveEventTracker>();
|
|
let liveEventsPollTimer: NodeJS.Timeout | null = null;
|
|
|
|
function eventsLogPathFor(videoPath: string): string {
|
|
const ext = path.extname(videoPath);
|
|
const base = ext ? videoPath.slice(0, -ext.length) : videoPath;
|
|
return `${base}.events.jsonl`;
|
|
}
|
|
|
|
function appendEventLine(tracker: LiveEventTracker, payload: Record<string, unknown>): void {
|
|
if (tracker.fileHandle === null) return;
|
|
const line = JSON.stringify({ t: new Date().toISOString(), ...payload }) + '\n';
|
|
try {
|
|
fs.writeSync(tracker.fileHandle, line);
|
|
} catch (e) {
|
|
appendDebugLog('events-log-write-failed', { itemId: tracker.itemId, error: String(e) });
|
|
}
|
|
}
|
|
|
|
function startLiveEventsTracker(itemId: string, streamer: string, videoPath: string, initialTitle: string, initialGame: string): LiveEventTracker | null {
|
|
const eventsPath = eventsLogPathFor(videoPath);
|
|
let fd: number;
|
|
try {
|
|
fd = fs.openSync(eventsPath, 'w');
|
|
} catch (e) {
|
|
appendDebugLog('events-log-open-failed', { itemId, eventsPath, error: String(e) });
|
|
return null;
|
|
}
|
|
|
|
const tracker: LiveEventTracker = {
|
|
itemId,
|
|
streamer,
|
|
eventsPath,
|
|
fileHandle: fd,
|
|
startedAt: Date.now(),
|
|
lastTitle: initialTitle,
|
|
lastGame: initialGame,
|
|
closing: false
|
|
};
|
|
|
|
appendEventLine(tracker, {
|
|
type: 'recording_start',
|
|
streamer,
|
|
title: initialTitle,
|
|
game: initialGame
|
|
});
|
|
|
|
liveEventTrackers.set(itemId, tracker);
|
|
ensureLiveEventsPollTimer();
|
|
return tracker;
|
|
}
|
|
|
|
function stopLiveEventsTracker(itemId: string, finalNote?: { success: boolean; durationMs: number; error?: string }): void {
|
|
const tracker = liveEventTrackers.get(itemId);
|
|
if (!tracker || tracker.closing) return;
|
|
tracker.closing = true;
|
|
|
|
appendEventLine(tracker, {
|
|
type: 'recording_end',
|
|
durationSeconds: finalNote ? Math.floor(finalNote.durationMs / 1000) : Math.floor((Date.now() - tracker.startedAt) / 1000),
|
|
success: finalNote?.success === true,
|
|
error: finalNote?.error || ''
|
|
});
|
|
|
|
if (tracker.fileHandle !== null) {
|
|
try { fs.closeSync(tracker.fileHandle); } catch { /* ignore */ }
|
|
tracker.fileHandle = null;
|
|
}
|
|
liveEventTrackers.delete(itemId);
|
|
|
|
if (liveEventTrackers.size === 0 && liveEventsPollTimer) {
|
|
clearInterval(liveEventsPollTimer);
|
|
liveEventsPollTimer = null;
|
|
}
|
|
}
|
|
|
|
function ensureLiveEventsPollTimer(): void {
|
|
if (liveEventsPollTimer) return;
|
|
// Same cadence as auto-record polling; metadata changes don't need
|
|
// sub-minute resolution and we want to keep API load bounded.
|
|
liveEventsPollTimer = setInterval(() => { void pollLiveEventsForChanges(); }, 60 * 1000);
|
|
liveEventsPollTimer.unref?.();
|
|
}
|
|
|
|
async function pollLiveEventsForChanges(): Promise<void> {
|
|
if (liveEventTrackers.size === 0) return;
|
|
for (const tracker of liveEventTrackers.values()) {
|
|
if (tracker.closing) continue;
|
|
const info = await getLiveStreamInfo(tracker.streamer);
|
|
if (!info || !info.isLive) continue;
|
|
const currentTitle = info.title || '';
|
|
const currentGame = info.gameName || '';
|
|
|
|
if (currentTitle !== tracker.lastTitle) {
|
|
appendEventLine(tracker, {
|
|
type: 'title_change',
|
|
from: tracker.lastTitle,
|
|
to: currentTitle
|
|
});
|
|
tracker.lastTitle = currentTitle;
|
|
}
|
|
if (currentGame !== tracker.lastGame) {
|
|
appendEventLine(tracker, {
|
|
type: 'game_change',
|
|
from: tracker.lastGame,
|
|
to: currentGame
|
|
});
|
|
tracker.lastGame = currentGame;
|
|
// Also fire a webhook ping if the user wants it. Game changes
|
|
// matter more than title micro-tweaks, so we only ping for game.
|
|
if (config.discord_notify_live_start) {
|
|
void sendDiscordWebhook({
|
|
title: `Game change: ${tracker.streamer}`,
|
|
description: `Now playing **${currentGame || 'unknown'}**`,
|
|
color: 'info',
|
|
fields: [
|
|
{ name: 'Title', value: currentTitle || '-', inline: false }
|
|
]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// LIVE CHAT CAPTURE (during live recording)
|
|
// ==========================================
|
|
// Companion to fetchVodChatReplay: while a stream is being recorded live,
|
|
// open an anonymous IRC connection to Twitch chat and append every message
|
|
// to a sibling .chat.jsonl file. Format is JSON Lines (one JSON object per
|
|
// line) so a partial / killed write still parses correctly — important
|
|
// because live recordings can run for many hours and we don't want to
|
|
// keep the full chat in memory.
|
|
interface LiveChatSession {
|
|
streamer: string;
|
|
outputPath: string;
|
|
socket: TLSSocket;
|
|
fileHandle: number | null;
|
|
closing: boolean;
|
|
messageCount: number;
|
|
buffer: string;
|
|
}
|
|
|
|
const TWITCH_IRC_HOST = 'irc.chat.twitch.tv';
|
|
const TWITCH_IRC_PORT = 6697;
|
|
|
|
function liveChatPathFor(videoPath: string): string {
|
|
const ext = path.extname(videoPath);
|
|
const base = ext ? videoPath.slice(0, -ext.length) : videoPath;
|
|
return `${base}.chat.jsonl`;
|
|
}
|
|
|
|
function startLiveChatCapture(streamer: string, outputPath: string): LiveChatSession | null {
|
|
const channelName = normalizeLogin(streamer);
|
|
if (!channelName) return null;
|
|
|
|
let fd: number;
|
|
try {
|
|
fd = fs.openSync(outputPath, 'w');
|
|
} catch (e) {
|
|
appendDebugLog('chat-capture-open-failed', { streamer: channelName, outputPath, error: String(e) });
|
|
return null;
|
|
}
|
|
|
|
const session: LiveChatSession = {
|
|
streamer: channelName,
|
|
outputPath,
|
|
socket: tlsConnect({ host: TWITCH_IRC_HOST, port: TWITCH_IRC_PORT, servername: TWITCH_IRC_HOST }),
|
|
fileHandle: fd,
|
|
closing: false,
|
|
messageCount: 0,
|
|
buffer: ''
|
|
};
|
|
|
|
// Write a header line so the file is self-describing even if zero
|
|
// messages arrive (e.g. silent stream, immediate disconnect).
|
|
const header = {
|
|
type: 'header',
|
|
streamer: channelName,
|
|
startedAt: new Date().toISOString(),
|
|
format: 'twitch-vod-manager-chat-jsonl-v1'
|
|
};
|
|
try { fs.writeSync(fd, JSON.stringify(header) + '\n'); } catch { /* ignore */ }
|
|
|
|
session.socket.on('secureConnect', () => {
|
|
// Anonymous Twitch IRC: any nick prefixed with "justinfan" is
|
|
// accepted without a password. Random suffix avoids collisions.
|
|
const nick = `justinfan${Math.floor(Math.random() * 100000)}`;
|
|
try {
|
|
session.socket.write('CAP REQ :twitch.tv/tags twitch.tv/commands\r\n');
|
|
session.socket.write(`NICK ${nick}\r\n`);
|
|
session.socket.write(`JOIN #${channelName}\r\n`);
|
|
} catch (e) {
|
|
appendDebugLog('chat-capture-handshake-failed', { streamer: channelName, error: String(e) });
|
|
}
|
|
appendDebugLog('chat-capture-connected', { streamer: channelName, nick });
|
|
});
|
|
|
|
session.socket.on('data', (chunk: Buffer) => {
|
|
session.buffer += chunk.toString('utf-8');
|
|
const lines = session.buffer.split('\r\n');
|
|
session.buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
|
handleIrcLine(session, line);
|
|
}
|
|
});
|
|
|
|
session.socket.on('error', (err: Error) => {
|
|
appendDebugLog('chat-capture-socket-error', { streamer: channelName, error: String(err) });
|
|
});
|
|
|
|
session.socket.on('close', () => {
|
|
if (!session.closing) {
|
|
appendDebugLog('chat-capture-disconnected', { streamer: channelName, messages: session.messageCount });
|
|
}
|
|
if (session.fileHandle !== null) {
|
|
try { fs.closeSync(session.fileHandle); } catch { /* ignore */ }
|
|
session.fileHandle = null;
|
|
}
|
|
});
|
|
|
|
return session;
|
|
}
|
|
|
|
function handleIrcLine(session: LiveChatSession, line: string): void {
|
|
if (!line) return;
|
|
if (line.startsWith('PING')) {
|
|
try { session.socket.write('PONG' + line.slice(4) + '\r\n'); } catch { /* ignore */ }
|
|
return;
|
|
}
|
|
|
|
let rest = line;
|
|
let tagsStr = '';
|
|
if (rest.startsWith('@')) {
|
|
const sp = rest.indexOf(' ');
|
|
if (sp < 0) return;
|
|
tagsStr = rest.slice(1, sp);
|
|
rest = rest.slice(sp + 1);
|
|
}
|
|
let prefix = '';
|
|
if (rest.startsWith(':')) {
|
|
const sp = rest.indexOf(' ');
|
|
if (sp < 0) return;
|
|
prefix = rest.slice(1, sp);
|
|
rest = rest.slice(sp + 1);
|
|
}
|
|
const cmdSp = rest.indexOf(' ');
|
|
const command = cmdSp < 0 ? rest : rest.slice(0, cmdSp);
|
|
const params = cmdSp < 0 ? '' : rest.slice(cmdSp + 1);
|
|
|
|
if (command !== 'PRIVMSG' && command !== 'USERNOTICE' && command !== 'CLEARCHAT' && command !== 'CLEARMSG') return;
|
|
|
|
const colonIdx = params.indexOf(' :');
|
|
const text = colonIdx >= 0 ? params.slice(colonIdx + 2) : '';
|
|
|
|
const tags: Record<string, string> = {};
|
|
if (tagsStr) {
|
|
for (const pair of tagsStr.split(';')) {
|
|
const eq = pair.indexOf('=');
|
|
if (eq < 0) continue;
|
|
tags[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
}
|
|
}
|
|
|
|
const login = (prefix.split('!')[0] || tags['login'] || '').toLowerCase();
|
|
const message = {
|
|
t: new Date().toISOString(),
|
|
type: command === 'PRIVMSG' ? 'msg' : (command === 'USERNOTICE' ? 'notice' : command.toLowerCase()),
|
|
u: tags['display-name'] || login,
|
|
login,
|
|
color: tags['color'] || '',
|
|
msg: text,
|
|
badges: tags['badges'] || '',
|
|
bits: tags['bits'] || '',
|
|
msgId: tags['msg-id'] || '',
|
|
systemMsg: (tags['system-msg'] || '').replace(/\\s/g, ' ')
|
|
};
|
|
|
|
if (session.fileHandle === null) return;
|
|
try {
|
|
fs.writeSync(session.fileHandle, JSON.stringify(message) + '\n');
|
|
session.messageCount++;
|
|
} catch (e) {
|
|
appendDebugLog('chat-capture-write-failed', { error: String(e) });
|
|
}
|
|
}
|
|
|
|
function stopLiveChatCapture(session: LiveChatSession): void {
|
|
if (session.closing) return;
|
|
session.closing = true;
|
|
appendDebugLog('chat-capture-stopping', { streamer: session.streamer, messages: session.messageCount });
|
|
try { session.socket.write(`PART #${session.streamer}\r\nQUIT\r\n`); } catch { /* ignore */ }
|
|
try { session.socket.end(); } catch { /* ignore */ }
|
|
setTimeout(() => {
|
|
try { session.socket.destroy(); } catch { /* ignore */ }
|
|
}, 500);
|
|
}
|
|
|
|
async function downloadLiveStream(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
const streamlinkReady = await ensureStreamlinkInstalled();
|
|
if (!streamlinkReady) {
|
|
return { success: false, error: tBackend('streamlinkAutoInstallFailed') };
|
|
}
|
|
|
|
onProgress({
|
|
id: item.id,
|
|
progress: -1,
|
|
speed: '',
|
|
eta: '',
|
|
status: tBackend('statusDownloadStarted'),
|
|
currentPart: 0,
|
|
totalParts: 0
|
|
});
|
|
|
|
const safeStreamer = (item.streamer || 'live').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const now = new Date();
|
|
const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`;
|
|
const timeStr = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`;
|
|
const folder = path.join(config.download_path, safeStreamer, 'live');
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const baseFilename = ensureUniqueFilename(
|
|
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
|
|
item.id
|
|
);
|
|
|
|
// Optional: anonymous IRC chat capture for the duration of the
|
|
// recording. Sibling .chat.jsonl file. We start it BEFORE streamlink
|
|
// so the very first chat lines after JOIN aren't dropped, and stop it
|
|
// AFTER streamlink exits so trailing messages (e.g. "stream offline"
|
|
// user reactions) are still captured. Chat + events span the whole
|
|
// multi-part recording (chat is an independent IRC connection, events
|
|
// is an independent poller), so they stay alive across resume cycles.
|
|
let chatSession: LiveChatSession | null = null;
|
|
if (config.capture_live_chat) {
|
|
const chatPath = liveChatPathFor(baseFilename);
|
|
chatSession = startLiveChatCapture(item.streamer, chatPath);
|
|
}
|
|
|
|
let eventsTracker: LiveEventTracker | null = null;
|
|
if (config.log_stream_events) {
|
|
let initialTitle = '';
|
|
let initialGame = '';
|
|
try {
|
|
const info = await getLiveStreamInfo(item.streamer);
|
|
if (info) {
|
|
initialTitle = info.title || '';
|
|
initialGame = info.gameName || '';
|
|
}
|
|
} catch { /* ignore */ }
|
|
eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame);
|
|
}
|
|
|
|
if (config.discord_notify_live_start) {
|
|
void sendDiscordWebhook({
|
|
title: `Recording started: ${item.streamer}`,
|
|
description: item.title || `${item.streamer} is live`,
|
|
color: 'live',
|
|
fields: [
|
|
{ name: 'URL', value: item.url, inline: false },
|
|
{ name: 'Output', value: path.basename(baseFilename), inline: false }
|
|
]
|
|
});
|
|
}
|
|
|
|
const recordingStartedAt = Date.now();
|
|
const BYTES_FRESH_MS = 30_000;
|
|
const MIN_HEALTHY_PART_MS = 30_000;
|
|
const RESUME_WAIT_MS = 10_000;
|
|
const MAX_RESUME_ATTEMPTS = 5;
|
|
|
|
// Total-recording byte tracking. Each resumed part starts streamlink
|
|
// fresh, so its byte counter resets to 0; we keep accumulatedBytes
|
|
// across parts so the meta line shows the TOTAL recorded size, not
|
|
// just the current part. Same for elapsed — recordingStartedAt is the
|
|
// overall start, not per-part.
|
|
let accumulatedBytes = 0;
|
|
let currentPartBytes = 0;
|
|
let lastBytesValue = 0;
|
|
let lastBytesAdvancedAt = 0;
|
|
let lastEmittedProgress: DownloadProgress | null = null;
|
|
|
|
const computeHealth = (): 'ok' | 'stale' | 'unknown' => {
|
|
if (lastBytesAdvancedAt === 0) return 'unknown';
|
|
return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale';
|
|
};
|
|
|
|
const wrappedProgress = (p: DownloadProgress): void => {
|
|
const bytes = Number(p.downloadedBytes) || 0;
|
|
if (bytes > lastBytesValue) {
|
|
lastBytesValue = bytes;
|
|
lastBytesAdvancedAt = Date.now();
|
|
}
|
|
currentPartBytes = bytes;
|
|
const totalBytes = accumulatedBytes + currentPartBytes;
|
|
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
|
const avgBitrateMbps = (totalBytes * 8) / elapsed / 1_000_000;
|
|
const parts: string[] = [formatDuration(elapsed)];
|
|
if (totalBytes > 0) parts.push(formatBytes(totalBytes));
|
|
if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
|
|
const next = {
|
|
...p,
|
|
speed: '',
|
|
eta: '',
|
|
status: parts.join(' · '),
|
|
recordingHealth: computeHealth()
|
|
};
|
|
lastEmittedProgress = next;
|
|
onProgress(next);
|
|
};
|
|
|
|
// Health-tick: re-emit the most recent progress every 10s so the
|
|
// renderer's health badge updates even when streamlink is silent.
|
|
// Without this, a streamlink hung on a buffer-stall would keep showing
|
|
// 'ok' until the next real byte event.
|
|
const healthTick = setInterval(() => {
|
|
if (!lastEmittedProgress) return;
|
|
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
|
|
lastEmittedProgress = updated;
|
|
onProgress(updated);
|
|
}, 10_000);
|
|
healthTick.unref?.();
|
|
|
|
const outputs: string[] = [];
|
|
let partNumber = 1;
|
|
let resumeCount = 0;
|
|
let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
|
|
|
try {
|
|
// Resume loop. Each iteration runs streamlink once. On clean exit,
|
|
// we re-check whether the stream is still live on Twitch's side;
|
|
// if yes, the exit was an interruption (network blip, segment
|
|
// discontinuity, etc.) — start a new part and append. If the
|
|
// stream really ended, break and finalize.
|
|
while (true) {
|
|
const partFilename = partNumber === 1
|
|
? baseFilename
|
|
: ensureUniqueFilename(
|
|
baseFilename.replace(/\.mp4$/i, `_part${partNumber}.mp4`),
|
|
item.id
|
|
);
|
|
|
|
// Reset per-part counters — streamlink is fresh, byte counter
|
|
// restarts at zero. lastBytesAdvancedAt stays at zero until
|
|
// the first segment arrives, which correctly flips the health
|
|
// dot to 'unknown' during the resume gap.
|
|
lastBytesValue = 0;
|
|
lastBytesAdvancedAt = 0;
|
|
currentPartBytes = 0;
|
|
|
|
const partStartedAt = Date.now();
|
|
appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) });
|
|
|
|
lastPartResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber);
|
|
|
|
// Accumulate this part's final bytes into the running total so
|
|
// the next part's meta line continues from the correct figure.
|
|
let partFinalBytes = 0;
|
|
if (fs.existsSync(partFilename)) {
|
|
try {
|
|
partFinalBytes = fs.statSync(partFilename).size || 0;
|
|
} catch { /* ignore */ }
|
|
}
|
|
if (partFinalBytes > 0) {
|
|
outputs.push(partFilename);
|
|
accumulatedBytes += partFinalBytes;
|
|
} else {
|
|
// Streamlink produced no bytes — likely permission or auth
|
|
// failure. Skip resume because retrying will hit the same
|
|
// wall. The error from lastPartResult will surface upstream.
|
|
appendDebugLog('recording-part-zero-bytes', { itemId: item.id, partNumber });
|
|
break;
|
|
}
|
|
|
|
// Resume decision tree.
|
|
if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) {
|
|
appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' });
|
|
break;
|
|
}
|
|
if (!config.auto_resume_live_recording) {
|
|
appendDebugLog('recording-resume-disabled', { itemId: item.id });
|
|
break;
|
|
}
|
|
if (resumeCount >= MAX_RESUME_ATTEMPTS) {
|
|
appendDebugLog('recording-resume-max-attempts', { itemId: item.id, max: MAX_RESUME_ATTEMPTS });
|
|
break;
|
|
}
|
|
// Don't resume on suspiciously short parts — that pattern points
|
|
// at a config issue (bad URL, auth-required stream, streamlink
|
|
// missing plugin) where retrying will just loop and burn API
|
|
// quota.
|
|
const partDurationMs = Date.now() - partStartedAt;
|
|
if (partDurationMs < MIN_HEALTHY_PART_MS) {
|
|
appendDebugLog('recording-resume-skip-short', { itemId: item.id, partNumber, durationMs: partDurationMs });
|
|
break;
|
|
}
|
|
|
|
// Only resume if Twitch still says the stream is live. If the
|
|
// streamer actually ended their broadcast, we accept the part
|
|
// we have and call the recording done.
|
|
let stillLive = false;
|
|
try {
|
|
const info = await getLiveStreamInfo(item.streamer);
|
|
stillLive = info?.isLive === true;
|
|
} catch {
|
|
// Unknown liveness — err on the side of NOT resuming to
|
|
// avoid infinite-loop on network-out conditions where we
|
|
// can't even reach Twitch to check. The user can always
|
|
// restart manually.
|
|
stillLive = false;
|
|
}
|
|
if (!stillLive) {
|
|
appendDebugLog('recording-finished-stream-offline', { itemId: item.id, parts: partNumber });
|
|
break;
|
|
}
|
|
|
|
appendDebugLog('recording-resume-attempt', { itemId: item.id, previousPart: partNumber, attempt: resumeCount + 1 });
|
|
if (eventsTracker) {
|
|
appendEventLine(eventsTracker, { type: 'recording_resume', part: partNumber + 1 });
|
|
}
|
|
resumeCount++;
|
|
partNumber++;
|
|
await sleep(RESUME_WAIT_MS);
|
|
}
|
|
} finally {
|
|
clearInterval(healthTick);
|
|
}
|
|
|
|
if (chatSession) {
|
|
stopLiveChatCapture(chatSession);
|
|
}
|
|
if (eventsTracker) {
|
|
stopLiveEventsTracker(item.id, {
|
|
success: outputs.length > 0,
|
|
durationMs: Date.now() - recordingStartedAt,
|
|
error: outputs.length === 0 ? lastPartResult.error : undefined
|
|
});
|
|
}
|
|
|
|
if (config.discord_notify_live_end) {
|
|
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
|
const sizeBytes = accumulatedBytes;
|
|
const success = outputs.length > 0;
|
|
void sendDiscordWebhook({
|
|
title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
|
|
description: item.title || `${item.streamer}`,
|
|
color: success ? 'success' : 'info',
|
|
fields: [
|
|
{ name: 'Duration', value: formatDuration(durationSec), inline: true },
|
|
{ name: 'Size', value: formatBytes(sizeBytes), inline: true },
|
|
{ name: 'Parts', value: String(outputs.length || 1), inline: true },
|
|
{ name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true },
|
|
{ name: 'Output', value: path.basename(baseFilename), inline: false }
|
|
]
|
|
});
|
|
}
|
|
|
|
if (outputs.length === 0) return lastPartResult;
|
|
|
|
// Auto-merge resumed parts. Only attempt when (a) the user opted in,
|
|
// (b) there's actually something to merge, and (c) the parts are all
|
|
// present on disk. Failure is non-fatal — we keep the parts so the
|
|
// user still has working files even if ffmpeg trips on a corrupted
|
|
// segment header.
|
|
let finalRecordings = outputs.slice();
|
|
if (config.auto_merge_resumed_parts && outputs.length > 1) {
|
|
const mergedOutput = ensureUniqueFilename(
|
|
baseFilename.replace(/\.mp4$/i, '_merged.mp4'),
|
|
item.id
|
|
);
|
|
const mergeOk = await concatVideoFiles(outputs, mergedOutput);
|
|
if (mergeOk) {
|
|
if (config.delete_parts_after_merge) {
|
|
for (const partPath of outputs) {
|
|
try { fs.unlinkSync(partPath); } catch (e) {
|
|
appendDebugLog('merge-part-delete-failed', { path: partPath, error: String(e) });
|
|
}
|
|
}
|
|
finalRecordings = [mergedOutput];
|
|
} else {
|
|
finalRecordings = [mergedOutput, ...outputs];
|
|
}
|
|
appendDebugLog('merge-resumed-parts-ok', { merged: mergedOutput, partsKept: !config.delete_parts_after_merge });
|
|
} else {
|
|
appendDebugLog('merge-resumed-parts-failed-keeping-parts');
|
|
}
|
|
}
|
|
|
|
if (chatSession && fs.existsSync(chatSession.outputPath)) {
|
|
finalRecordings.push(chatSession.outputPath);
|
|
}
|
|
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
|
|
finalRecordings.push(eventsTracker.eventsPath);
|
|
}
|
|
return { success: true, outputFiles: finalRecordings };
|
|
}
|
|
|
|
async function downloadVOD(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
// Live-recording branch: URL is the channel page, no VOD id, no time
|
|
// window. Streamlink runs until the stream ends, then we treat the
|
|
// whole capture as a single output file.
|
|
if (item.isLive) {
|
|
return await downloadLiveStream(item, onProgress);
|
|
}
|
|
|
|
const vodId = parseVodId(item.url);
|
|
if (!isLikelyVodUrl(item.url) || !vodId) {
|
|
return {
|
|
success: false,
|
|
error: tBackend('invalidVodUrl')
|
|
};
|
|
}
|
|
|
|
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', () => {
|
|
appendDebugLog('auto-updater-checking');
|
|
mainWindow?.webContents.send('update-checking');
|
|
});
|
|
|
|
autoUpdater.on('update-available', (info) => {
|
|
const incomingVersion = normalizeUpdateVersion(info.version);
|
|
const displayVersion = incomingVersion || info.version;
|
|
|
|
if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) {
|
|
appendDebugLog('update-available-ignored-older', {
|
|
incomingVersion: displayVersion,
|
|
knownVersion: latestKnownUpdateVersion
|
|
});
|
|
return;
|
|
}
|
|
|
|
latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion;
|
|
|
|
const hasAlreadyDownloadedThisVersion = Boolean(
|
|
autoUpdateReadyToInstall &&
|
|
downloadedUpdateVersion &&
|
|
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
|
|
);
|
|
|
|
appendDebugLog('auto-updater-update-available', { version: 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', () => {
|
|
appendDebugLog('auto-updater-update-not-available');
|
|
mainWindow?.webContents.send('update-not-available');
|
|
});
|
|
|
|
autoUpdater.on('download-progress', (progress) => {
|
|
// No per-tick stdout — the autoUpdater fires this ~10x/sec during
|
|
// an in-flight download. The renderer banner is the user-visible
|
|
// surface; appendDebugLog already captures phase transitions.
|
|
if (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;
|
|
appendDebugLog('auto-updater-update-downloaded', { version: 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('get-automation-status', () => ({
|
|
autoRecord: {
|
|
watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0,
|
|
lastRunAt: autoRecordLastRunAt,
|
|
nextRunAt: autoRecordNextRunAt,
|
|
lastTriggeredCount: autoRecordLastTriggerCount,
|
|
inFlight: autoRecordPollInFlight
|
|
},
|
|
autoVod: {
|
|
watching: Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers.length : 0,
|
|
lastRunAt: autoVodLastRunAt,
|
|
nextRunAt: autoVodNextRunAt,
|
|
lastQueuedCount: autoVodLastQueuedCount,
|
|
inFlight: autoVodPollInFlight
|
|
}
|
|
}));
|
|
|
|
ipcMain.handle('trigger-auto-record-scan', async () => {
|
|
const triggered = await runAutoRecordPoll();
|
|
return { triggered };
|
|
});
|
|
|
|
ipcMain.handle('trigger-auto-vod-scan', async () => {
|
|
const queuedCount = await runAutoVodPoll();
|
|
return { queuedCount };
|
|
});
|
|
|
|
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|
const previousClientId = config.client_id;
|
|
const previousClientSecret = config.client_secret;
|
|
const previousCacheMinutes = config.metadata_cache_minutes;
|
|
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
|
const previousTheme = config.theme;
|
|
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
|
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
|
|
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
|
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
|
|
const previousStreamerList = JSON.stringify(config.streamers || []);
|
|
|
|
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
|
|
|
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
|
|
accessToken = null;
|
|
twitchLoginInFlight = null;
|
|
}
|
|
|
|
if (config.metadata_cache_minutes !== previousCacheMinutes) {
|
|
clearMetadataCaches();
|
|
}
|
|
|
|
if (config.theme !== previousTheme) {
|
|
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
|
}
|
|
|
|
saveConfig(config);
|
|
|
|
if (config.persist_queue_on_restart === false) {
|
|
pendingQueueSnapshot = null;
|
|
if (queueSaveTimer) {
|
|
clearTimeout(queueSaveTimer);
|
|
queueSaveTimer = null;
|
|
}
|
|
clearQueueFileFromDisk();
|
|
} else if (previousPersistQueueOnRestart === false) {
|
|
saveQueue(downloadQueue, true);
|
|
}
|
|
|
|
// Restart auto-record poller if its inputs changed (added/removed
|
|
// streamers or interval changed). Drop transition state for any
|
|
// streamer no longer being watched so re-enabling them later doesn't
|
|
// suppress an immediate first-poll trigger.
|
|
const newAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
|
if (newAutoRecordList !== previousAutoRecordList || config.auto_record_poll_seconds !== previousAutoRecordSeconds) {
|
|
const watched = new Set(config.auto_record_streamers || []);
|
|
for (const k of Array.from(autoRecordLastLiveState.keys())) {
|
|
if (!watched.has(k)) autoRecordLastLiveState.delete(k);
|
|
}
|
|
restartAutoRecordPoller();
|
|
}
|
|
|
|
// Same dance for the auto-VOD poller — independent cadence from
|
|
// auto-record because VOD listings are heavier to fetch.
|
|
const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
|
if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) {
|
|
restartAutoVodPoller();
|
|
}
|
|
|
|
// Live-status batch poller — fire an immediate refresh when the
|
|
// streamer list itself changes (added/removed) so the sidebar dots
|
|
// update instantly instead of waiting for the next 60s tick.
|
|
const newStreamerList = JSON.stringify(config.streamers || []);
|
|
if (newStreamerList !== previousStreamerList) {
|
|
restartLiveStatusPoller();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
// Extensions that shell.openPath would happily execute via the system
|
|
// default. Calc.exe via XSS smuggling is the canonical example; this
|
|
// list blocks the obvious vectors. Media/text/image extensions are
|
|
// still fine — shell.openPath opens them in the OS's default viewer.
|
|
const OPEN_FILE_BLOCKED_EXTENSIONS = new Set([
|
|
'.exe', '.bat', '.cmd', '.com', '.ps1', '.vbs', '.vbe',
|
|
'.js', '.jse', '.wsf', '.wsh', '.scr', '.msi', '.msp',
|
|
'.lnk', '.cpl', '.reg', '.hta', '.jar', '.application'
|
|
]);
|
|
|
|
ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => {
|
|
if (typeof filePath !== 'string' || !filePath) return false;
|
|
if (!fs.existsSync(filePath)) return false;
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) {
|
|
appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) });
|
|
return false;
|
|
}
|
|
const result = await shell.openPath(filePath);
|
|
// shell.openPath returns '' on success, an error string on failure.
|
|
return result === '';
|
|
});
|
|
|
|
ipcMain.handle('show-in-folder', (_, 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) => {
|
|
// Only allow https / http URLs — never let the renderer push a
|
|
// file://, javascript:, or shell:-style URL through to the OS
|
|
// shell.openExternal handler. The renderer is contextIsolated +
|
|
// nodeIntegration: false, but an XSS through (e.g.) a streamer name
|
|
// smuggling a payload into a template would otherwise hand the
|
|
// attacker shell.openExternal which on Windows happily resolves
|
|
// file:///C:/Windows/System32/calc.exe.
|
|
if (typeof url !== 'string') return;
|
|
const trimmed = url.trim();
|
|
if (!/^https?:\/\//i.test(trimmed)) {
|
|
appendDebugLog('open-external-rejected', { url: trimmed.slice(0, 200) });
|
|
return;
|
|
}
|
|
await shell.openExternal(trimmed);
|
|
});
|
|
|
|
// Tracks active standalone clip downloads so cancel-download / window-all-closed
|
|
// can kill them. Separate from activeDownloads (queue) because clip downloads
|
|
// don't go through the queue scheduler.
|
|
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-archive-stats', (): ArchiveStats => {
|
|
return computeArchiveStats();
|
|
});
|
|
|
|
ipcMain.handle('get-streamer-profile', async (_, login: string, forceRefresh?: boolean): Promise<StreamerProfile | null> => {
|
|
return await getStreamerProfile(login, forceRefresh === true);
|
|
});
|
|
|
|
ipcMain.handle('get-vod-storyboard', async (_, vodId: string): Promise<VodStoryboard | null> => {
|
|
return await getVodStoryboard(vodId);
|
|
});
|
|
|
|
ipcMain.handle('get-live-status-snapshot', (): Record<string, boolean> => {
|
|
const snap: Record<string, boolean> = {};
|
|
for (const [k, v] of liveStatusByLogin.entries()) snap[k] = v;
|
|
return snap;
|
|
});
|
|
|
|
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
|
const normalized: ArchiveSearchFilter = {
|
|
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
|
type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events')
|
|
? filter!.type as 'all' | 'live' | 'vod' | 'chat' | 'events'
|
|
: 'all',
|
|
streamer: typeof filter?.streamer === 'string' ? filter.streamer.trim() : '',
|
|
sinceMs: Number.isFinite(filter?.sinceMs as number) ? Number(filter?.sinceMs) : null,
|
|
untilMs: Number.isFinite(filter?.untilMs as number) ? Number(filter?.untilMs) : null,
|
|
sort: (['date_desc', 'date_asc', 'size_desc', 'size_asc', 'name_asc'] as const).includes(filter?.sort as 'date_desc')
|
|
? filter!.sort as 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'
|
|
: 'date_desc',
|
|
limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200
|
|
};
|
|
return searchArchive(normalized);
|
|
});
|
|
|
|
ipcMain.handle('get-storage-stats', (): StorageStatsResult => {
|
|
return computeStorageStats();
|
|
});
|
|
|
|
ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): CleanupReport => {
|
|
return runStorageCleanup({ dryRun: options?.dryRun === true });
|
|
});
|
|
|
|
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
|
|
// return a normalized message list the renderer can display directly.
|
|
// Caps at 50k messages to stop a runaway file from killing the renderer.
|
|
ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
|
|
if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' };
|
|
if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' };
|
|
|
|
const MAX_MESSAGES = 50000;
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
if (filePath.toLowerCase().endsWith('.jsonl')) {
|
|
// JSON Lines (live chat): one object per line, first line may be header
|
|
const messages: Array<Record<string, unknown>> = [];
|
|
let truncated = false;
|
|
const lines = raw.split('\n');
|
|
let total = 0;
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const obj = JSON.parse(trimmed);
|
|
if (obj && typeof obj === 'object' && obj.type !== 'header') {
|
|
total++;
|
|
if (messages.length < MAX_MESSAGES) messages.push(obj);
|
|
else truncated = true;
|
|
}
|
|
} catch { /* skip bad lines */ }
|
|
}
|
|
return { success: true, format: 'live', messages, truncated, total };
|
|
}
|
|
|
|
// .chat.json (VOD replay) — single object with messages array
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) {
|
|
return { success: false, error: 'Unsupported chat file format' };
|
|
}
|
|
const total = parsed.messages.length;
|
|
const messages = parsed.messages.length > MAX_MESSAGES
|
|
? parsed.messages.slice(0, MAX_MESSAGES)
|
|
: parsed.messages;
|
|
return {
|
|
success: true,
|
|
format: 'replay',
|
|
messages,
|
|
truncated: total > MAX_MESSAGES,
|
|
total
|
|
};
|
|
} catch (e) {
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
|
|
if (typeof folderPath !== 'string' || !folderPath) return false;
|
|
return isDownloadPathWritable(folderPath);
|
|
});
|
|
|
|
ipcMain.handle('is-downloading', () => isDownloading);
|
|
|
|
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
|
|
|
|
ipcMain.handle('export-runtime-metrics', async () => {
|
|
try {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const defaultName = `runtime-metrics-${timestamp}.json`;
|
|
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
|
|
|
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: path.join(preferredDir, defaultName),
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
|
|
if (dialogResult.canceled || !dialogResult.filePath) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
const snapshot = getRuntimeMetricsSnapshot();
|
|
// Atomic write: same fsync+rename pattern used for config/queue
|
|
// (cycle 1) so a power loss mid-export can't leave a half-written
|
|
// metrics file at the user's chosen path.
|
|
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2));
|
|
return { success: true, filePath: dialogResult.filePath };
|
|
} catch (e) {
|
|
appendDebugLog('runtime-metrics-export-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => {
|
|
if (typeof vodId !== 'string' || !vodId) return { success: false };
|
|
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
|
const has = config.downloaded_vod_ids.includes(vodId);
|
|
if (mark && !has) {
|
|
config.downloaded_vod_ids.push(vodId);
|
|
} else if (!mark && has) {
|
|
config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId);
|
|
} else {
|
|
return { success: true };
|
|
}
|
|
saveConfig(config);
|
|
appendDebugLog('mark-vod-downloaded', { vodId, mark });
|
|
return { success: true };
|
|
});
|
|
|
|
ipcMain.handle('reset-downloaded-vod-ids', () => {
|
|
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
|
config.downloaded_vod_ids = [];
|
|
saveConfig(config);
|
|
appendDebugLog('reset-downloaded-vod-ids', { previousCount: count });
|
|
return { success: true, removedCount: count };
|
|
});
|
|
|
|
ipcMain.handle('export-config', async () => {
|
|
try {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const defaultName = `twitch-vod-manager-config-${timestamp}.json`;
|
|
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
|
|
|
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: path.join(preferredDir, defaultName),
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
|
|
if (dialogResult.canceled || !dialogResult.filePath) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
// Strip the secrets from the export — Client Secret should not
|
|
// travel as plain text across machines / cloud sync. The user
|
|
// re-enters it on the new machine after import.
|
|
const exportable = {
|
|
...config,
|
|
client_secret: '',
|
|
__exportVersion: 1,
|
|
__exportedAt: new Date().toISOString()
|
|
};
|
|
writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2));
|
|
return { success: true, filePath: dialogResult.filePath };
|
|
} catch (e) {
|
|
appendDebugLog('config-export-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('import-config', async () => {
|
|
try {
|
|
const dialogResult = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile'],
|
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
|
});
|
|
if (dialogResult.canceled || !dialogResult.filePaths[0]) {
|
|
return { success: false, cancelled: true };
|
|
}
|
|
|
|
const importPath = dialogResult.filePaths[0];
|
|
const raw = fs.readFileSync(importPath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
if (!isPlainObject(parsed)) {
|
|
return { success: false, error: 'Imported file is not a JSON object.' };
|
|
}
|
|
|
|
// Merge over current config so unknown / missing keys keep their
|
|
// existing values. Then run normalizeConfigTemplates so any
|
|
// out-of-range field falls back to defaults.
|
|
const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config);
|
|
|
|
// Preserve the existing client_secret if the import stripped it
|
|
// (export does this on purpose) — the user shouldn't lose creds.
|
|
if (!merged.client_secret && config.client_secret) {
|
|
merged.client_secret = config.client_secret;
|
|
}
|
|
|
|
config = merged;
|
|
saveConfig(config);
|
|
appendDebugLog('config-import-applied', { source: importPath });
|
|
return { success: true, filePath: importPath };
|
|
} catch (e) {
|
|
appendDebugLog('config-import-failed', String(e));
|
|
return { success: false, error: String(e) };
|
|
}
|
|
});
|
|
|
|
// Video Cutter IPC
|
|
ipcMain.handle('get-video-info', async (_, filePath: string) => {
|
|
return await getVideoInfo(filePath);
|
|
});
|
|
|
|
ipcMain.handle('extract-frame', async (_, filePath: string, timeSeconds: number) => {
|
|
return await extractFrame(filePath, timeSeconds);
|
|
});
|
|
|
|
ipcMain.handle('cut-video', async (_, inputFile: string, startTime: number, endTime: number) => {
|
|
const dir = path.dirname(inputFile);
|
|
const baseName = path.basename(inputFile, path.extname(inputFile));
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19);
|
|
const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`);
|
|
|
|
let lastProgress = 0;
|
|
const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => {
|
|
lastProgress = percent;
|
|
mainWindow?.webContents.send('cut-progress', percent);
|
|
});
|
|
|
|
return { success, outputFile: success ? outputFile : null };
|
|
});
|
|
|
|
// Merge IPC
|
|
ipcMain.handle('merge-videos', async (_, inputFiles: string[], outputFile: string) => {
|
|
const success = await mergeVideos(inputFiles, outputFile, (percent) => {
|
|
mainWindow?.webContents.send('merge-progress', percent);
|
|
});
|
|
|
|
return { success, outputFile: success ? outputFile : null };
|
|
});
|
|
|
|
ipcMain.handle('select-multiple-videos', async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile', 'multiSelections'],
|
|
filters: [
|
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
|
]
|
|
});
|
|
return result.filePaths;
|
|
});
|
|
|
|
ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
|
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
|
defaultPath: defaultName,
|
|
filters: [
|
|
{ name: 'MP4 Video', extensions: ['mp4'] }
|
|
]
|
|
});
|
|
return result.filePath || null;
|
|
});
|
|
|
|
// ==========================================
|
|
// APP LIFECYCLE
|
|
// ==========================================
|
|
app.whenReady().then(() => {
|
|
app.setAppUserModelId('com.twitch.vodmanager');
|
|
refreshBundledToolPaths(true);
|
|
startMetadataCacheCleanup();
|
|
startDebugLogFlushTimer();
|
|
|
|
// SQLite-Shadow-Migration (Plan 02 / v5.0.0-alpha.1). Idempotent + fail-soft —
|
|
// bei Fehler bleibt JSON der Master. Lazy require, damit Native-Build-Fehler
|
|
// den App-Start nicht verhindern.
|
|
try {
|
|
const { openDatabase } = require('./main/infra/db');
|
|
const { migrateJsonToSqlite } = require('./main/domain/migrator');
|
|
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
|
const db = openDatabase(dbPath);
|
|
try {
|
|
const result = migrateJsonToSqlite({ db, appDataDir: APPDATA_DIR });
|
|
appendDebugLog('sqlite-migrator', result);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('sqlite-migrator-failed', {
|
|
error: e instanceof Error ? e.message : String(e),
|
|
});
|
|
}
|
|
|
|
restartAutoRecordPoller();
|
|
restartAutoVodPoller();
|
|
restartLiveStatusPoller();
|
|
restartAutoCleanupTimer();
|
|
createWindow();
|
|
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Both window-all-closed and before-quit ran nearly identical cleanup blocks
|
|
// before, with slight drift (only window-all-closed killed children, only
|
|
// window-all-closed did anything platform-specific). Consolidating them into
|
|
// a single idempotent helper means any future tweak (e.g. flushing a new
|
|
// debug stream) lands once and applies on every quit path.
|
|
let shutdownCleanupDone = false;
|
|
|
|
function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
|
if (shutdownCleanupDone) return;
|
|
shutdownCleanupDone = true;
|
|
|
|
appendDebugLog('shutdown-cleanup', { reason });
|
|
|
|
stopMetadataCacheCleanup();
|
|
cleanupMetadataCaches('shutdown');
|
|
stopAutoUpdatePolling();
|
|
stopAutoRecordPoller();
|
|
stopAutoVodPoller();
|
|
stopLiveStatusPoller();
|
|
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');
|
|
});
|