Up to now, the app saved chat data (4.6.2 VOD replay, 4.6.3 live
capture) but had no way to view it — users had to open the file in
Notepad or write a custom parser. New in-app modal closes that loop:
queue items with a sibling .chat.json or .chat.jsonl get a "View
chat" button next to Open file / Show in folder; click pops a modal
with a scrollable, filterable, formatted message list.
Server:
- New ipcMain.handle("read-chat-file") parses both formats. JSON
Lines (.jsonl) is split per line, header row skipped, malformed
lines silently dropped — that way a partial / killed live capture
still renders. JSON object (.json) is the VOD replay shape with
messages array. Hard-capped at 50k messages so a multi-day archive
can't kill the renderer; truncation is reported via {truncated,
total} in the result.
Renderer:
- New chatViewerModal in index.html — full-height list with a filter
input + status line.
- openChatViewer(filePath, title) loads the file via IPC, normalises
the message shape (supports both .chat.json and .chat.jsonl
fields), renders in 500-message chunks via setTimeout(0) so the
main thread stays responsive on a 30k-message archive.
- Each row: time marker (offset for replays, wall-clock for live),
user (in their stored color), message text. Non-msg event types
(subs, raids, clears) get a faint italic [type] tag.
- Filter substring-matches user OR text, case-insensitive, instant.
- Esc + outside-click + the close-x dismiss; Esc handler in
closeTopmostOpenModal lists the chat viewer first so a user
with multiple modals open closes the foreground one.
Queue UI:
- renderQueueItemFileActions detects sibling chat files (regex
/\.chat\.json(l)?$/) in item.outputFiles and surfaces the View
chat button. The button is shown for both 4.6.2-style replays
and 4.6.3-style live captures because both formats parse.
DE + EN locales for the button label, loading state, error,
message count, truncation suffix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
9.6 KiB
TypeScript
296 lines
9.6 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;
|
|
auto_cleanup_enabled?: boolean;
|
|
auto_cleanup_days?: number;
|
|
auto_cleanup_target?: 'live_only' | 'all';
|
|
auto_cleanup_action?: 'delete' | 'archive';
|
|
log_stream_events?: 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;
|
|
}
|
|
|
|
interface DownloadProgress {
|
|
id: string;
|
|
progress: number;
|
|
speed: string;
|
|
speedBytesPerSec?: number;
|
|
eta: string;
|
|
status: string;
|
|
currentPart?: number;
|
|
totalParts?: number;
|
|
downloadedBytes?: number;
|
|
totalBytes?: number;
|
|
}
|
|
|
|
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 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>;
|
|
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 }>;
|
|
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;
|
|
}
|