Compare commits

..

No commits in common. "5098510d5353a4670d909fabcf9b84a34e18614b" and "dc0b92d5a49212e799fb4c21362304e417867aa7" have entirely different histories.

10 changed files with 4 additions and 238 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.6.8", "version": "4.6.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.6.8", "version": "4.6.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.6.8", "version": "4.6.7",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -136,19 +136,6 @@
</div> </div>
</div> </div>
<!-- Chat Replay Viewer Modal -->
<div class="modal-overlay" id="chatViewerModal">
<div class="modal" style="max-width: 800px; height: 80vh; display:flex; flex-direction:column;">
<button class="modal-close" onclick="closeChatViewer()">x</button>
<h2 id="chatViewerTitle" style="margin-top:0;">Chat replay</h2>
<div class="form-row" style="margin-bottom:8px; gap:8px; flex-wrap:wrap; align-items:center;">
<input type="text" id="chatViewerFilter" placeholder="Filter..." oninput="onChatViewerFilterChange()" style="flex:1; min-width:160px; background: var(--bg-card); border:1px solid var(--border-soft); border-radius:6px; padding:6px 10px; color:var(--text); font-size:13px;">
<span id="chatViewerStatus" style="color:var(--text-secondary); font-size:12px;"></span>
</div>
<div id="chatViewerList" style="flex:1; overflow-y:auto; background: var(--bg-main); border:1px solid var(--border-soft); border-radius:6px; padding:8px; font-family: 'Consolas', monospace; font-size: 12px;"></div>
</div>
</div>
<!-- Template Guide Modal --> <!-- Template Guide Modal -->
<div class="modal-overlay" id="templateGuideModal"> <div class="modal-overlay" id="templateGuideModal">
<div class="modal template-guide-modal"> <div class="modal template-guide-modal">

View File

@ -5693,58 +5693,6 @@ ipcMain.handle('run-storage-cleanup', (_, options?: { dryRun?: boolean }): Clean
return runStorageCleanup({ dryRun: options?.dryRun === true }); return runStorageCleanup({ dryRun: options?.dryRun === true });
}); });
// Read a chat-replay (.chat.json) or live-chat (.chat.jsonl) file and
// return a normalized message list the renderer can display directly.
// Caps at 50k messages to stop a runaway file from killing the renderer.
ipcMain.handle('read-chat-file', (_, filePath: string): { success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number } => {
if (typeof filePath !== 'string' || !filePath) return { success: false, error: 'No path' };
if (!fs.existsSync(filePath)) return { success: false, error: 'File not found' };
const MAX_MESSAGES = 50000;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
if (filePath.toLowerCase().endsWith('.jsonl')) {
// JSON Lines (live chat): one object per line, first line may be header
const messages: Array<Record<string, unknown>> = [];
let truncated = false;
const lines = raw.split('\n');
let total = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
if (obj && typeof obj === 'object' && obj.type !== 'header') {
total++;
if (messages.length < MAX_MESSAGES) messages.push(obj);
else truncated = true;
}
} catch { /* skip bad lines */ }
}
return { success: true, format: 'live', messages, truncated, total };
}
// .chat.json (VOD replay) — single object with messages array
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.messages)) {
return { success: false, error: 'Unsupported chat file format' };
}
const total = parsed.messages.length;
const messages = parsed.messages.length > MAX_MESSAGES
? parsed.messages.slice(0, MAX_MESSAGES)
: parsed.messages;
return {
success: true,
format: 'replay',
messages,
truncated: total > MAX_MESSAGES,
total
};
} catch (e) {
return { success: false, error: String(e) };
}
});
ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => { ipcMain.handle('check-folder-writable', (_, folderPath: string): boolean => {
if (typeof folderPath !== 'string' || !folderPath) return false; if (typeof folderPath !== 'string' || !folderPath) return false;
return isDownloadPathWritable(folderPath); return isDownloadPathWritable(folderPath);

View File

@ -91,7 +91,6 @@ contextBridge.exposeInMainWorld('api', {
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path), checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
// Video Cutter // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),

View File

@ -257,7 +257,6 @@ interface ApiBridge {
checkFolderWritable(path: string): Promise<boolean>; checkFolderWritable(path: string): Promise<boolean>;
getStorageStats(): Promise<StorageStatsResult>; getStorageStats(): Promise<StorageStatsResult>;
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>; 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>; getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;

View File

@ -232,11 +232,6 @@ const UI_TEXT_DE = {
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).', openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
outputFilesLabel: '{count} Ausgabedateien', outputFilesLabel: '{count} Ausgabedateien',
retryItem: 'Diesen Eintrag erneut versuchen', retryItem: 'Diesen Eintrag erneut versuchen',
viewChat: 'Chat ansehen',
viewChatLoading: 'Lade Chat...',
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
viewChatCount: '{count} Nachrichten',
viewChatTruncatedSuffix: ' (gekuerzt)',
statusBarSummary: '{downloading} aktiv, {pending} wartet', statusBarSummary: '{downloading} aktiv, {pending} wartet',
ctxMoveTop: 'Nach oben verschieben', ctxMoveTop: 'Nach oben verschieben',
ctxMoveBottom: 'Nach unten verschieben', ctxMoveBottom: 'Nach unten verschieben',

View File

@ -232,11 +232,6 @@ const UI_TEXT_EN = {
openFileFailed: 'Could not open the file (it may have been moved or deleted).', openFileFailed: 'Could not open the file (it may have been moved or deleted).',
outputFilesLabel: '{count} output files', outputFilesLabel: '{count} output files',
retryItem: 'Retry this item', retryItem: 'Retry this item',
viewChat: 'View chat',
viewChatLoading: 'Loading chat...',
viewChatFailed: 'Could not read chat file',
viewChatCount: '{count} messages',
viewChatTruncatedSuffix: ' (truncated)',
statusBarSummary: '{downloading} dl, {pending} queued', statusBarSummary: '{downloading} dl, {pending} queued',
ctxMoveTop: 'Move to top', ctxMoveTop: 'Move to top',
ctxMoveBottom: 'Move to bottom', ctxMoveBottom: 'Move to bottom',

View File

@ -17,14 +17,6 @@ function renderQueueItemFileActions(item: QueueItem): string {
} }
buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`); buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
// Surface a "View chat" button when a sibling chat file exists in the
// outputs list. Single click opens the in-app viewer modal.
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
if (chatFile) {
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '&quot;');
buttons.push(`<button class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
}
const fileLabel = item.outputFiles.length === 1 const fileLabel = item.outputFiles.length === 1
? safeFirst ? safeFirst
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`; : `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;

View File

@ -246,157 +246,8 @@ function openTwitchDevConsole(): void {
void window.api.openExternal('https://dev.twitch.tv/console/apps'); void window.api.openExternal('https://dev.twitch.tv/console/apps');
} }
interface ChatViewerMessage {
t?: string;
type?: string;
u?: string;
user?: string;
login?: string;
color?: string;
msg?: string;
text?: string;
offset?: number;
badges?: string;
bits?: string;
msgId?: string;
systemMsg?: string;
}
let chatViewerMessages: ChatViewerMessage[] = [];
let chatViewerFormat: 'replay' | 'live' = 'replay';
async function openChatViewer(filePath: string, title: string): Promise<void> {
const modal = byId('chatViewerModal');
const list = byId('chatViewerList');
const status = byId('chatViewerStatus');
const filterInput = byId<HTMLInputElement>('chatViewerFilter');
byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat;
list.replaceChildren();
filterInput.value = '';
status.textContent = UI_TEXT.queue.viewChatLoading;
modal.classList.add('show');
const result = await window.api.readChatFile(filePath);
if (!result.success || !Array.isArray(result.messages)) {
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
return;
}
chatViewerMessages = result.messages as ChatViewerMessage[];
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
renderChatViewerList(chatViewerMessages);
}
function closeChatViewer(): void {
byId('chatViewerModal').classList.remove('show');
chatViewerMessages = [];
}
function onChatViewerFilterChange(): void {
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
if (!filter) {
renderChatViewerList(chatViewerMessages);
return;
}
const filtered = chatViewerMessages.filter((m) => {
const u = (m.u || m.user || m.login || '').toLowerCase();
const text = (m.msg || m.text || '').toLowerCase();
return u.includes(filter) || text.includes(filter);
});
renderChatViewerList(filtered);
}
function formatChatTimeMarker(m: ChatViewerMessage): string {
if (chatViewerFormat === 'replay' && typeof m.offset === 'number') {
const total = Math.max(0, Math.floor(m.offset));
const h = Math.floor(total / 3600);
const min = Math.floor((total % 3600) / 60);
const sec = total % 60;
return `${h.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
if (m.t) {
try {
const d = new Date(m.t);
const h = d.getHours().toString().padStart(2, '0');
const min = d.getMinutes().toString().padStart(2, '0');
const sec = d.getSeconds().toString().padStart(2, '0');
return `${h}:${min}:${sec}`;
} catch { /* ignore */ }
}
return '';
}
function renderChatViewerList(messages: ChatViewerMessage[]): void {
const list = byId('chatViewerList');
list.replaceChildren();
// Render in chunks to keep main thread responsive on big files.
const CHUNK = 500;
let idx = 0;
const renderChunk = (): void => {
if (idx >= messages.length) return;
const fragment = document.createDocumentFragment();
const end = Math.min(idx + CHUNK, messages.length);
for (let i = idx; i < end; i++) {
const m = messages[i];
const row = document.createElement('div');
row.style.padding = '2px 0';
row.style.lineHeight = '1.5';
const time = formatChatTimeMarker(m);
if (time) {
const tSpan = document.createElement('span');
tSpan.style.color = 'var(--text-secondary)';
tSpan.style.marginRight = '6px';
tSpan.textContent = `[${time}]`;
row.appendChild(tSpan);
}
const user = m.u || m.user || m.login || '';
if (user) {
const uSpan = document.createElement('span');
uSpan.style.fontWeight = '600';
uSpan.style.color = m.color || 'var(--accent)';
uSpan.style.marginRight = '4px';
uSpan.textContent = `${user}:`;
row.appendChild(uSpan);
}
const msgSpan = document.createElement('span');
msgSpan.textContent = m.msg || m.text || '';
row.appendChild(msgSpan);
// System events (subs, raids, deletions) get a faint bracketed prefix
const isMessageType = m.type === 'msg' || !m.type;
if (!isMessageType) {
const tag = document.createElement('span');
tag.style.color = 'var(--text-secondary)';
tag.style.fontStyle = 'italic';
tag.style.marginRight = '4px';
tag.textContent = `[${m.type}]`;
row.insertBefore(tag, row.firstChild);
}
fragment.appendChild(row);
}
list.appendChild(fragment);
idx = end;
if (idx < messages.length) {
window.setTimeout(renderChunk, 0);
}
};
renderChunk();
}
function closeTopmostOpenModal(): boolean { function closeTopmostOpenModal(): boolean {
// Try each known modal in priority order // Try each known modal in priority order: clip dialog, template guide, update modal
const chatViewerModal = document.getElementById('chatViewerModal');
if (chatViewerModal?.classList.contains('show')) {
closeChatViewer();
return true;
}
const clipModal = document.getElementById('clipModal'); const clipModal = document.getElementById('clipModal');
if (clipModal?.classList.contains('show')) { if (clipModal?.classList.contains('show')) {
closeClipDialog(); closeClipDialog();