The live-status batch poller (60s cadence, every streamer in the watch list) was sending two things on every tick: - `changes` — the diff vs. the previous tick, used by the renderer - `snapshot` — the full Map<login, boolean> serialized as a record Renderer destructures only `changes` (renderer-streamers.ts line 20). The snapshot field was wire-noise. For a typical 30-50 streamer watch list, that snapshot is ~1.5KB of JSON every minute, never read on the other side. Dropped from the broadcast payload. Initial-state sync still works: the renderer's initLiveStatusSubscription calls window.api.getLiveStatusSnapshot() once at boot to pre-fill its map. The broadcast is only for diffs. Also added a short-circuit on the main side: if changes.length === 0 (every streamer's live status matched the cached value this tick), don't broadcast at all. The renderer would just iterate an empty array and trigger a no-op render; saves the wakeup entirely. Type signature updates ride through preload.ts + renderer-globals.d.ts so the API contract stays accurate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
interface AppConfig {
|
|
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?: 'stability' | 'balanced' | 'speed';
|
|
prevent_duplicate_downloads?: boolean;
|
|
persist_queue_on_restart?: boolean;
|
|
metadata_cache_minutes?: number;
|
|
parallel_downloads?: number;
|
|
auto_resume_queue_on_startup?: boolean;
|
|
downloaded_vod_ids?: string[];
|
|
streamlink_quality?: string;
|
|
notify_on_each_completion?: boolean;
|
|
streamlink_disable_ads?: boolean;
|
|
auto_record_streamers?: string[];
|
|
auto_record_poll_seconds?: number;
|
|
download_chat_replay?: boolean;
|
|
capture_live_chat?: boolean;
|
|
discord_webhook_url?: string;
|
|
discord_notify_live_start?: boolean;
|
|
discord_notify_live_end?: boolean;
|
|
discord_notify_vod_complete?: boolean;
|
|
discord_notify_vod_auto_queued?: boolean;
|
|
auto_cleanup_enabled?: boolean;
|
|
auto_cleanup_days?: number;
|
|
auto_cleanup_target?: 'live_only' | 'all';
|
|
auto_cleanup_action?: 'delete' | 'archive';
|
|
log_stream_events?: boolean;
|
|
auto_vod_download_streamers?: string[];
|
|
auto_vod_download_poll_minutes?: number;
|
|
auto_vod_max_age_hours?: number;
|
|
auto_resume_live_recording?: boolean;
|
|
auto_merge_resumed_parts?: boolean;
|
|
delete_parts_after_merge?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface VOD {
|
|
id: string;
|
|
title: string;
|
|
created_at: string;
|
|
duration: string;
|
|
thumbnail_url: string;
|
|
url: string;
|
|
view_count: number;
|
|
stream_id?: string;
|
|
}
|
|
|
|
interface CustomClip {
|
|
startSec: number;
|
|
durationSec: number;
|
|
startPart: number;
|
|
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
|
filenameTemplate?: string;
|
|
}
|
|
|
|
interface MergeGroupItem {
|
|
url: string;
|
|
title: string;
|
|
date: string;
|
|
streamer: string;
|
|
duration_str: string;
|
|
}
|
|
|
|
interface MergeGroup {
|
|
items: MergeGroupItem[];
|
|
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
|
currentItemIndex: number;
|
|
downloadedFiles: Record<number, string>;
|
|
mergedFile?: string;
|
|
splitFiles?: string[];
|
|
totalDurationSec?: number;
|
|
}
|
|
|
|
interface QueueItem {
|
|
id: string;
|
|
title: string;
|
|
url: string;
|
|
date: string;
|
|
streamer: string;
|
|
duration_str: string;
|
|
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
|
progress: number;
|
|
currentPart?: number;
|
|
totalParts?: number;
|
|
speed?: string;
|
|
eta?: string;
|
|
downloadedBytes?: number;
|
|
totalBytes?: number;
|
|
progressStatus?: string;
|
|
last_error?: string;
|
|
customClip?: CustomClip;
|
|
mergeGroup?: MergeGroup;
|
|
outputFiles?: string[];
|
|
isLive?: boolean;
|
|
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
|
}
|
|
|
|
interface DownloadProgress {
|
|
id: string;
|
|
progress: number;
|
|
speed: string;
|
|
speedBytesPerSec?: number;
|
|
eta: string;
|
|
status: string;
|
|
currentPart?: number;
|
|
totalParts?: number;
|
|
downloadedBytes?: number;
|
|
totalBytes?: number;
|
|
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface ClipDialogData {
|
|
url: string;
|
|
title: string;
|
|
date: string;
|
|
streamer: string;
|
|
duration: string;
|
|
}
|
|
|
|
interface UpdateInfo {
|
|
version: string;
|
|
releaseDate?: string;
|
|
releaseName?: string;
|
|
releaseNotes?: string;
|
|
}
|
|
|
|
interface UpdateDownloadProgress {
|
|
percent: number;
|
|
bytesPerSecond: number;
|
|
transferred: number;
|
|
total: number;
|
|
}
|
|
|
|
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 StreamerStorageEntry {
|
|
name: string;
|
|
fileCount: number;
|
|
totalBytes: number;
|
|
liveBytes: number;
|
|
chatBytes: number;
|
|
folderPath: string;
|
|
}
|
|
interface CleanupReport {
|
|
enabled: boolean;
|
|
dryRun: boolean;
|
|
cutoffDays: number;
|
|
target: 'live_only' | 'all';
|
|
action: 'delete' | 'archive';
|
|
scannedAt: string;
|
|
candidates: number;
|
|
processed: number;
|
|
failed: number;
|
|
bytesFreed: number;
|
|
failures: Array<{ path: string; error: string }>;
|
|
}
|
|
interface StorageStatsResult {
|
|
downloadPath: string;
|
|
rootExists: boolean;
|
|
freeBytes: number | null;
|
|
totalFiles: number;
|
|
totalBytes: number;
|
|
streamers: StreamerStorageEntry[];
|
|
extras: StreamerStorageEntry[];
|
|
scannedAt: string;
|
|
}
|
|
|
|
interface StreamerProfile {
|
|
login: string;
|
|
displayName: string;
|
|
avatarUrl: string;
|
|
bannerUrl: string;
|
|
description: string;
|
|
broadcasterType: '' | 'partner' | 'affiliate';
|
|
followerCount: number | null;
|
|
vodCount: number;
|
|
lastStreamAt: string | null;
|
|
isLive: boolean;
|
|
currentTitle: string | null;
|
|
currentGame: string | null;
|
|
currentStreamPreviewUrl: string;
|
|
currentStreamViewers: number | null;
|
|
twitchUrl: string;
|
|
fetchedAt: number;
|
|
}
|
|
|
|
interface VodStoryboard {
|
|
vodId: string;
|
|
spriteDataUrl: string;
|
|
cols: number;
|
|
rows: number;
|
|
cellWidth: number;
|
|
cellHeight: number;
|
|
framesInSprite: number;
|
|
}
|
|
|
|
interface ArchiveSearchHit {
|
|
fullPath: string;
|
|
fileName: string;
|
|
streamer: string;
|
|
type: 'live' | 'vod' | 'chat' | 'events' | 'other';
|
|
size: number;
|
|
mtimeMs: number;
|
|
chatPath: string | null;
|
|
eventsPath: string | null;
|
|
}
|
|
interface ArchiveSearchResult {
|
|
totalScanned: number;
|
|
matchCount: number;
|
|
truncated: boolean;
|
|
hits: ArchiveSearchHit[];
|
|
scannedAt: string;
|
|
rootExists: boolean;
|
|
}
|
|
|
|
interface ArchiveStatsTopStreamer {
|
|
streamer: string;
|
|
bytes: number;
|
|
fileCount: number;
|
|
liveBytes: number;
|
|
vodBytes: number;
|
|
chatBytes: number;
|
|
}
|
|
interface ArchiveStatsDay { date: string; count: number; bytes: number }
|
|
interface ArchiveStatsBucket { label: string; count: number; bytes: number }
|
|
interface ArchiveStats {
|
|
totalFiles: number;
|
|
totalBytes: number;
|
|
liveCount: number;
|
|
liveBytes: number;
|
|
vodCount: number;
|
|
vodBytes: number;
|
|
chatCount: number;
|
|
chatBytes: number;
|
|
eventsCount: number;
|
|
streamerCount: number;
|
|
avgRecordingSizeBytes: number;
|
|
topStreamers: ArchiveStatsTopStreamer[];
|
|
dailyActivity: ArchiveStatsDay[];
|
|
sizeBuckets: ArchiveStatsBucket[];
|
|
scannedAt: string;
|
|
downloadPath: string;
|
|
rootExists: boolean;
|
|
}
|
|
|
|
interface ApiBridge {
|
|
getConfig(): Promise<AppConfig>;
|
|
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
|
|
login(): Promise<boolean>;
|
|
getUserId(username: string): Promise<string | null>;
|
|
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
|
getQueue(): Promise<QueueItem[]>;
|
|
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
|
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
|
removeFromQueue(id: string): Promise<QueueItem[]>;
|
|
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
|
clearCompleted(): Promise<QueueItem[]>;
|
|
retryFailedDownloads(): Promise<QueueItem[]>;
|
|
retryQueueItem(id: string): Promise<QueueItem[]>;
|
|
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
|
startDownload(): Promise<boolean>;
|
|
pauseDownload(): Promise<boolean>;
|
|
cancelDownload(): Promise<boolean>;
|
|
isDownloading(): Promise<boolean>;
|
|
downloadClip(url: string): Promise<{ success: boolean; error?: string }>;
|
|
selectFolder(): Promise<string | null>;
|
|
selectVideoFile(): Promise<string | null>;
|
|
selectMultipleVideos(): Promise<string[] | null>;
|
|
saveVideoDialog(defaultName: string): Promise<string | null>;
|
|
openFolder(path: string): Promise<void>;
|
|
openFile(path: string): Promise<boolean>;
|
|
showInFolder(path: string): Promise<boolean>;
|
|
openDebugLogFile(): Promise<boolean>;
|
|
checkFolderWritable(path: string): Promise<boolean>;
|
|
getStorageStats(): Promise<StorageStatsResult>;
|
|
getArchiveStats(): Promise<ArchiveStats>;
|
|
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
|
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
|
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
|
|
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
|
|
searchArchive(filter: {
|
|
query?: string;
|
|
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
|
streamer?: string;
|
|
sinceMs?: number | null;
|
|
untilMs?: number | null;
|
|
sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
|
limit?: number;
|
|
}): Promise<ArchiveSearchResult>;
|
|
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
|
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
|
getAutomationStatus(): Promise<{
|
|
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
|
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
|
}>;
|
|
triggerAutoVodScan(): Promise<{ queuedCount: number }>;
|
|
triggerAutoRecordScan(): Promise<{ triggered: number }>;
|
|
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
|
|
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
|
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
|
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
|
mergeVideos(inputFiles: string[], outputFile: string): Promise<{ success: boolean; outputFile: string | null }>;
|
|
getVersion(): Promise<string>;
|
|
checkUpdate(): Promise<{ checking?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'throttled' | 'error' | string }>;
|
|
downloadUpdate(): Promise<{ downloading?: boolean; error?: boolean; skipped?: 'ready-to-install' | 'in-progress' | 'error' | string }>;
|
|
installUpdate(): Promise<void>;
|
|
openExternal(url: string): Promise<void>;
|
|
runPreflight(autoFix: boolean): Promise<PreflightResult>;
|
|
getDebugLog(lines: number): Promise<string>;
|
|
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
|
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
|
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
|
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
|
|
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
|
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
|
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
|
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
|
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
|
onDownloadStarted(callback: () => void): void;
|
|
onDownloadFinished(callback: () => void): void;
|
|
onCutProgress(callback: (percent: number) => void): void;
|
|
onMergeProgress(callback: (percent: number) => void): void;
|
|
onUpdateChecking(callback: () => void): void;
|
|
onUpdateAvailable(callback: (info: UpdateInfo) => void): void;
|
|
onUpdateNotAvailable(callback: () => void): void;
|
|
onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void;
|
|
onUpdateDownloaded(callback: (info: UpdateInfo) => void): void;
|
|
onUpdateError(callback: (payload: { message: string }) => void): void;
|
|
}
|
|
|
|
interface Window {
|
|
api: ApiBridge;
|
|
}
|