Two real UX wins.
1. Auto-resume queue on startup. New checkbox in Settings -> Download
("Queue beim Start automatisch fortsetzen"). When enabled and the
persisted queue has pending items, processQueue() fires ~5 seconds
after did-finish-load — long enough for the user to see the queue
and pause if they did not actually want this. Default off so the
existing behaviour (explicit Start click) is preserved on upgrade.
The Settings auto-save fingerprint includes the new flag and
syncSettingsFormFromConfig restores it. Tooltip explains the
timing on hover.
2. Already-downloaded indicator on VOD cards. Config gains
downloaded_vod_ids: string[] (bounded to 4096 latest entries).
Every successful queue-item download appends its parsed VOD ID
(or every component ID for merge groups). On the VOD grid each
card whose vod.id is in the set gets a small green checkmark
badge in the top-right plus a slightly dimmed thumbnail, with a
localized "Already downloaded" / "Bereits heruntergeladen"
tooltip. The lookup builds a Set once per render so it stays
O(1) per card. The renderer refreshes its local config copy on
every "newly completed" queue update so the badge appears live
without waiting for a settings save.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4406 lines
155 KiB
TypeScript
4406 lines
155 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 axios from 'axios';
|
|
import { autoUpdater } from 'electron-updater';
|
|
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
|
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
|
import {
|
|
setDebugLogFn, initToolDirs,
|
|
getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath,
|
|
refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled,
|
|
canExecute, canExecuteCommand,
|
|
cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand,
|
|
cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands,
|
|
invalidateVerifiedToolCaches
|
|
} from './tools';
|
|
|
|
// ==========================================
|
|
// CONFIG & CONSTANTS
|
|
// ==========================================
|
|
const APP_VERSION = app.getVersion();
|
|
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
|
const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://git.24-music.de').replace(/\/+$/, '');
|
|
const GITEA_REPO_OWNER = process.env.GITEA_REPO_OWNER || 'Administrator';
|
|
const GITEA_REPO_NAME = process.env.GITEA_REPO_NAME || 'Twitch-VOD-Manager';
|
|
const GITEA_RELEASES_API_LATEST_URL = `${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/latest`;
|
|
const GITEA_RELEASES_DOWNLOAD_BASE_URL = `${GITEA_BASE_URL}/${GITEA_REPO_OWNER}/${GITEA_REPO_NAME}/releases/download`;
|
|
|
|
// Paths
|
|
const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager');
|
|
const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json');
|
|
const QUEUE_FILE = path.join(APPDATA_DIR, 'download_queue.json');
|
|
const DEBUG_LOG_FILE = path.join(APPDATA_DIR, 'debug.log');
|
|
const TOOLS_DIR = path.join(APPDATA_DIR, 'tools');
|
|
const TOOLS_STREAMLINK_DIR = path.join(TOOLS_DIR, 'streamlink');
|
|
const TOOLS_FFMPEG_DIR = path.join(TOOLS_DIR, 'ffmpeg');
|
|
const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
|
|
const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
|
|
const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
|
|
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
|
|
const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
|
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
|
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
|
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
|
|
const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000;
|
|
const DEBUG_LOG_BUFFER_FLUSH_LINES = 48;
|
|
const DEBUG_LOG_READ_TAIL_BYTES = 512 * 1024;
|
|
const DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
|
const DEBUG_LOG_TRIM_TO_BYTES = 4 * 1024 * 1024;
|
|
const AUTO_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
|
const AUTO_UPDATE_STARTUP_CHECK_DELAY_MS = 5000;
|
|
const AUTO_UPDATE_MIN_CHECK_GAP_MS = 45 * 1000;
|
|
const AUTO_UPDATE_AUTO_DOWNLOAD = false;
|
|
const AUTO_UPDATE_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096;
|
|
const MAX_VOD_LIST_CACHE_ENTRIES = 512;
|
|
const MAX_CLIP_INFO_CACHE_ENTRIES = 4096;
|
|
|
|
// Timeouts
|
|
const API_TIMEOUT = 10000;
|
|
const DEFAULT_RETRY_DELAY_SECONDS = 5;
|
|
const MIN_FILE_BYTES = 256 * 1024;
|
|
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
|
|
|
type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
|
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
|
type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
|
type UpdateDownloadSource = 'auto' | 'manual';
|
|
|
|
function getMergeGroupPhaseText(phase: string): string {
|
|
const isEnglish = config.language === 'en';
|
|
switch (phase) {
|
|
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
|
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
|
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
|
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
|
default: return phase;
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// BACKEND I18N
|
|
// ==========================================
|
|
// User-visible messages produced in main.ts. Keep keys stable — the renderer
|
|
// no longer translates these (renderer.ts:downloadClip used to translate a
|
|
// hardcoded set, which was brittle as the strings drifted). Internal
|
|
// debug log messages stay English-only since they're developer-facing.
|
|
const BACKEND_MESSAGES = {
|
|
de: {
|
|
invalidVodUrl: 'Ungueltige VOD-URL',
|
|
invalidClipUrl: 'Ungueltige Clip-URL',
|
|
clipNotFound: 'Clip nicht gefunden',
|
|
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
|
streamlinkMissing: 'Streamlink fehlt.',
|
|
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
|
|
streamlinkExitCode: 'Streamlink Fehlercode {code}',
|
|
ffmpegMissing: 'FFmpeg fehlt.',
|
|
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
|
|
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
|
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
|
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
|
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
|
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
|
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
|
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
|
downloadCancelled: 'Download wurde abgebrochen.',
|
|
downloadPaused: 'Download wurde pausiert.',
|
|
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
|
unknownDownloadError: 'Unbekannter Fehler beim Download',
|
|
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
|
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
|
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
|
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
|
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
|
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
|
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
|
statusCheckingTools: 'Prufe Download-Tools...',
|
|
statusDownloadStarted: 'Download gestartet',
|
|
statusBytesDownloaded: '{bytes} heruntergeladen',
|
|
preflightNoInternet: 'Keine Internetverbindung erkannt.',
|
|
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
|
|
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
|
|
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
|
|
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
|
|
},
|
|
en: {
|
|
invalidVodUrl: 'Invalid VOD URL',
|
|
invalidClipUrl: 'Invalid clip URL',
|
|
clipNotFound: 'Clip not found',
|
|
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
|
|
streamlinkMissing: 'Streamlink is missing.',
|
|
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
|
|
streamlinkExitCode: 'Streamlink exit code {code}',
|
|
ffmpegMissing: 'FFmpeg is missing.',
|
|
ffmpegMergeFailed: 'FFmpeg merge failed.',
|
|
ffmpegSplitFailed: 'FFmpeg split failed.',
|
|
fileTooSmall: 'File too small ({bytes} bytes)',
|
|
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
|
|
integrityNoVideo: 'Integrity check failed: no video stream found.',
|
|
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
|
|
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
|
|
integrityFailedGeneric: 'Integrity check failed.',
|
|
downloadCancelled: 'Download was cancelled.',
|
|
downloadPaused: 'Download was paused.',
|
|
downloadFailedExitCode: 'Download failed (exit code {code})',
|
|
unknownDownloadError: 'Unknown download error',
|
|
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
|
|
notAllPartsDownloaded: 'Not all parts could be downloaded.',
|
|
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
|
|
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
|
|
diskSpaceShortGeneric: 'Not enough disk space.',
|
|
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
|
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
|
statusCheckingTools: 'Checking download tools...',
|
|
statusDownloadStarted: 'Download started',
|
|
statusBytesDownloaded: '{bytes} downloaded',
|
|
preflightNoInternet: 'No internet connection detected.',
|
|
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
|
|
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
|
|
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
|
|
preflightDownloadPathNotWritable: 'Download folder is not writable.'
|
|
}
|
|
} as const;
|
|
|
|
type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
|
|
|
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
|
const lang: 'de' | 'en' = config.language === 'en' ? 'en' : 'de';
|
|
let template: string = BACKEND_MESSAGES[lang][key];
|
|
if (params) {
|
|
for (const [k, v] of Object.entries(params)) {
|
|
template = template.replace(`{${k}}`, String(v));
|
|
}
|
|
}
|
|
return template;
|
|
}
|
|
|
|
// Ensure directories exist
|
|
if (!fs.existsSync(APPDATA_DIR)) {
|
|
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
|
}
|
|
|
|
// ==========================================
|
|
// INTERFACES
|
|
// ==========================================
|
|
interface Config {
|
|
client_id: string;
|
|
client_secret: string;
|
|
download_path: string;
|
|
streamers: string[];
|
|
theme: string;
|
|
download_mode: 'parts' | 'full';
|
|
part_minutes: number;
|
|
language: 'de' | 'en';
|
|
filename_template_vod: string;
|
|
filename_template_parts: string;
|
|
filename_template_clip: string;
|
|
smart_queue_scheduler: boolean;
|
|
performance_mode: PerformanceMode;
|
|
prevent_duplicate_downloads: boolean;
|
|
persist_queue_on_restart: boolean;
|
|
metadata_cache_minutes: number;
|
|
parallel_downloads: number;
|
|
auto_resume_queue_on_startup: boolean;
|
|
downloaded_vod_ids: string[];
|
|
}
|
|
|
|
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: []
|
|
};
|
|
|
|
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
|
const value = (template || '').trim();
|
|
return value || fallback;
|
|
}
|
|
|
|
function normalizeMetadataCacheMinutes(value: unknown): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return DEFAULT_METADATA_CACHE_MINUTES;
|
|
}
|
|
|
|
return Math.max(1, Math.min(120, Math.floor(parsed)));
|
|
}
|
|
|
|
function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
|
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
|
return mode;
|
|
}
|
|
|
|
return DEFAULT_PERFORMANCE_MODE;
|
|
}
|
|
|
|
function normalizeConfigTemplates(input: Config): Config {
|
|
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
|
// an unbounded list across years of downloads. Latest entries kept.
|
|
const DOWNLOADED_IDS_MAX = 4096;
|
|
const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : [];
|
|
const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX
|
|
? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX)
|
|
: cleanIds;
|
|
|
|
return {
|
|
...input,
|
|
filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD),
|
|
filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS),
|
|
filename_template_clip: normalizeFilenameTemplate(input.filename_template_clip, DEFAULT_FILENAME_TEMPLATE_CLIP),
|
|
smart_queue_scheduler: input.smart_queue_scheduler !== false,
|
|
performance_mode: normalizePerformanceMode(input.performance_mode),
|
|
prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false,
|
|
persist_queue_on_restart: input.persist_queue_on_restart !== false,
|
|
metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes),
|
|
auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true,
|
|
downloaded_vod_ids: trimmedIds
|
|
};
|
|
}
|
|
|
|
function recordDownloadedVodId(vodId: string): void {
|
|
if (!vodId) return;
|
|
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
|
if (config.downloaded_vod_ids.includes(vodId)) return;
|
|
config.downloaded_vod_ids.push(vodId);
|
|
// Cap to keep config size bounded — drop oldest first.
|
|
const DOWNLOADED_IDS_MAX = 4096;
|
|
if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) {
|
|
config.downloaded_vod_ids = config.downloaded_vod_ids.slice(
|
|
config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX
|
|
);
|
|
}
|
|
saveConfig(config);
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function loadConfig(): Config {
|
|
try {
|
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
const parsed = JSON.parse(data);
|
|
if (!isPlainObject(parsed)) {
|
|
console.error('Config file is not a JSON object — using defaults');
|
|
return normalizeConfigTemplates(defaultConfig);
|
|
}
|
|
return normalizeConfigTemplates({ ...defaultConfig, ...parsed });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading config:', e);
|
|
}
|
|
return normalizeConfigTemplates(defaultConfig);
|
|
}
|
|
|
|
function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
|
|
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
|
|
const tmpPath = targetPath + '.tmp';
|
|
|
|
let fd: number | null = null;
|
|
try {
|
|
fd = fs.openSync(tmpPath, 'w');
|
|
fs.writeSync(fd, buffer, 0, buffer.length, 0);
|
|
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
|
|
} finally {
|
|
if (fd !== null) {
|
|
try { fs.closeSync(fd); } catch { }
|
|
}
|
|
}
|
|
|
|
try {
|
|
fs.renameSync(tmpPath, targetPath);
|
|
} catch {
|
|
// On Windows, rename can fail if target exists or is locked. Fall back to copy.
|
|
fs.copyFileSync(tmpPath, targetPath);
|
|
try { fs.unlinkSync(tmpPath); } catch { }
|
|
}
|
|
}
|
|
|
|
function saveConfig(config: Config): void {
|
|
try {
|
|
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
} catch (e) {
|
|
console.error('Error saving config:', e);
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// QUEUE MANAGEMENT
|
|
// ==========================================
|
|
const VALID_QUEUE_STATUSES: ReadonlyArray<QueueItem['status']> = ['pending', 'downloading', 'paused', 'completed', 'error'];
|
|
const VALID_MERGE_PHASES: ReadonlyArray<MergeGroup['mergePhase']> = ['downloading', 'merging', 'splitting', 'cleanup', 'done'];
|
|
|
|
function isValidQueueStatus(status: unknown): status is QueueItem['status'] {
|
|
return typeof status === 'string' && (VALID_QUEUE_STATUSES as readonly string[]).includes(status);
|
|
}
|
|
|
|
function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined {
|
|
if (!isPlainObject(raw)) return undefined;
|
|
if (!Array.isArray(raw.items) || raw.items.length < 2) return undefined;
|
|
|
|
const items: MergeGroupItem[] = [];
|
|
for (const mi of raw.items) {
|
|
if (!isPlainObject(mi)) continue;
|
|
if (typeof mi.url !== 'string' || typeof mi.title !== 'string'
|
|
|| typeof mi.date !== 'string' || typeof mi.streamer !== 'string'
|
|
|| typeof mi.duration_str !== 'string') continue;
|
|
items.push({ url: mi.url, title: mi.title, date: mi.date, streamer: mi.streamer, duration_str: mi.duration_str });
|
|
}
|
|
if (items.length < 2) return undefined;
|
|
|
|
const phase: MergeGroup['mergePhase'] = (VALID_MERGE_PHASES as readonly string[]).includes(String(raw.mergePhase))
|
|
? raw.mergePhase as MergeGroup['mergePhase']
|
|
: 'downloading';
|
|
|
|
const downloadedFiles: Record<number, string> = {};
|
|
if (isPlainObject(raw.downloadedFiles)) {
|
|
for (const [k, v] of Object.entries(raw.downloadedFiles)) {
|
|
const idx = Number(k);
|
|
if (Number.isFinite(idx) && typeof v === 'string') downloadedFiles[idx] = v;
|
|
}
|
|
}
|
|
|
|
return {
|
|
items,
|
|
mergePhase: phase,
|
|
currentItemIndex: typeof raw.currentItemIndex === 'number' && Number.isFinite(raw.currentItemIndex) ? raw.currentItemIndex : 0,
|
|
downloadedFiles,
|
|
mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined,
|
|
splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined,
|
|
totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined
|
|
};
|
|
}
|
|
|
|
function sanitizeCustomClip(raw: unknown): CustomClip | undefined {
|
|
if (!isPlainObject(raw)) return undefined;
|
|
const startSec = Number(raw.startSec);
|
|
const durationSec = Number(raw.durationSec);
|
|
const startPart = Number(raw.startPart);
|
|
if (!Number.isFinite(startSec) || !Number.isFinite(durationSec) || durationSec <= 0 || !Number.isFinite(startPart)) return undefined;
|
|
|
|
const filenameFormat = raw.filenameFormat;
|
|
if (filenameFormat !== 'simple' && filenameFormat !== 'timestamp' && filenameFormat !== 'template' && filenameFormat !== 'parts') return undefined;
|
|
|
|
return {
|
|
startSec: Math.max(0, startSec),
|
|
durationSec: Math.max(1, durationSec),
|
|
startPart: Math.max(1, Math.floor(startPart)),
|
|
filenameFormat,
|
|
filenameTemplate: typeof raw.filenameTemplate === 'string' ? raw.filenameTemplate : undefined
|
|
};
|
|
}
|
|
|
|
function sanitizeQueueItem(raw: unknown): QueueItem | null {
|
|
if (!isPlainObject(raw)) return null;
|
|
if (typeof raw.id !== 'string' || !raw.id) return null;
|
|
if (typeof raw.url !== 'string' || !raw.url) return null;
|
|
if (!isValidQueueStatus(raw.status)) return null;
|
|
|
|
// 'downloading' on cold start is stale — no download is actually running
|
|
// and the user expects to resume from start, so map it back to 'pending'
|
|
const isStaleDownloading = raw.status === 'downloading';
|
|
const finalStatus: QueueItem['status'] = isStaleDownloading ? 'pending' : raw.status;
|
|
|
|
const progressNum = Number(raw.progress);
|
|
const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0;
|
|
|
|
const item: QueueItem = {
|
|
id: raw.id,
|
|
url: raw.url,
|
|
title: typeof raw.title === 'string' ? raw.title : '',
|
|
date: typeof raw.date === 'string' ? raw.date : '',
|
|
streamer: typeof raw.streamer === 'string' ? raw.streamer : '',
|
|
duration_str: typeof raw.duration_str === 'string' ? raw.duration_str : '0s',
|
|
status: finalStatus,
|
|
progress: isStaleDownloading ? 0 : safeProgress
|
|
};
|
|
|
|
if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart;
|
|
if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts;
|
|
if (typeof raw.speed === 'string') item.speed = raw.speed;
|
|
if (typeof raw.eta === 'string') item.eta = raw.eta;
|
|
if (typeof raw.last_error === 'string') item.last_error = raw.last_error;
|
|
if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes;
|
|
if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes;
|
|
|
|
if (Array.isArray(raw.outputFiles)) {
|
|
const files = raw.outputFiles.filter((f): f is string => typeof f === 'string' && f.length > 0);
|
|
if (files.length > 0) item.outputFiles = files;
|
|
}
|
|
|
|
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;
|
|
let currentDownloadCancelled = false;
|
|
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>();
|
|
const userIdLoginCache = new Map<string, string>();
|
|
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
|
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
|
const clipInfoCache = new Map<string, CacheEntry<any>>();
|
|
const inFlightUserIdRequests = new Map<string, Promise<string | null>>();
|
|
const inFlightVodRequests = new Map<string, Promise<VOD[]>>();
|
|
const inFlightClipRequests = new Map<string, Promise<any | null>>();
|
|
let cacheCleanupTimer: NodeJS.Timeout | null = null;
|
|
const runtimeMetrics: RuntimeMetrics = {
|
|
cacheHits: 0,
|
|
cacheMisses: 0,
|
|
duplicateSkips: 0,
|
|
retriesScheduled: 0,
|
|
retriesExhausted: 0,
|
|
integrityFailures: 0,
|
|
downloadsStarted: 0,
|
|
downloadsCompleted: 0,
|
|
downloadsFailed: 0,
|
|
downloadedBytesTotal: 0,
|
|
lastSpeedBytesPerSec: 0,
|
|
avgSpeedBytesPerSec: 0,
|
|
activeItemId: null,
|
|
activeItemTitle: null,
|
|
lastErrorClass: null,
|
|
lastRetryDelaySeconds: 0
|
|
};
|
|
let debugLogFlushTimer: NodeJS.Timeout | null = null;
|
|
let pendingDebugLogLines: string[] = [];
|
|
let autoUpdaterInitialized = false;
|
|
let autoUpdateCheckTimer: NodeJS.Timeout | null = null;
|
|
let autoUpdateStartupTimer: NodeJS.Timeout | null = null;
|
|
let autoUpdateCheckInProgress = false;
|
|
let autoUpdateReadyToInstall = false;
|
|
let autoUpdateDownloadInProgress = false;
|
|
let lastAutoUpdateCheckAt = 0;
|
|
let latestKnownUpdateVersion: string | null = null;
|
|
let downloadedUpdateVersion: string | null = null;
|
|
let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null;
|
|
let twitchLoginInFlight: Promise<boolean> | null = null;
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function isDownloadPathWritable(targetPath: string): boolean {
|
|
try {
|
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
const probeFile = path.join(targetPath, `.write_test_${Date.now()}.tmp`);
|
|
fs.writeFileSync(probeFile, 'ok');
|
|
fs.unlinkSync(probeFile);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function hasInternetConnection(): Promise<boolean> {
|
|
try {
|
|
const res = await axios.get('https://id.twitch.tv/oauth2/validate', {
|
|
timeout: 5000,
|
|
validateStatus: () => true
|
|
});
|
|
return res.status > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runPreflight(autoFix = false): Promise<PreflightResult> {
|
|
appendDebugLog('preflight-start', { autoFix });
|
|
|
|
refreshBundledToolPaths();
|
|
|
|
const checks: PreflightChecks = {
|
|
internet: await hasInternetConnection(),
|
|
streamlink: false,
|
|
ffmpeg: false,
|
|
ffprobe: false,
|
|
downloadPathWritable: isDownloadPathWritable(config.download_path)
|
|
};
|
|
|
|
if (autoFix) {
|
|
await ensureStreamlinkInstalled();
|
|
await ensureFfmpegInstalled();
|
|
refreshBundledToolPaths(true);
|
|
}
|
|
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
checks.streamlink = canExecuteCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
|
|
if (checks.streamlink) {
|
|
cacheVerifiedStreamlinkCommand(streamlinkCmd.command, [...streamlinkCmd.prefixArgs, '--version']);
|
|
}
|
|
|
|
const ffmpegPath = getFFmpegPath();
|
|
const ffprobePath = getFFprobePath();
|
|
checks.ffmpeg = canExecuteCommand(ffmpegPath, ['-version']);
|
|
checks.ffprobe = canExecuteCommand(ffprobePath, ['-version']);
|
|
if (checks.ffmpeg && checks.ffprobe) {
|
|
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
|
|
}
|
|
|
|
const messages: string[] = [];
|
|
if (!checks.internet) messages.push(tBackend('preflightNoInternet'));
|
|
if (!checks.streamlink) messages.push(tBackend('preflightStreamlinkMissing'));
|
|
if (!checks.ffmpeg) messages.push(tBackend('preflightFfmpegMissing'));
|
|
if (!checks.ffprobe) messages.push(tBackend('preflightFfprobeMissing'));
|
|
if (!checks.downloadPathWritable) messages.push(tBackend('preflightDownloadPathNotWritable'));
|
|
|
|
const result: PreflightResult = {
|
|
ok: messages.length === 0,
|
|
autoFixApplied: autoFix,
|
|
checks,
|
|
messages,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
appendDebugLog('preflight-finished', result);
|
|
return result;
|
|
}
|
|
|
|
function flushPendingDebugLogLines(): void {
|
|
if (!pendingDebugLogLines.length) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = pendingDebugLogLines.join('');
|
|
pendingDebugLogLines = [];
|
|
fs.appendFileSync(DEBUG_LOG_FILE, payload);
|
|
trimDebugLogFileIfNeeded();
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
function trimDebugLogFileIfNeeded(): void {
|
|
try {
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
return;
|
|
}
|
|
|
|
const stats = fs.statSync(DEBUG_LOG_FILE);
|
|
if (stats.size <= DEBUG_LOG_MAX_BYTES) {
|
|
return;
|
|
}
|
|
|
|
const bytesToKeep = Math.min(DEBUG_LOG_TRIM_TO_BYTES, stats.size);
|
|
const startOffset = Math.max(0, stats.size - bytesToKeep);
|
|
const buffer = Buffer.allocUnsafe(bytesToKeep);
|
|
|
|
let fileHandle: number | null = null;
|
|
try {
|
|
fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r');
|
|
fs.readSync(fileHandle, buffer, 0, bytesToKeep, startOffset);
|
|
} finally {
|
|
if (fileHandle !== null) {
|
|
fs.closeSync(fileHandle);
|
|
}
|
|
}
|
|
|
|
const firstLineBreak = buffer.indexOf(0x0a);
|
|
const trimmed = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length
|
|
? buffer.subarray(firstLineBreak + 1)
|
|
: buffer;
|
|
|
|
fs.writeFileSync(DEBUG_LOG_FILE, trimmed);
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
function readDebugLogTailFromDisk(): string {
|
|
const stats = fs.statSync(DEBUG_LOG_FILE);
|
|
if (stats.size <= 0) {
|
|
return '';
|
|
}
|
|
|
|
const bytesToRead = Math.min(stats.size, DEBUG_LOG_READ_TAIL_BYTES);
|
|
if (bytesToRead === stats.size) {
|
|
return fs.readFileSync(DEBUG_LOG_FILE, 'utf-8');
|
|
}
|
|
|
|
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
let fileHandle: number | null = null;
|
|
try {
|
|
fileHandle = fs.openSync(DEBUG_LOG_FILE, 'r');
|
|
fs.readSync(fileHandle, buffer, 0, bytesToRead, stats.size - bytesToRead);
|
|
} finally {
|
|
if (fileHandle !== null) {
|
|
fs.closeSync(fileHandle);
|
|
}
|
|
}
|
|
|
|
const firstLineBreak = buffer.indexOf(0x0a);
|
|
const slice = firstLineBreak >= 0 && firstLineBreak + 1 < buffer.length
|
|
? buffer.subarray(firstLineBreak + 1)
|
|
: buffer;
|
|
|
|
return slice.toString('utf-8');
|
|
}
|
|
|
|
function startDebugLogFlushTimer(): void {
|
|
if (debugLogFlushTimer) {
|
|
return;
|
|
}
|
|
|
|
debugLogFlushTimer = setInterval(() => {
|
|
flushPendingDebugLogLines();
|
|
}, DEBUG_LOG_FLUSH_INTERVAL_MS);
|
|
|
|
debugLogFlushTimer.unref?.();
|
|
}
|
|
|
|
function stopDebugLogFlushTimer(flush = true): void {
|
|
if (debugLogFlushTimer) {
|
|
clearInterval(debugLogFlushTimer);
|
|
debugLogFlushTimer = null;
|
|
}
|
|
|
|
if (flush) {
|
|
flushPendingDebugLogLines();
|
|
}
|
|
}
|
|
|
|
function readDebugLog(lines = 200): string {
|
|
try {
|
|
flushPendingDebugLogLines();
|
|
|
|
if (!fs.existsSync(DEBUG_LOG_FILE)) {
|
|
return 'Debug-Log ist leer.';
|
|
}
|
|
|
|
const text = readDebugLogTailFromDisk();
|
|
const rows = text.split(/\r?\n/).filter(Boolean);
|
|
return rows.slice(-lines).join('\n') || 'Debug-Log ist leer.';
|
|
} catch (e) {
|
|
return `Debug-Log konnte nicht gelesen werden: ${String(e)}`;
|
|
}
|
|
}
|
|
|
|
function appendDebugLog(message: string, details?: unknown): void {
|
|
try {
|
|
const ts = new Date().toISOString();
|
|
const payload = details === undefined
|
|
? ''
|
|
: ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`;
|
|
|
|
pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`);
|
|
|
|
if (pendingDebugLogLines.length >= DEBUG_LOG_BUFFER_FLUSH_LINES) {
|
|
flushPendingDebugLogLines();
|
|
} else {
|
|
startDebugLogFlushTimer();
|
|
}
|
|
} catch {
|
|
// ignore debug log errors
|
|
}
|
|
}
|
|
|
|
// Wire up tools module with debug logging and directory paths
|
|
setDebugLogFn(appendDebugLog);
|
|
initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp'));
|
|
|
|
// ==========================================
|
|
// DURATION HELPERS
|
|
// ==========================================
|
|
function parseDuration(duration: string): number {
|
|
let seconds = 0;
|
|
const hours = duration.match(/(\d+)h/);
|
|
const minutes = duration.match(/(\d+)m/);
|
|
const secs = duration.match(/(\d+)s/);
|
|
|
|
if (hours) seconds += parseInt(hours[1]) * 3600;
|
|
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
|
if (secs) seconds += parseInt(secs[1]);
|
|
|
|
return seconds;
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatDurationDashed(seconds: number): string {
|
|
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
const claimedFilenames = new Set<string>();
|
|
const itemClaimedFilenames = new Map<string, Set<string>>();
|
|
|
|
function ensureUniqueFilename(filePath: string, itemId: string | null = null): string {
|
|
const dir = path.dirname(filePath);
|
|
const ext = path.extname(filePath);
|
|
const base = path.basename(filePath, ext);
|
|
let candidate = filePath;
|
|
let counter = 0;
|
|
while (fs.existsSync(candidate) || claimedFilenames.has(candidate)) {
|
|
counter++;
|
|
candidate = path.join(dir, `${base}_${counter}${ext}`);
|
|
}
|
|
claimedFilenames.add(candidate);
|
|
if (itemId) {
|
|
let perItem = itemClaimedFilenames.get(itemId);
|
|
if (!perItem) {
|
|
perItem = new Set();
|
|
itemClaimedFilenames.set(itemId, perItem);
|
|
}
|
|
perItem.add(candidate);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function releaseClaimedFilenamesForItem(itemId: string): void {
|
|
const perItem = itemClaimedFilenames.get(itemId);
|
|
if (!perItem) return;
|
|
for (const f of perItem) claimedFilenames.delete(f);
|
|
itemClaimedFilenames.delete(itemId);
|
|
}
|
|
|
|
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
|
const cleaned = (input || '')
|
|
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
|
.replace(/[\\/]/g, '_')
|
|
.trim();
|
|
return cleaned || fallback;
|
|
}
|
|
|
|
function formatDateWithPattern(date: Date, pattern: string): string {
|
|
const tokenMap: Record<string, string> = {
|
|
yyyy: date.getFullYear().toString(),
|
|
yy: date.getFullYear().toString().slice(-2),
|
|
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
M: (date.getMonth() + 1).toString(),
|
|
dd: date.getDate().toString().padStart(2, '0'),
|
|
d: date.getDate().toString(),
|
|
HH: date.getHours().toString().padStart(2, '0'),
|
|
H: date.getHours().toString(),
|
|
hh: date.getHours().toString().padStart(2, '0'),
|
|
h: date.getHours().toString(),
|
|
mm: date.getMinutes().toString().padStart(2, '0'),
|
|
m: date.getMinutes().toString(),
|
|
ss: date.getSeconds().toString().padStart(2, '0'),
|
|
s: date.getSeconds().toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
|
const hours = Math.floor(safe / 3600);
|
|
const minutes = Math.floor((safe % 3600) / 60);
|
|
const seconds = safe % 60;
|
|
|
|
const tokenMap: Record<string, string> = {
|
|
HH: hours.toString().padStart(2, '0'),
|
|
H: hours.toString(),
|
|
hh: hours.toString().padStart(2, '0'),
|
|
h: hours.toString(),
|
|
mm: minutes.toString().padStart(2, '0'),
|
|
m: minutes.toString(),
|
|
ss: seconds.toString().padStart(2, '0'),
|
|
s: seconds.toString()
|
|
};
|
|
|
|
return pattern
|
|
.replace(/HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
|
.replace(/\\(.)/g, '$1');
|
|
}
|
|
|
|
function parseVodId(url: string): string {
|
|
const match = url.match(/videos\/(\d+)/i);
|
|
return match?.[1] || '';
|
|
}
|
|
|
|
function isLikelyVodUrl(url: string): boolean {
|
|
return /twitch\.tv\/videos\/\d+/i.test(url || '');
|
|
}
|
|
|
|
function parseFrameRate(rawFrameRate: string | undefined): number {
|
|
const fallback = 30;
|
|
const value = (rawFrameRate || '').trim();
|
|
if (!value) return fallback;
|
|
|
|
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
}
|
|
|
|
const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
|
|
if (!ratio) return fallback;
|
|
|
|
const numerator = Number(ratio[1]);
|
|
const denominator = Number(ratio[2]);
|
|
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
|
return fallback;
|
|
}
|
|
|
|
const fps = numerator / denominator;
|
|
return Number.isFinite(fps) && fps > 0 ? fps : fallback;
|
|
}
|
|
|
|
interface ClipTemplateContext {
|
|
template: string;
|
|
title: string;
|
|
vodId: string;
|
|
channel: string;
|
|
date: Date;
|
|
part: number;
|
|
partPadded: string;
|
|
trimStartSec: number;
|
|
trimEndSec: number;
|
|
trimLengthSec: number;
|
|
fullLengthSec: number;
|
|
}
|
|
|
|
function renderClipFilenameTemplate(context: ClipTemplateContext): string {
|
|
const baseDate = `${context.date.getDate().toString().padStart(2, '0')}.${(context.date.getMonth() + 1).toString().padStart(2, '0')}.${context.date.getFullYear()}`;
|
|
let rendered = context.template
|
|
.replace(/\{title\}/g, sanitizeFilenamePart(context.title, 'untitled'))
|
|
.replace(/\{id\}/g, sanitizeFilenamePart(context.vodId, 'unknown'))
|
|
.replace(/\{channel\}/g, sanitizeFilenamePart(context.channel, 'unknown'))
|
|
.replace(/\{channel_id\}/g, '')
|
|
.replace(/\{date\}/g, baseDate)
|
|
.replace(/\{part\}/g, String(context.part))
|
|
.replace(/\{part_padded\}/g, context.partPadded)
|
|
.replace(/\{trim_start\}/g, formatDurationDashed(context.trimStartSec))
|
|
.replace(/\{trim_end\}/g, formatDurationDashed(context.trimEndSec))
|
|
.replace(/\{trim_length\}/g, formatDurationDashed(context.trimLengthSec))
|
|
.replace(/\{length\}/g, formatDurationDashed(context.fullLengthSec))
|
|
.replace(/\{ext\}/g, 'mp4')
|
|
.replace(/\{random_string\}/g, Math.random().toString(36).slice(2, 10));
|
|
|
|
rendered = rendered.replace(/\{date_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatDateWithPattern(context.date, pattern), 'date');
|
|
});
|
|
rendered = rendered.replace(/\{trim_start_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimStartSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{trim_end_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimEndSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{trim_length_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.trimLengthSec, pattern), '00-00-00');
|
|
});
|
|
rendered = rendered.replace(/\{length_custom="(.*?)"\}/g, (_, pattern: string) => {
|
|
return sanitizeFilenamePart(formatSecondsWithPattern(context.fullLengthSec, pattern), '00-00-00');
|
|
});
|
|
|
|
const parts = rendered
|
|
.split(/[\\/]+/)
|
|
.map((segment) => sanitizeFilenamePart(segment, 'unnamed'))
|
|
.filter((segment) => segment !== '.' && segment !== '..');
|
|
|
|
if (parts.length === 0) {
|
|
return 'clip.mp4';
|
|
}
|
|
|
|
const lastIdx = parts.length - 1;
|
|
if (!/\.[A-Za-z0-9]{1,8}$/.test(parts[lastIdx])) {
|
|
parts[lastIdx] = `${parts[lastIdx]}.mp4`;
|
|
}
|
|
|
|
return path.join(...parts);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
}
|
|
|
|
function formatSpeed(bytesPerSec: number): string {
|
|
if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s';
|
|
if (bytesPerSec < 1024 * 1024) return (bytesPerSec / 1024).toFixed(1) + ' KB/s';
|
|
return (bytesPerSec / (1024 * 1024)).toFixed(1) + ' MB/s';
|
|
}
|
|
|
|
function formatETA(seconds: number): string {
|
|
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
return `${h}h ${m}m`;
|
|
}
|
|
|
|
function getFreeDiskBytes(targetPath: string): number | null {
|
|
try {
|
|
const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync;
|
|
if (!statfsSync) {
|
|
return null;
|
|
}
|
|
|
|
const info = statfsSync(targetPath);
|
|
const blockSize = Number(info?.bsize || info?.frsize || 0);
|
|
const availableBlocks = Number(info?.bavail || 0);
|
|
if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.floor(blockSize * availableBlocks);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function estimateRequiredDownloadBytes(item: QueueItem): number {
|
|
const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s'));
|
|
|
|
const bytesPerSecondByMode: Record<PerformanceMode, number> = {
|
|
stability: 900 * 1024,
|
|
balanced: 700 * 1024,
|
|
speed: 550 * 1024
|
|
};
|
|
|
|
const mode = normalizePerformanceMode(config.performance_mode);
|
|
const baseEstimate = durationSeconds * bytesPerSecondByMode[mode];
|
|
const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35));
|
|
|
|
return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024));
|
|
}
|
|
|
|
function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult {
|
|
const freeBytes = getFreeDiskBytes(targetPath);
|
|
if (freeBytes === null) {
|
|
appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context });
|
|
return { success: true };
|
|
}
|
|
|
|
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
|
const message = tBackend('diskSpaceShortFor', { context, free: formatBytes(freeBytes), required: formatBytes(requiredBytes) });
|
|
appendDebugLog('disk-space-check-failed', {
|
|
targetPath,
|
|
requiredBytes,
|
|
freeBytes,
|
|
context
|
|
});
|
|
return { success: false, error: message };
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
function getMetadataCacheTtlMs(): number {
|
|
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
|
|
}
|
|
|
|
function getCachedValue<T>(cache: Map<string, CacheEntry<T>>, key: string): T | undefined {
|
|
const cached = cache.get(key);
|
|
if (!cached) {
|
|
return undefined;
|
|
}
|
|
|
|
if (cached.expiresAt <= Date.now()) {
|
|
cache.delete(key);
|
|
return undefined;
|
|
}
|
|
|
|
cache.delete(key);
|
|
cache.set(key, cached);
|
|
return cached.value;
|
|
}
|
|
|
|
function pruneExpiredCacheEntries<T>(cache: Map<string, CacheEntry<T>>): number {
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
|
|
for (const [key, entry] of cache.entries()) {
|
|
if (entry.expiresAt <= now) {
|
|
cache.delete(key);
|
|
removed += 1;
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function enforceCacheEntryLimit<T>(cache: Map<string, CacheEntry<T>>, maxEntries: number): number {
|
|
if (maxEntries <= 0) {
|
|
const removed = cache.size;
|
|
cache.clear();
|
|
return removed;
|
|
}
|
|
|
|
let removed = 0;
|
|
while (cache.size > maxEntries) {
|
|
const oldest = cache.keys().next().value as string | undefined;
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
cache.delete(oldest);
|
|
removed += 1;
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
function setCachedValue<T>(
|
|
cache: Map<string, CacheEntry<T>>,
|
|
key: string,
|
|
value: T,
|
|
maxEntries: number
|
|
): void {
|
|
cache.set(key, {
|
|
value,
|
|
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
|
});
|
|
|
|
if (cache.size > maxEntries) {
|
|
pruneExpiredCacheEntries(cache);
|
|
enforceCacheEntryLimit(cache, maxEntries);
|
|
}
|
|
}
|
|
|
|
function cleanupMetadataCaches(reason: 'interval' | 'manual' | 'shutdown'): void {
|
|
const before = {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
};
|
|
|
|
const expired = {
|
|
loginToUserId: pruneExpiredCacheEntries(loginToUserIdCache),
|
|
vodList: pruneExpiredCacheEntries(vodListCache),
|
|
clipInfo: pruneExpiredCacheEntries(clipInfoCache)
|
|
};
|
|
|
|
const evicted = {
|
|
loginToUserId: enforceCacheEntryLimit(loginToUserIdCache, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES),
|
|
vodList: enforceCacheEntryLimit(vodListCache, MAX_VOD_LIST_CACHE_ENTRIES),
|
|
clipInfo: enforceCacheEntryLimit(clipInfoCache, MAX_CLIP_INFO_CACHE_ENTRIES)
|
|
};
|
|
|
|
const removedTotal =
|
|
expired.loginToUserId + expired.vodList + expired.clipInfo +
|
|
evicted.loginToUserId + evicted.vodList + evicted.clipInfo;
|
|
|
|
if (removedTotal > 0) {
|
|
appendDebugLog('metadata-cache-cleanup', {
|
|
reason,
|
|
before,
|
|
after: {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
},
|
|
expired,
|
|
evicted,
|
|
removedTotal
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearMetadataCaches(): void {
|
|
loginToUserIdCache.clear();
|
|
vodListCache.clear();
|
|
clipInfoCache.clear();
|
|
}
|
|
|
|
function startMetadataCacheCleanup(): void {
|
|
if (cacheCleanupTimer) {
|
|
return;
|
|
}
|
|
|
|
cacheCleanupTimer = setInterval(() => {
|
|
cleanupMetadataCaches('interval');
|
|
}, CACHE_CLEANUP_INTERVAL_MS);
|
|
|
|
cacheCleanupTimer.unref?.();
|
|
}
|
|
|
|
function stopMetadataCacheCleanup(): void {
|
|
if (!cacheCleanupTimer) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(cacheCleanupTimer);
|
|
cacheCleanupTimer = null;
|
|
}
|
|
|
|
function withInFlightDedup<T>(
|
|
store: Map<string, Promise<T>>,
|
|
key: string,
|
|
factory: () => Promise<T>
|
|
): Promise<T> {
|
|
const existing = store.get(key);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const requestPromise: Promise<T> = factory().finally(() => {
|
|
if (store.get(key) === requestPromise) {
|
|
store.delete(key);
|
|
}
|
|
});
|
|
|
|
store.set(key, requestPromise);
|
|
return requestPromise;
|
|
}
|
|
|
|
function getRetryAttemptLimit(): number {
|
|
switch (normalizePerformanceMode(config.performance_mode)) {
|
|
case 'stability':
|
|
return 5;
|
|
case 'speed':
|
|
return 2;
|
|
case 'balanced':
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
function classifyDownloadError(errorMessage: string): RetryErrorClass {
|
|
const text = (errorMessage || '').toLowerCase();
|
|
if (!text) return 'unknown';
|
|
|
|
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
|
|
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
|
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
|
|
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
|
|
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
|
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
|
|
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): number {
|
|
const jitter = Math.floor(Math.random() * 3);
|
|
|
|
switch (errorClass) {
|
|
case 'rate_limit':
|
|
return Math.min(45, 10 + attempt * 6 + jitter);
|
|
case 'network':
|
|
return Math.min(30, 4 * attempt + jitter);
|
|
case 'auth':
|
|
return Math.min(40, 8 + attempt * 5 + jitter);
|
|
case 'integrity':
|
|
return Math.min(20, 3 + attempt * 2 + jitter);
|
|
case 'io':
|
|
return Math.min(25, 5 + attempt * 3 + jitter);
|
|
case 'tooling':
|
|
return DEFAULT_RETRY_DELAY_SECONDS;
|
|
case 'validation':
|
|
return 0;
|
|
case 'unknown':
|
|
default:
|
|
return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter);
|
|
}
|
|
}
|
|
|
|
function getQueueCounts(queueData: QueueItem[] = downloadQueue): RuntimeMetricsSnapshot['queue'] {
|
|
const counts = {
|
|
pending: 0,
|
|
downloading: 0,
|
|
paused: 0,
|
|
completed: 0,
|
|
error: 0,
|
|
total: queueData.length
|
|
};
|
|
|
|
for (const item of queueData) {
|
|
if (item.status === 'pending') counts.pending += 1;
|
|
else if (item.status === 'downloading') counts.downloading += 1;
|
|
else if (item.status === 'paused') counts.paused += 1;
|
|
else if (item.status === 'completed') counts.completed += 1;
|
|
else if (item.status === 'error') counts.error += 1;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
function generateQueueItemId(): string {
|
|
queueIdCounter = (queueIdCounter + 1) % 1000;
|
|
return `${Date.now()}-${queueIdCounter}`;
|
|
}
|
|
|
|
function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): string {
|
|
return queueData.map((item) => [
|
|
item.id,
|
|
item.status,
|
|
Math.round((Number(item.progress) || 0) * 10),
|
|
item.currentPart || 0,
|
|
item.totalParts || 0,
|
|
item.speed || '',
|
|
item.eta || '',
|
|
item.last_error || ''
|
|
].join(':')).join('|');
|
|
}
|
|
|
|
function emitQueueUpdated(force = false): void {
|
|
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
|
|
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
|
|
return;
|
|
}
|
|
|
|
lastQueueBroadcastFingerprint = nextFingerprint;
|
|
mainWindow?.webContents.send('queue-updated', downloadQueue);
|
|
}
|
|
|
|
function hasQueueItemId(id: string): boolean {
|
|
return downloadQueue.some((item) => item.id === id);
|
|
}
|
|
|
|
function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot {
|
|
return {
|
|
...runtimeMetrics,
|
|
timestamp: new Date().toISOString(),
|
|
queue: getQueueCounts(downloadQueue),
|
|
caches: {
|
|
loginToUserId: loginToUserIdCache.size,
|
|
vodList: vodListCache.size,
|
|
clipInfo: clipInfoCache.size
|
|
},
|
|
config: {
|
|
performanceMode: normalizePerformanceMode(config.performance_mode),
|
|
smartScheduler: config.smart_queue_scheduler !== false,
|
|
metadataCacheMinutes: normalizeMetadataCacheMinutes(config.metadata_cache_minutes),
|
|
duplicatePrevention: config.prevent_duplicate_downloads !== false
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeQueueUrlForFingerprint(url: string): string {
|
|
return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
|
|
}
|
|
|
|
function getQueueItemFingerprint(item: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): string {
|
|
const clip = item.customClip;
|
|
const clipFingerprint = clip
|
|
? [
|
|
'clip',
|
|
clip.startSec,
|
|
clip.durationSec,
|
|
clip.startPart,
|
|
clip.filenameFormat,
|
|
(clip.filenameTemplate || '').trim().toLowerCase()
|
|
].join(':')
|
|
: 'vod';
|
|
|
|
return [
|
|
normalizeQueueUrlForFingerprint(item.url),
|
|
(item.streamer || '').trim().toLowerCase(),
|
|
(item.date || '').trim(),
|
|
clipFingerprint
|
|
].join('|');
|
|
}
|
|
|
|
function isQueueItemActive(item: QueueItem): boolean {
|
|
return item.status === 'pending' || item.status === 'downloading' || item.status === 'paused';
|
|
}
|
|
|
|
function hasActiveDuplicate(candidate: Pick<QueueItem, 'url' | 'streamer' | 'date' | 'customClip'>): boolean {
|
|
const candidateFingerprint = getQueueItemFingerprint(candidate);
|
|
|
|
return downloadQueue.some((existing) => {
|
|
if (!isQueueItemActive(existing)) return false;
|
|
return getQueueItemFingerprint(existing) === candidateFingerprint;
|
|
});
|
|
}
|
|
|
|
function getQueuePriorityScore(item: QueueItem): number {
|
|
const now = Date.now();
|
|
const createdMs = Number(item.id) || now;
|
|
const waitSeconds = Math.max(0, Math.floor((now - createdMs) / 1000));
|
|
const durationSeconds = Math.max(0, parseDuration(item.duration_str || '0s'));
|
|
const clipBoost = item.customClip ? 1500 : 0;
|
|
const shortJobBoost = Math.max(0, 7200 - Math.min(7200, durationSeconds)) / 5;
|
|
const ageBoost = Math.min(waitSeconds, 1800) / 2;
|
|
|
|
return clipBoost + shortJobBoost + ageBoost;
|
|
}
|
|
|
|
function pickNextPendingQueueItem(): QueueItem | null {
|
|
const pendingItems = downloadQueue.filter((item) => item.status === 'pending');
|
|
if (!pendingItems.length) return null;
|
|
|
|
if (!config.smart_queue_scheduler) {
|
|
return pendingItems[0];
|
|
}
|
|
|
|
let best = pendingItems[0];
|
|
let bestScore = getQueuePriorityScore(best);
|
|
|
|
for (let i = 1; i < pendingItems.length; i += 1) {
|
|
const candidate = pendingItems[i];
|
|
const score = getQueuePriorityScore(candidate);
|
|
if (score > bestScore) {
|
|
best = candidate;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
function parseClockDurationSeconds(duration: string | null): number | null {
|
|
if (!duration) return null;
|
|
const parts = duration.split(':').map((part) => Number(part));
|
|
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
|
|
return null;
|
|
}
|
|
|
|
return Math.max(0, Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2]));
|
|
}
|
|
|
|
function probeMediaFile(filePath: string): { durationSeconds: number; hasVideo: boolean } | null {
|
|
try {
|
|
const ffprobePath = getFFprobePath();
|
|
if (!canExecuteCommand(ffprobePath, ['-version'])) {
|
|
return null;
|
|
}
|
|
|
|
const res = spawnSync(ffprobePath, [
|
|
'-v', 'error',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
filePath
|
|
], {
|
|
windowsHide: true,
|
|
encoding: 'utf-8'
|
|
});
|
|
|
|
if (res.status !== 0 || !res.stdout) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(res.stdout) as {
|
|
format?: { duration?: string };
|
|
streams?: Array<{ codec_type?: string }>;
|
|
};
|
|
|
|
const durationSeconds = Number(parsed?.format?.duration || 0);
|
|
const hasVideo = Boolean(parsed?.streams?.some((stream) => stream.codec_type === 'video'));
|
|
|
|
return {
|
|
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
|
|
hasVideo
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function validateDownloadedFileIntegrity(filePath: string, expectedDurationSeconds: number | null): DownloadResult {
|
|
const probed = probeMediaFile(filePath);
|
|
if (!probed) {
|
|
appendDebugLog('integrity-probe-skipped', { filePath });
|
|
return { success: true };
|
|
}
|
|
|
|
if (!probed.hasVideo) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return { success: false, error: tBackend('integrityNoVideo') };
|
|
}
|
|
|
|
if (probed.durationSeconds <= 1) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return { success: false, error: tBackend('integrityTooShort', { duration: probed.durationSeconds.toFixed(2) }) };
|
|
}
|
|
|
|
if (expectedDurationSeconds && expectedDurationSeconds > 4) {
|
|
const minExpected = Math.max(2, expectedDurationSeconds * 0.45);
|
|
if (probed.durationSeconds < minExpected) {
|
|
runtimeMetrics.integrityFailures += 1;
|
|
return {
|
|
success: false,
|
|
error: tBackend('integrityDurationMismatch', { actual: probed.durationSeconds.toFixed(1), expected: String(expectedDurationSeconds) })
|
|
};
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
// ==========================================
|
|
// TWITCH API
|
|
// ==========================================
|
|
async function twitchLogin(): Promise<boolean> {
|
|
if (!config.client_id || !config.client_secret) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
|
|
params: {
|
|
client_id: config.client_id,
|
|
client_secret: config.client_secret,
|
|
grant_type: 'client_credentials'
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
accessToken = response.data.access_token;
|
|
return true;
|
|
} catch (e) {
|
|
console.error('Login error:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function requestTwitchLogin(): Promise<boolean> {
|
|
if (twitchLoginInFlight) {
|
|
return twitchLoginInFlight;
|
|
}
|
|
|
|
const loginPromise: Promise<boolean> = twitchLogin().finally(() => {
|
|
if (twitchLoginInFlight === loginPromise) {
|
|
twitchLoginInFlight = null;
|
|
}
|
|
});
|
|
|
|
twitchLoginInFlight = loginPromise;
|
|
return loginPromise;
|
|
}
|
|
|
|
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
|
if (!config.client_id || !config.client_secret) {
|
|
accessToken = null;
|
|
return false;
|
|
}
|
|
|
|
if (!forceRefresh && accessToken) {
|
|
return true;
|
|
}
|
|
|
|
return await requestTwitchLogin();
|
|
}
|
|
|
|
function normalizeLogin(input: string): string {
|
|
return input.trim().replace(/^@+/, '').toLowerCase();
|
|
}
|
|
|
|
function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
|
const seconds = Math.max(0, Math.floor(totalSeconds));
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
|
|
if (h > 0) return `${h}h${m}m${s}s`;
|
|
if (m > 0) return `${m}m${s}s`;
|
|
return `${s}s`;
|
|
}
|
|
|
|
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
|
|
// 4xx (other than 408/429) are application errors and not retried.
|
|
function isTransientAxiosError(err: unknown): boolean {
|
|
if (!axios.isAxiosError(err)) {
|
|
// Non-axios errors thrown from axios.post are typically network-layer
|
|
// failures (DNS, ECONNRESET, socket hangup) — retry those too.
|
|
return true;
|
|
}
|
|
const status = err.response?.status;
|
|
if (status === undefined) {
|
|
// No response means the request never reached / never returned —
|
|
// treat as transient (network blip, timeout).
|
|
return true;
|
|
}
|
|
return status === 408 || status === 429 || (status >= 500 && status < 600);
|
|
}
|
|
|
|
const TWITCH_GQL_RETRY_ATTEMPTS = 3;
|
|
const TWITCH_GQL_RETRY_BASE_DELAY_MS = 400;
|
|
|
|
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
|
|
let lastError: unknown = null;
|
|
|
|
for (let attempt = 1; attempt <= TWITCH_GQL_RETRY_ATTEMPTS; attempt++) {
|
|
try {
|
|
const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>(
|
|
'https://gql.twitch.tv/gql',
|
|
{ query, variables },
|
|
{
|
|
headers: {
|
|
'Client-ID': TWITCH_WEB_CLIENT_ID,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: API_TIMEOUT
|
|
}
|
|
);
|
|
|
|
// GraphQL errors (in `errors[]`) are application-level and not
|
|
// retried — the query itself is rejected.
|
|
if (response.data.errors?.length) {
|
|
const messages = response.data.errors.map((err) => err.message).join('; ');
|
|
appendDebugLog('public-gql-errors', { messages, attempt });
|
|
console.error('Public Twitch GQL errors:', messages);
|
|
return null;
|
|
}
|
|
|
|
if (attempt > 1) {
|
|
appendDebugLog('public-gql-recovered', { attempt });
|
|
}
|
|
return response.data.data || null;
|
|
} catch (e) {
|
|
lastError = e;
|
|
const transient = isTransientAxiosError(e);
|
|
const willRetry = transient && attempt < TWITCH_GQL_RETRY_ATTEMPTS;
|
|
appendDebugLog('public-gql-failed', {
|
|
attempt,
|
|
maxAttempts: TWITCH_GQL_RETRY_ATTEMPTS,
|
|
transient,
|
|
willRetry,
|
|
error: String(e)
|
|
});
|
|
if (!willRetry) {
|
|
break;
|
|
}
|
|
// Exponential backoff with jitter
|
|
const delay = TWITCH_GQL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250);
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
|
|
console.error('Public Twitch GQL request failed:', lastError);
|
|
return null;
|
|
}
|
|
|
|
async function getPublicUserId(username: string): Promise<string | null> {
|
|
const login = normalizeLogin(username);
|
|
if (!login) return null;
|
|
|
|
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
|
if (cachedUserId !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedUserId;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
type UserQueryResult = { user: { id: string; login: string } | null };
|
|
const data = await fetchPublicTwitchGql<UserQueryResult>(
|
|
'query($login:String!){ user(login:$login){ id login } }',
|
|
{ login }
|
|
);
|
|
|
|
const user = data?.user;
|
|
if (!user?.id) return null;
|
|
|
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
|
userIdLoginCache.set(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);
|
|
userIdLoginCache.set(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);
|
|
userIdLoginCache.set(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) {
|
|
userIdLoginCache.set(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();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function getClipInfo(clipId: string): Promise<any | null> {
|
|
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (cachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return cachedClip;
|
|
}
|
|
|
|
return await withInFlightDedup(inFlightClipRequests, clipId, async () => {
|
|
const refreshedCachedClip = getCachedValue(clipInfoCache, clipId);
|
|
if (refreshedCachedClip !== undefined) {
|
|
runtimeMetrics.cacheHits += 1;
|
|
return refreshedCachedClip;
|
|
}
|
|
|
|
runtimeMetrics.cacheMisses += 1;
|
|
|
|
if (!(await ensureTwitchAuth())) return null;
|
|
|
|
const fetchClip = async () => {
|
|
return await axios.get('https://api.twitch.tv/helix/clips', {
|
|
params: { id: clipId },
|
|
headers: {
|
|
'Client-ID': config.client_id,
|
|
'Authorization': `Bearer ${accessToken}`
|
|
},
|
|
timeout: API_TIMEOUT
|
|
});
|
|
};
|
|
|
|
try {
|
|
const response = await fetchClip();
|
|
const clip = response.data.data[0] || null;
|
|
if (clip) {
|
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
|
}
|
|
return clip;
|
|
} catch (e) {
|
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
try {
|
|
const retryResponse = await fetchClip();
|
|
const clip = retryResponse.data.data[0] || null;
|
|
if (clip) {
|
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
|
}
|
|
return clip;
|
|
} catch (retryError) {
|
|
console.error('Error getting clip after relogin:', retryError);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
console.error('Error getting clip:', e);
|
|
return null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VIDEO INFO (for cutter)
|
|
// ==========================================
|
|
async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('get-video-info-missing-ffmpeg');
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const ffprobe = getFFprobePath();
|
|
const args = [
|
|
'-v', 'quiet',
|
|
'-print_format', 'json',
|
|
'-show_format',
|
|
'-show_streams',
|
|
filePath
|
|
];
|
|
|
|
const proc = spawn(ffprobe, args, { windowsHide: true });
|
|
let output = '';
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const info = JSON.parse(output);
|
|
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
|
|
|
|
resolve({
|
|
duration: parseFloat(info.format?.duration || '0'),
|
|
width: videoStream?.width || 0,
|
|
height: videoStream?.height || 0,
|
|
fps: parseFrameRate(videoStream?.r_frame_rate)
|
|
});
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => resolve(null));
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// VIDEO CUTTER
|
|
// ==========================================
|
|
async function extractFrame(filePath: string, timeSeconds: number): Promise<string | null> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('extract-frame-missing-ffmpeg');
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const ffmpeg = getFFmpegPath();
|
|
const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`);
|
|
|
|
const args = [
|
|
'-ss', timeSeconds.toString(),
|
|
'-i', filePath,
|
|
'-vframes', '1',
|
|
'-q:v', '2',
|
|
'-y',
|
|
tempFile
|
|
];
|
|
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
|
|
proc.on('close', (code) => {
|
|
if (code === 0 && fs.existsSync(tempFile)) {
|
|
const imageData = fs.readFileSync(tempFile);
|
|
const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`;
|
|
fs.unlinkSync(tempFile);
|
|
resolve(base64);
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => resolve(null));
|
|
});
|
|
}
|
|
|
|
async function cutVideo(
|
|
inputFile: string,
|
|
outputFile: string,
|
|
startTime: number,
|
|
endTime: number,
|
|
onProgress: (percent: number) => void
|
|
): Promise<boolean> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('cut-video-missing-ffmpeg');
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const duration = Math.max(0.1, endTime - startTime);
|
|
|
|
let inputBytes = 0;
|
|
try {
|
|
inputBytes = fs.statSync(inputFile).size;
|
|
} catch { }
|
|
|
|
const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75));
|
|
const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut');
|
|
if (!cutDiskCheck.success) {
|
|
appendDebugLog('cut-video-no-disk-space', {
|
|
inputFile,
|
|
outputFile,
|
|
requiredBytes: cutRequiredBytes,
|
|
error: cutDiskCheck.error
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const runCutAttempt = async (copyMode: boolean): Promise<boolean> => {
|
|
const args = [
|
|
'-ss', formatDuration(startTime),
|
|
'-i', inputFile,
|
|
'-t', formatDuration(duration)
|
|
];
|
|
|
|
if (copyMode) {
|
|
args.push('-c', 'copy');
|
|
} else {
|
|
args.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-crf', '20',
|
|
'-c:a', 'aac',
|
|
'-b:a', '160k',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
|
|
|
appendDebugLog('cut-video-attempt', { copyMode, args });
|
|
|
|
return await new Promise((resolve) => {
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
currentEditorProcess = proc;
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
const line = data.toString();
|
|
const match = line.match(/out_time_us=(\d+)/);
|
|
if (match) {
|
|
const currentUs = parseInt(match[1], 10);
|
|
const percent = Math.min(100, (currentUs / 1000000) / duration * 100);
|
|
onProgress(percent);
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
currentEditorProcess = null;
|
|
if (code === 0 && fs.existsSync(outputFile)) {
|
|
const stats = fs.statSync(outputFile);
|
|
if (stats.size <= 256) {
|
|
appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size });
|
|
resolve(false);
|
|
return;
|
|
}
|
|
resolve(true);
|
|
} else {
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
currentEditorProcess = null;
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
const copySuccess = await runCutAttempt(true);
|
|
if (copySuccess) {
|
|
return true;
|
|
}
|
|
|
|
appendDebugLog('cut-video-copy-failed-fallback-reencode', { inputFile, outputFile });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { }
|
|
|
|
return await runCutAttempt(false);
|
|
}
|
|
|
|
// ==========================================
|
|
// MERGE VIDEOS
|
|
// ==========================================
|
|
async function mergeVideos(
|
|
inputFiles: string[],
|
|
outputFile: string,
|
|
onProgress: (percent: number) => void,
|
|
totalDurationSec?: number
|
|
): Promise<boolean> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('merge-videos-missing-ffmpeg');
|
|
return false;
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`);
|
|
const concatContent = inputFiles.map((filePath) => {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
return `file '${normalized.replace(/'/g, "'\\''")}'`;
|
|
}).join('\n');
|
|
fs.writeFileSync(concatFile, concatContent);
|
|
|
|
let mergeInputBytes = 0;
|
|
for (const filePath of inputFiles) {
|
|
try {
|
|
mergeInputBytes += fs.statSync(filePath).size;
|
|
} catch {
|
|
// ignore missing file in estimation
|
|
}
|
|
}
|
|
|
|
const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1));
|
|
const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge');
|
|
if (!mergeDiskCheck.success) {
|
|
appendDebugLog('merge-video-no-disk-space', {
|
|
outputFile,
|
|
files: inputFiles.length,
|
|
requiredBytes: mergeRequiredBytes,
|
|
error: mergeDiskCheck.error
|
|
});
|
|
try {
|
|
fs.unlinkSync(concatFile);
|
|
} catch { }
|
|
return false;
|
|
}
|
|
|
|
// Determine total duration for accurate progress
|
|
let mergeTotalDurationUs = 0;
|
|
if (totalDurationSec && totalDurationSec > 0) {
|
|
mergeTotalDurationUs = totalDurationSec * 1_000_000;
|
|
} else {
|
|
// Fallback: use ffprobe to get total duration of all input files
|
|
const ffprobe = getFFprobePath();
|
|
for (const filePath of inputFiles) {
|
|
try {
|
|
const result = execSync(
|
|
`"${ffprobe}" -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`,
|
|
{ timeout: 10000, windowsHide: true }
|
|
).toString().trim();
|
|
const dur = parseFloat(result);
|
|
if (!isNaN(dur)) {
|
|
mergeTotalDurationUs += dur * 1_000_000;
|
|
}
|
|
} catch {
|
|
// If ffprobe fails, fall back to old behavior
|
|
}
|
|
}
|
|
}
|
|
|
|
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
|
|
const args = [
|
|
'-f', 'concat',
|
|
'-safe', '0',
|
|
'-i', concatFile
|
|
];
|
|
|
|
if (copyMode) {
|
|
args.push('-c', 'copy');
|
|
} else {
|
|
args.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'veryfast',
|
|
'-crf', '20',
|
|
'-c:a', 'aac',
|
|
'-b:a', '160k',
|
|
'-movflags', '+faststart'
|
|
);
|
|
}
|
|
|
|
args.push('-progress', 'pipe:1', '-y', outputFile);
|
|
appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length });
|
|
|
|
return await new Promise((resolve) => {
|
|
const proc = spawn(ffmpeg, args, { windowsHide: true });
|
|
currentEditorProcess = proc;
|
|
|
|
proc.stdout?.on('data', (data) => {
|
|
const line = data.toString();
|
|
const match = line.match(/out_time_us=(\d+)/);
|
|
if (match) {
|
|
const currentUs = parseInt(match[1], 10);
|
|
if (mergeTotalDurationUs > 0) {
|
|
onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100));
|
|
} else {
|
|
onProgress(Math.min(99, currentUs / 10000000));
|
|
}
|
|
}
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
currentEditorProcess = null;
|
|
const success = code === 0 && fs.existsSync(outputFile);
|
|
if (success) {
|
|
onProgress(100);
|
|
}
|
|
resolve(success);
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
currentEditorProcess = null;
|
|
resolve(false);
|
|
});
|
|
});
|
|
};
|
|
|
|
try {
|
|
const copySuccess = await runMergeAttempt(true);
|
|
if (copySuccess) {
|
|
return true;
|
|
}
|
|
|
|
appendDebugLog('merge-video-copy-failed-fallback-reencode', { outputFile, files: inputFiles.length });
|
|
try {
|
|
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
} catch { }
|
|
|
|
return await runMergeAttempt(false);
|
|
} finally {
|
|
try {
|
|
fs.unlinkSync(concatFile);
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// SPLIT MERGED FILE
|
|
// ==========================================
|
|
async function splitMergedFile(
|
|
inputFile: string,
|
|
outputFolder: string,
|
|
partDurationSec: number,
|
|
totalDurationSec: number,
|
|
filenameGenerator: (partNum: number) => string,
|
|
onProgress: (currentPart: number, totalParts: number) => void,
|
|
itemId: string | null = null
|
|
): Promise<{ success: boolean; files: string[] }> {
|
|
const ffmpegReady = await ensureFfmpegInstalled();
|
|
if (!ffmpegReady) {
|
|
appendDebugLog('split-merged-missing-ffmpeg');
|
|
return { success: false, files: [] };
|
|
}
|
|
|
|
const ffmpeg = getFFmpegPath();
|
|
const numParts = Math.ceil(totalDurationSec / partDurationSec);
|
|
const splitFiles: string[] = [];
|
|
|
|
for (let i = 0; i < numParts; i++) {
|
|
if (currentDownloadCancelled) {
|
|
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, 'best', '-o', filename, '--force'];
|
|
let lastErrorLine = '';
|
|
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
|
let lastStreamlinkPercent = 0;
|
|
|
|
if (startTime) {
|
|
args.push('--hls-start-offset', startTime);
|
|
}
|
|
if (endTime) {
|
|
args.push('--hls-duration', endTime);
|
|
}
|
|
|
|
console.log('Starting download:', streamlinkCmd.command, args);
|
|
appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args });
|
|
|
|
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
|
|
|
|
// Register in per-item tracking map for parallel downloads
|
|
// (no longer mirrored on a global — currentEditorProcess is editor-only)
|
|
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
|
|
activeDownloads.set(itemId, itemTracking);
|
|
|
|
downloadStartTime = itemTracking.startTime;
|
|
downloadedBytes = 0;
|
|
let lastBytes = 0;
|
|
let lastTime = Date.now();
|
|
|
|
// Monitor file size for progress
|
|
const progressInterval = setInterval(() => {
|
|
if (fs.existsSync(filename)) {
|
|
try {
|
|
const stats = fs.statSync(filename);
|
|
downloadedBytes = stats.size;
|
|
itemTracking.bytes = stats.size;
|
|
|
|
const now = Date.now();
|
|
const timeDiff = (now - lastTime) / 1000;
|
|
const bytesDiff = downloadedBytes - lastBytes;
|
|
const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
|
|
|
|
runtimeMetrics.lastSpeedBytesPerSec = speed;
|
|
if (speed > 0) {
|
|
runtimeMetrics.avgSpeedBytesPerSec = runtimeMetrics.avgSpeedBytesPerSec <= 0
|
|
? speed
|
|
: (runtimeMetrics.avgSpeedBytesPerSec * 0.8) + (speed * 0.2);
|
|
}
|
|
|
|
lastBytes = downloadedBytes;
|
|
lastTime = now;
|
|
|
|
let etaStr = '';
|
|
if (downloadedBytes > 0) {
|
|
const elapsedSec = (Date.now() - (itemTracking?.startTime || Date.now())) / 1000;
|
|
if (elapsedSec > 5 && lastStreamlinkPercent > 1) {
|
|
// Use streamlink's reported progress for accurate ETA
|
|
const remainingSec = (elapsedSec / lastStreamlinkPercent) * (100 - lastStreamlinkPercent);
|
|
if (remainingSec > 0 && remainingSec < 86400) {
|
|
etaStr = formatETA(remainingSec);
|
|
}
|
|
}
|
|
}
|
|
|
|
onProgress({
|
|
id: itemId,
|
|
progress: -1, // Unknown total
|
|
speed: formatSpeed(speed),
|
|
eta: etaStr,
|
|
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
|
|
currentPart: partNum,
|
|
totalParts: totalParts,
|
|
downloadedBytes: downloadedBytes,
|
|
speedBytesPerSec: speed
|
|
});
|
|
} catch { }
|
|
}
|
|
}, 1000);
|
|
|
|
proc.stdout?.on('data', (data: Buffer) => {
|
|
const line = data.toString();
|
|
console.log('Streamlink:', line);
|
|
|
|
// Parse progress
|
|
const match = line.match(/(\d+\.\d+)%/);
|
|
if (match) {
|
|
const percent = parseFloat(match[1]);
|
|
lastStreamlinkPercent = percent;
|
|
onProgress({
|
|
id: itemId,
|
|
progress: percent,
|
|
speed: '',
|
|
eta: '',
|
|
status: `${percent.toFixed(1)}%`,
|
|
currentPart: partNum,
|
|
totalParts: totalParts
|
|
});
|
|
}
|
|
});
|
|
|
|
proc.stderr?.on('data', (data: Buffer) => {
|
|
const message = data.toString().trim();
|
|
if (message) {
|
|
lastErrorLine = message.split('\n').pop() || message;
|
|
appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine });
|
|
console.error('Streamlink error:', message);
|
|
}
|
|
});
|
|
|
|
proc.on('close', async (code) => {
|
|
clearInterval(progressInterval);
|
|
activeDownloads.delete(itemId);
|
|
|
|
if (currentDownloadCancelled || 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 });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function downloadVOD(
|
|
item: QueueItem,
|
|
onProgress: (progress: DownloadProgress) => void
|
|
): Promise<DownloadResult> {
|
|
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 (currentDownloadCancelled || 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 (currentDownloadCancelled || 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 (currentDownloadCancelled || 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 (currentDownloadCancelled || 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);
|
|
})
|
|
: await downloadVOD(item, (progress) => {
|
|
mainWindow?.webContents.send('download-progress', progress);
|
|
});
|
|
|
|
if (result.success) {
|
|
finalResult = result;
|
|
break;
|
|
}
|
|
|
|
finalResult = result;
|
|
|
|
if (!isDownloading || currentDownloadCancelled || 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];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
currentDownloadCancelled = false;
|
|
cancelledItemIds.clear();
|
|
mainWindow?.webContents.send('download-started');
|
|
emitQueueUpdated();
|
|
|
|
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
|
|
const activePromises = new Map<string, Promise<void>>();
|
|
|
|
while (isDownloading && !pauseRequested) {
|
|
// Clean up finished promises
|
|
for (const [id] of activePromises) {
|
|
const queueItem = downloadQueue.find(i => i.id === id);
|
|
if (!queueItem || queueItem.status !== 'downloading') {
|
|
activePromises.delete(id);
|
|
}
|
|
}
|
|
|
|
// Fill available slots
|
|
while (activePromises.size < maxSlots && !pauseRequested) {
|
|
const item = pickNextPendingQueueItem();
|
|
if (!item) break;
|
|
|
|
const itemPromise = processOneQueueItem(item);
|
|
activePromises.set(item.id, itemPromise);
|
|
}
|
|
|
|
if (activePromises.size === 0) break;
|
|
|
|
// Wait for any one download to finish before re-checking
|
|
await Promise.race([...activePromises.values()]);
|
|
}
|
|
|
|
// Wait for all remaining active downloads to complete
|
|
if (activePromises.size > 0) {
|
|
await Promise.allSettled([...activePromises.values()]);
|
|
}
|
|
|
|
isDownloading = false;
|
|
pauseRequested = false;
|
|
runtimeMetrics.activeItemId = null;
|
|
runtimeMetrics.activeItemTitle = null;
|
|
activeQueueItemId = null;
|
|
activeDownloads.clear();
|
|
cancelledItemIds.clear();
|
|
|
|
saveQueue(downloadQueue);
|
|
emitQueueUpdated();
|
|
mainWindow?.webContents.send('download-finished');
|
|
try {
|
|
if (Notification.isSupported()) {
|
|
const completed = downloadQueue.filter(i => i.status === 'completed').length;
|
|
const failed = downloadQueue.filter(i => i.status === 'error').length;
|
|
const notification = new Notification({
|
|
title: 'Twitch VOD Manager',
|
|
body: failed > 0
|
|
? `${completed} Downloads fertig, ${failed} fehlgeschlagen`
|
|
: `${completed} Downloads abgeschlossen`
|
|
});
|
|
// Click brings the app to the foreground AND opens the download
|
|
// folder so the user can immediately see the output files.
|
|
notification.on('click', () => {
|
|
try {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
}
|
|
if (config.download_path && fs.existsSync(config.download_path)) {
|
|
void shell.openPath(config.download_path);
|
|
}
|
|
} catch (e) {
|
|
appendDebugLog('notification-click-failed', String(e));
|
|
}
|
|
});
|
|
notification.show();
|
|
}
|
|
} catch { }
|
|
appendDebugLog('queue-finished', { items: downloadQueue.length });
|
|
}
|
|
|
|
// ==========================================
|
|
// WINDOW CREATION
|
|
// ==========================================
|
|
function createWindow(): void {
|
|
nativeTheme.themeSource = config.theme === 'light' ? 'light' : 'dark';
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1400,
|
|
height: 900,
|
|
minWidth: 1200,
|
|
minHeight: 700,
|
|
title: `Twitch VOD Manager [v${APP_VERSION}]`,
|
|
backgroundColor: '#0e0e10',
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
preload: path.join(__dirname, 'preload.js')
|
|
}
|
|
});
|
|
|
|
if (process.platform !== 'darwin') {
|
|
mainWindow.removeMenu();
|
|
}
|
|
|
|
mainWindow.loadFile(path.join(__dirname, '../src/index.html'));
|
|
|
|
mainWindow.webContents.on('did-finish-load', () => {
|
|
emitQueueUpdated(true);
|
|
if (isDownloading) {
|
|
mainWindow?.webContents.send('download-started');
|
|
}
|
|
|
|
if (autoUpdateReadyToInstall && downloadedUpdateVersion) {
|
|
mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion));
|
|
}
|
|
|
|
// Auto-resume: if the user opted in AND the persisted queue has
|
|
// pending entries, kick off processing after a short delay so the
|
|
// UI has time to render and the user can still pause if they want.
|
|
if (config.auto_resume_queue_on_startup && !isDownloading) {
|
|
const hasPending = downloadQueue.some((it) => it.status === 'pending');
|
|
if (hasPending) {
|
|
appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length });
|
|
setTimeout(() => {
|
|
if (config.auto_resume_queue_on_startup && !isDownloading
|
|
&& downloadQueue.some((it) => it.status === 'pending')) {
|
|
void processQueue();
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
// Setup auto-updater after window is ready
|
|
setTimeout(() => {
|
|
setupAutoUpdater();
|
|
}, 3000);
|
|
}
|
|
|
|
// ==========================================
|
|
// AUTO-UPDATER (electron-updater)
|
|
// ==========================================
|
|
function hasNewerKnownUpdateThanDownloaded(): boolean {
|
|
if (!latestKnownUpdateVersion || !downloadedUpdateVersion) {
|
|
return false;
|
|
}
|
|
|
|
return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion);
|
|
}
|
|
|
|
function normalizeReleaseVersionCandidate(value: unknown): string | undefined {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
return normalizeUpdateVersion(trimmed) || trimmed.replace(/^v/i, '');
|
|
}
|
|
|
|
function cacheLatestReleaseUpdateInfo(releaseData: any): void {
|
|
if (!releaseData || typeof releaseData !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const tagName = typeof releaseData.tag_name === 'string' ? releaseData.tag_name.trim() : '';
|
|
const version = normalizeReleaseVersionCandidate(tagName)
|
|
|| normalizeReleaseVersionCandidate(releaseData.name);
|
|
const releaseName = typeof releaseData.name === 'string' ? releaseData.name.trim() : '';
|
|
const releaseNotes = typeof releaseData.body === 'string' ? releaseData.body : '';
|
|
const releaseDate = typeof releaseData.published_at === 'string'
|
|
? releaseData.published_at
|
|
: (typeof releaseData.created_at === 'string' ? releaseData.created_at : undefined);
|
|
|
|
latestReleaseUpdateInfo = {
|
|
tagName: tagName || undefined,
|
|
version,
|
|
releaseDate,
|
|
releaseName: releaseName || undefined,
|
|
releaseNotes: releaseNotes.trim() ? releaseNotes : undefined
|
|
};
|
|
}
|
|
|
|
function buildUpdateInfoPayload(version: string, releaseDate?: string): {
|
|
version: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
} {
|
|
const normalizedVersion = normalizeReleaseVersionCandidate(version) || version;
|
|
const cachedVersion = latestReleaseUpdateInfo?.version
|
|
? (normalizeReleaseVersionCandidate(latestReleaseUpdateInfo.version) || latestReleaseUpdateInfo.version)
|
|
: undefined;
|
|
const hasMatchingReleaseInfo = !cachedVersion || cachedVersion === normalizedVersion;
|
|
|
|
return {
|
|
version: normalizedVersion,
|
|
releaseDate: releaseDate || (hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseDate : undefined),
|
|
releaseName: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseName : undefined,
|
|
releaseNotes: hasMatchingReleaseInfo ? latestReleaseUpdateInfo?.releaseNotes : undefined
|
|
};
|
|
}
|
|
|
|
async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> {
|
|
if (autoUpdateCheckInProgress) {
|
|
return { started: false, reason: 'in-progress' };
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (!force && lastAutoUpdateCheckAt > 0 && (now - lastAutoUpdateCheckAt) < AUTO_UPDATE_MIN_CHECK_GAP_MS) {
|
|
return { started: false, reason: 'throttled' };
|
|
}
|
|
|
|
autoUpdateCheckInProgress = true;
|
|
lastAutoUpdateCheckAt = now;
|
|
appendDebugLog('update-check-start', { source });
|
|
|
|
try {
|
|
try {
|
|
const giteaRes = await axios.get(GITEA_RELEASES_API_LATEST_URL, {
|
|
timeout: 5000,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Twitch-VOD-Manager'
|
|
}
|
|
});
|
|
cacheLatestReleaseUpdateInfo(giteaRes.data);
|
|
const tagName = latestReleaseUpdateInfo?.tagName || giteaRes.data?.tag_name;
|
|
if (tagName) {
|
|
autoUpdater.setFeedURL({
|
|
provider: 'generic',
|
|
url: `${GITEA_RELEASES_DOWNLOAD_BASE_URL}/${tagName}`
|
|
});
|
|
appendDebugLog('gitea-feed-url-set', { tagName, owner: GITEA_REPO_OWNER, repo: GITEA_REPO_NAME });
|
|
}
|
|
} catch (apiErr) {
|
|
appendDebugLog('gitea-api-failed', String(apiErr));
|
|
}
|
|
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
try {
|
|
await Promise.race([
|
|
autoUpdater.checkForUpdates(),
|
|
new Promise<never>((_, reject) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
reject(new Error(`Update check timed out after ${AUTO_UPDATE_CHECK_TIMEOUT_MS}ms`));
|
|
}, AUTO_UPDATE_CHECK_TIMEOUT_MS);
|
|
})
|
|
]);
|
|
} finally {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
}
|
|
|
|
return { started: true };
|
|
} catch (err) {
|
|
appendDebugLog('update-check-failed', { source, error: String(err) });
|
|
console.error('Update check failed:', err);
|
|
return { started: false, reason: 'error' };
|
|
} finally {
|
|
autoUpdateCheckInProgress = false;
|
|
}
|
|
}
|
|
|
|
async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> {
|
|
if (autoUpdateReadyToInstall && !hasNewerKnownUpdateThanDownloaded()) {
|
|
return { started: false, reason: 'ready-to-install' };
|
|
}
|
|
|
|
if (autoUpdateDownloadInProgress) {
|
|
return { started: false, reason: 'in-progress' };
|
|
}
|
|
|
|
autoUpdateDownloadInProgress = true;
|
|
appendDebugLog('update-download-start', { source });
|
|
|
|
try {
|
|
await autoUpdater.downloadUpdate();
|
|
return { started: true };
|
|
} catch (err) {
|
|
appendDebugLog('update-download-failed', { source, error: String(err) });
|
|
console.error('Download failed:', err);
|
|
return { started: false, reason: 'error' };
|
|
} finally {
|
|
autoUpdateDownloadInProgress = false;
|
|
}
|
|
}
|
|
|
|
function stopAutoUpdatePolling(): void {
|
|
if (autoUpdateCheckTimer) {
|
|
clearInterval(autoUpdateCheckTimer);
|
|
autoUpdateCheckTimer = null;
|
|
}
|
|
|
|
if (autoUpdateStartupTimer) {
|
|
clearTimeout(autoUpdateStartupTimer);
|
|
autoUpdateStartupTimer = null;
|
|
}
|
|
}
|
|
|
|
function startAutoUpdatePolling(): void {
|
|
if (!autoUpdateCheckTimer) {
|
|
autoUpdateCheckTimer = setInterval(() => {
|
|
void requestUpdateCheck('interval');
|
|
}, AUTO_UPDATE_CHECK_INTERVAL_MS);
|
|
|
|
autoUpdateCheckTimer.unref?.();
|
|
}
|
|
|
|
if (autoUpdateStartupTimer) {
|
|
clearTimeout(autoUpdateStartupTimer);
|
|
autoUpdateStartupTimer = null;
|
|
}
|
|
|
|
autoUpdateStartupTimer = setTimeout(() => {
|
|
autoUpdateStartupTimer = null;
|
|
void requestUpdateCheck('startup', true);
|
|
}, AUTO_UPDATE_STARTUP_CHECK_DELAY_MS);
|
|
}
|
|
|
|
function setupAutoUpdater() {
|
|
if (autoUpdaterInitialized) {
|
|
startAutoUpdatePolling();
|
|
return;
|
|
}
|
|
|
|
autoUpdaterInitialized = true;
|
|
autoUpdater.autoDownload = false;
|
|
autoUpdater.autoInstallOnAppQuit = true;
|
|
autoUpdater.autoRunAppAfterInstall = true;
|
|
|
|
autoUpdater.on('checking-for-update', () => {
|
|
console.log('Checking for updates...');
|
|
mainWindow?.webContents.send('update-checking');
|
|
});
|
|
|
|
autoUpdater.on('update-available', (info) => {
|
|
const incomingVersion = normalizeUpdateVersion(info.version);
|
|
const displayVersion = incomingVersion || info.version;
|
|
|
|
if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) {
|
|
appendDebugLog('update-available-ignored-older', {
|
|
incomingVersion: displayVersion,
|
|
knownVersion: latestKnownUpdateVersion
|
|
});
|
|
return;
|
|
}
|
|
|
|
latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion;
|
|
|
|
const hasAlreadyDownloadedThisVersion = Boolean(
|
|
autoUpdateReadyToInstall &&
|
|
downloadedUpdateVersion &&
|
|
compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0
|
|
);
|
|
|
|
console.log('Update available:', displayVersion);
|
|
if (!hasAlreadyDownloadedThisVersion) {
|
|
autoUpdateReadyToInstall = false;
|
|
}
|
|
|
|
autoUpdateDownloadInProgress = false;
|
|
|
|
if (hasAlreadyDownloadedThisVersion) {
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate));
|
|
}
|
|
|
|
if (AUTO_UPDATE_AUTO_DOWNLOAD) {
|
|
void requestUpdateDownload('auto');
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('update-not-available', () => {
|
|
console.log('No updates available');
|
|
mainWindow?.webContents.send('update-not-available');
|
|
});
|
|
|
|
autoUpdater.on('download-progress', (progress) => {
|
|
console.log(`Download progress: ${progress.percent.toFixed(1)}%`);
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-download-progress', {
|
|
percent: progress.percent,
|
|
bytesPerSecond: progress.bytesPerSecond,
|
|
transferred: progress.transferred,
|
|
total: progress.total
|
|
});
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('update-downloaded', (info) => {
|
|
const downloadedVersion = normalizeUpdateVersion(info.version) || info.version;
|
|
console.log('Update downloaded:', downloadedVersion);
|
|
autoUpdateReadyToInstall = true;
|
|
autoUpdateDownloadInProgress = false;
|
|
downloadedUpdateVersion = downloadedVersion;
|
|
if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) {
|
|
latestKnownUpdateVersion = downloadedVersion;
|
|
}
|
|
if (mainWindow) {
|
|
mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedVersion, info.releaseDate));
|
|
}
|
|
});
|
|
|
|
autoUpdater.on('error', (err) => {
|
|
autoUpdateCheckInProgress = false;
|
|
autoUpdateDownloadInProgress = false;
|
|
const message = String(err);
|
|
appendDebugLog('auto-updater-error', message);
|
|
mainWindow?.webContents.send('update-error', { message });
|
|
console.error('Auto-updater error:', err);
|
|
});
|
|
|
|
startAutoUpdatePolling();
|
|
}
|
|
|
|
// ==========================================
|
|
// IPC HANDLERS
|
|
// ==========================================
|
|
ipcMain.handle('get-config', () => config);
|
|
|
|
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|
const previousClientId = config.client_id;
|
|
const previousClientSecret = config.client_secret;
|
|
const previousCacheMinutes = config.metadata_cache_minutes;
|
|
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
|
const previousTheme = config.theme;
|
|
|
|
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);
|
|
}
|
|
|
|
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('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();
|
|
}
|
|
currentDownloadCancelled = true;
|
|
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;
|
|
currentDownloadCancelled = true;
|
|
// Kill queue downloads only — cutter/merger/splitter use currentEditorProcess
|
|
// and aren't affected by pause-download.
|
|
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;
|
|
currentDownloadCancelled = true;
|
|
// Kill queue downloads only — see pause-download note above.
|
|
for (const [id, tracking] of activeDownloads) {
|
|
cancelledItemIds.add(id);
|
|
if (tracking.process) {
|
|
tracking.process.kill();
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('select-folder', async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openDirectory']
|
|
});
|
|
return result.filePaths[0] || null;
|
|
});
|
|
|
|
ipcMain.handle('select-video-file', async () => {
|
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
|
properties: ['openFile'],
|
|
filters: [
|
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'ts', 'mov', 'avi'] }
|
|
]
|
|
});
|
|
return result.filePaths[0] || null;
|
|
});
|
|
|
|
ipcMain.handle('open-folder', (_, folderPath: string) => {
|
|
if (fs.existsSync(folderPath)) {
|
|
shell.openPath(folderPath);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => {
|
|
if (typeof filePath !== 'string' || !filePath) return false;
|
|
if (!fs.existsSync(filePath)) return false;
|
|
const result = await shell.openPath(filePath);
|
|
// shell.openPath returns '' on success, an error string on failure.
|
|
return result === '';
|
|
});
|
|
|
|
ipcMain.handle('show-in-folder', (_, filePath: string): boolean => {
|
|
if (typeof filePath !== 'string' || !filePath) return false;
|
|
if (!fs.existsSync(filePath)) return false;
|
|
shell.showItemInFolder(filePath);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle('get-version', () => APP_VERSION);
|
|
|
|
ipcMain.handle('check-update', async () => {
|
|
try {
|
|
setupAutoUpdater();
|
|
const result = await requestUpdateCheck('manual', true);
|
|
if (result.reason === 'error') {
|
|
return { error: true };
|
|
}
|
|
|
|
return result.started
|
|
? { checking: true }
|
|
: { checking: true, skipped: result.reason };
|
|
} catch (err) {
|
|
console.error('Update check failed:', err);
|
|
return { error: true };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('download-update', async () => {
|
|
try {
|
|
setupAutoUpdater();
|
|
const result = await requestUpdateDownload('manual');
|
|
if (result.reason === 'error') {
|
|
return { error: true };
|
|
}
|
|
|
|
return result.started
|
|
? { downloading: true }
|
|
: { downloading: true, skipped: result.reason };
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
return { error: true };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('install-update', () => {
|
|
autoUpdater.quitAndInstall(true, true);
|
|
});
|
|
|
|
ipcMain.handle('open-external', async (_, url: string) => {
|
|
await shell.openExternal(url);
|
|
});
|
|
|
|
// Tracks active standalone clip downloads so cancel-download / window-all-closed
|
|
// can kill them. Separate from activeDownloads (queue) because clip downloads
|
|
// don't go through the queue scheduler.
|
|
const activeClipProcesses = new Map<string, ChildProcess>();
|
|
|
|
ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
|
let clipId = '';
|
|
const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/);
|
|
const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/);
|
|
|
|
if (match1) clipId = match1[1];
|
|
else if (match2) clipId = match2[1];
|
|
else return { success: false, error: tBackend('invalidClipUrl') };
|
|
|
|
const clipInfo = await getClipInfo(clipId);
|
|
if (!clipInfo) return { success: false, error: tBackend('clipNotFound') };
|
|
|
|
// Sanitize broadcaster_name for path safety — Twitch returns the display
|
|
// name which can contain unicode, spaces, or punctuation that breaks
|
|
// path joining on some Windows configurations.
|
|
const safeBroadcaster = sanitizeFilenamePart(
|
|
typeof clipInfo.broadcaster_name === 'string' ? clipInfo.broadcaster_name : '',
|
|
'unknown'
|
|
);
|
|
const folder = path.join(config.download_path, 'Clips', safeBroadcaster);
|
|
fs.mkdirSync(folder, { recursive: true });
|
|
|
|
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
|
if (!clipDiskCheck.success) {
|
|
return { success: false, error: clipDiskCheck.error || tBackend('diskSpaceShortGeneric') };
|
|
}
|
|
|
|
const rawTitle = typeof clipInfo.title === 'string' ? clipInfo.title : '';
|
|
const safeTitle = (rawTitle.replace(/[^a-zA-Z0-9_\- ]/g, '').trim().substring(0, 50)) || 'clip';
|
|
// Use ensureUniqueFilename so retrying a clip with the same title doesn't
|
|
// overwrite the previous download. itemId is the clipId — if the user
|
|
// cancels via cancel-download, that's the handle.
|
|
const filename = ensureUniqueFilename(path.join(folder, `${safeTitle}.mp4`), clipId);
|
|
|
|
return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => {
|
|
const streamlinkCmd = getStreamlinkCommand();
|
|
const proc = spawn(streamlinkCmd.command, [
|
|
...streamlinkCmd.prefixArgs,
|
|
`https://clips.twitch.tv/${clipId}`,
|
|
'best',
|
|
'-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('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) };
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
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();
|
|
|
|
// 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');
|
|
});
|