Twitch-VOD-Manager/src/preload.ts
xRangerDE 56261216a9 feat: live stream recording — record streamers as they go live
VODs disappear from Twitch after 7-60 days depending on the channel
partnership tier. Anyone serious about archiving needs to capture
streams while they are still live, not after. The downloader is now
a recorder too.

End-user surface:
- Each streamer in the sidebar has a small red "REC" pill next to
  the remove-x. Click it -> server checks Helix (or public GQL when
  no client_id is configured) for live status. If the channel is
  online a new queue item is added with isLive: true, status:
  pending; the existing queue scheduler picks it up. Toast feedback
  for offline / already-recording / generic-failure cases.
- Live items render with a pulsing red REC badge in the queue title
  row and skip the bulk-select checkbox + the merge-group selector
  (they don't make sense for an open-ended capture).
- Output goes to {download_path}/{streamer}/live/
  {streamer}_LIVE_{YYYY-MM-DD}_{HH-mm-ss}.mp4 — timestamped so back-
  to-back recordings of the same channel never collide.
- Streamlink runs without --hls-start-offset / --hls-duration so it
  records until the stream actually ends or the user hits cancel /
  remove. The existing per-item filename claim, integrity check on
  close, and downloaded_vod_ids tracking apply unchanged (live
  recordings are not added to downloaded_vod_ids since they have
  no Twitch VOD ID).

Server plumbing:
- New getLiveStreamInfo(login) helper. Helix /streams when an app
  token is available (better metadata: title + game), public GQL
  fallback otherwise so users in public-mode still get live status.
- New IPC start-live-recording(streamerName) does the live check,
  refuses with ALREADY_RECORDING if a live item for the same
  channel is already pending or downloading.
- downloadVOD branches into a small downloadLiveStream helper when
  item.isLive — computes the timestamped filename, ensures the
  per-streamer/live folder exists, hands off to downloadVODPart
  with null start/end times.
- sanitizeQueueItem preserves the isLive flag across queue file
  reload so a recording in progress survives an app restart in
  state (though streamlink itself dies on app exit and the user
  has to re-trigger).

DE + EN locale strings for every toast + tooltip + the queue badge.
CSS animation for the pulsing badge so it visually distinguishes
live recordings from regular VOD downloads at a glance.

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

166 lines
7.7 KiB
TypeScript

import { contextBridge, ipcRenderer } from 'electron';
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
// Types
interface RuntimeMetricsSnapshot {
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: string | null;
lastRetryDelaySeconds: number;
timestamp: string;
queue: {
pending: number;
downloading: number;
paused: number;
completed: number;
error: number;
total: number;
};
caches: {
loginToUserId: number;
vodList: number;
clipInfo: number;
};
config: {
performanceMode: 'stability' | 'balanced' | 'speed';
smartScheduler: boolean;
metadataCacheMinutes: number;
duplicatePrevention: boolean;
};
}
interface VideoInfo {
duration: number;
width: number;
height: number;
fps: number;
}
// Expose protected methods to renderer
contextBridge.exposeInMainWorld('api', {
// Config
getConfig: () => ipcRenderer.invoke('get-config'),
saveConfig: (config: any) => ipcRenderer.invoke('save-config', config),
// Auth
login: () => ipcRenderer.invoke('login'),
// Twitch API
getUserId: (username: string) => ipcRenderer.invoke('get-user-id', username),
getVODs: (userId: string, forceRefresh: boolean = false) => ipcRenderer.invoke('get-vods', userId, forceRefresh),
// Queue
getQueue: () => ipcRenderer.invoke('get-queue'),
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id),
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
// Download
startDownload: () => ipcRenderer.invoke('start-download'),
pauseDownload: () => ipcRenderer.invoke('pause-download'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
isDownloading: () => ipcRenderer.invoke('is-downloading'),
downloadClip: (url: string) => ipcRenderer.invoke('download-clip', url),
// Files
selectFolder: () => ipcRenderer.invoke('select-folder'),
selectVideoFile: () => ipcRenderer.invoke('select-video-file'),
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
openFile: (path: string) => ipcRenderer.invoke('open-file', path),
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
// Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
extractFrame: (filePath: string, timeSeconds: number): Promise<string | null> => ipcRenderer.invoke('extract-frame', filePath, timeSeconds),
cutVideo: (inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }> =>
ipcRenderer.invoke('cut-video', inputFile, startTime, endTime),
// Merge Videos
mergeVideos: (inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }> =>
ipcRenderer.invoke('merge-videos', inputFiles, outputFile),
// App
getVersion: () => ipcRenderer.invoke('get-version'),
checkUpdate: () => ipcRenderer.invoke('check-update'),
downloadUpdate: () => ipcRenderer.invoke('download-update'),
installUpdate: () => ipcRenderer.invoke('install-update'),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('export-runtime-metrics'),
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
ipcRenderer.invoke('reset-downloaded-vod-ids'),
markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> =>
ipcRenderer.invoke('mark-vod-downloaded', vodId, mark),
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('export-config'),
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('import-config'),
// Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
ipcRenderer.on('download-progress', (_, progress) => callback(progress));
},
onQueueUpdated: (callback: (queue: QueueItem[]) => void) => {
ipcRenderer.on('queue-updated', (_, queue) => callback(queue));
},
onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => {
ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload));
},
onDownloadStarted: (callback: () => void) => {
ipcRenderer.on('download-started', () => callback());
},
onDownloadFinished: (callback: () => void) => {
ipcRenderer.on('download-finished', () => callback());
},
onCutProgress: (callback: (percent: number) => void) => {
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
},
onMergeProgress: (callback: (percent: number) => void) => {
ipcRenderer.on('merge-progress', (_, percent) => callback(percent));
},
// Auto-Update Events
onUpdateChecking: (callback: () => void) => {
ipcRenderer.on('update-checking', () => callback());
},
onUpdateAvailable: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
ipcRenderer.on('update-available', (_, info) => callback(info));
},
onUpdateNotAvailable: (callback: () => void) => {
ipcRenderer.on('update-not-available', () => callback());
},
onUpdateDownloadProgress: (callback: (progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => void) => {
ipcRenderer.on('update-download-progress', (_, progress) => callback(progress));
},
onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => {
ipcRenderer.on('update-downloaded', (_, info) => callback(info));
},
onUpdateError: (callback: (payload: { message: string }) => void) => {
ipcRenderer.on('update-error', (_, payload) => callback(payload));
}
});