Compare commits
No commits in common. "0ab3780ab17038f1f8953a21a120152ccca2dc84" and "81c775a92efbcb66b9ebdcd144b80ef754a80f07" have entirely different histories.
0ab3780ab1
...
81c775a92e
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.3",
|
||||
"version": "4.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.3",
|
||||
"version": "4.6.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.3",
|
||||
"version": "4.6.2",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
|
||||
@ -531,10 +531,6 @@
|
||||
<input type="checkbox" id="downloadChatReplayToggle">
|
||||
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||
<input type="checkbox" id="captureLiveChatToggle">
|
||||
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||
|
||||
205
src/main.ts
205
src/main.ts
@ -2,7 +2,6 @@ import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification }
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
||||
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
||||
import axios from 'axios';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
||||
@ -216,7 +215,6 @@ interface Config {
|
||||
auto_record_streamers: string[];
|
||||
auto_record_poll_seconds: number;
|
||||
download_chat_replay: boolean;
|
||||
capture_live_chat: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeMetrics {
|
||||
@ -336,8 +334,7 @@ const defaultConfig: Config = {
|
||||
streamlink_disable_ads: true,
|
||||
auto_record_streamers: [],
|
||||
auto_record_poll_seconds: 90,
|
||||
download_chat_replay: false,
|
||||
capture_live_chat: false
|
||||
download_chat_replay: false
|
||||
};
|
||||
|
||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||
@ -435,8 +432,7 @@ function normalizeConfigTemplates(input: Config): Config {
|
||||
streamlink_disable_ads: input.streamlink_disable_ads !== false,
|
||||
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
|
||||
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
||||
download_chat_replay: input.download_chat_replay === true,
|
||||
capture_live_chat: input.capture_live_chat === true
|
||||
download_chat_replay: input.download_chat_replay === true
|
||||
};
|
||||
}
|
||||
|
||||
@ -3075,180 +3071,6 @@ function chatReplayPathFor(vodFilePath: string): string {
|
||||
return `${base}.chat.json`;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LIVE CHAT CAPTURE (during live recording)
|
||||
// ==========================================
|
||||
// Companion to fetchVodChatReplay: while a stream is being recorded live,
|
||||
// open an anonymous IRC connection to Twitch chat and append every message
|
||||
// to a sibling .chat.jsonl file. Format is JSON Lines (one JSON object per
|
||||
// line) so a partial / killed write still parses correctly — important
|
||||
// because live recordings can run for many hours and we don't want to
|
||||
// keep the full chat in memory.
|
||||
interface LiveChatSession {
|
||||
streamer: string;
|
||||
outputPath: string;
|
||||
socket: TLSSocket;
|
||||
fileHandle: number | null;
|
||||
closing: boolean;
|
||||
messageCount: number;
|
||||
buffer: string;
|
||||
}
|
||||
|
||||
const TWITCH_IRC_HOST = 'irc.chat.twitch.tv';
|
||||
const TWITCH_IRC_PORT = 6697;
|
||||
|
||||
function liveChatPathFor(videoPath: string): string {
|
||||
const ext = path.extname(videoPath);
|
||||
const base = ext ? videoPath.slice(0, -ext.length) : videoPath;
|
||||
return `${base}.chat.jsonl`;
|
||||
}
|
||||
|
||||
function startLiveChatCapture(streamer: string, outputPath: string): LiveChatSession | null {
|
||||
const channelName = normalizeLogin(streamer);
|
||||
if (!channelName) return null;
|
||||
|
||||
let fd: number;
|
||||
try {
|
||||
fd = fs.openSync(outputPath, 'w');
|
||||
} catch (e) {
|
||||
appendDebugLog('chat-capture-open-failed', { streamer: channelName, outputPath, error: String(e) });
|
||||
return null;
|
||||
}
|
||||
|
||||
const session: LiveChatSession = {
|
||||
streamer: channelName,
|
||||
outputPath,
|
||||
socket: tlsConnect({ host: TWITCH_IRC_HOST, port: TWITCH_IRC_PORT, servername: TWITCH_IRC_HOST }),
|
||||
fileHandle: fd,
|
||||
closing: false,
|
||||
messageCount: 0,
|
||||
buffer: ''
|
||||
};
|
||||
|
||||
// Write a header line so the file is self-describing even if zero
|
||||
// messages arrive (e.g. silent stream, immediate disconnect).
|
||||
const header = {
|
||||
type: 'header',
|
||||
streamer: channelName,
|
||||
startedAt: new Date().toISOString(),
|
||||
format: 'twitch-vod-manager-chat-jsonl-v1'
|
||||
};
|
||||
try { fs.writeSync(fd, JSON.stringify(header) + '\n'); } catch { /* ignore */ }
|
||||
|
||||
session.socket.on('secureConnect', () => {
|
||||
// Anonymous Twitch IRC: any nick prefixed with "justinfan" is
|
||||
// accepted without a password. Random suffix avoids collisions.
|
||||
const nick = `justinfan${Math.floor(Math.random() * 100000)}`;
|
||||
try {
|
||||
session.socket.write('CAP REQ :twitch.tv/tags twitch.tv/commands\r\n');
|
||||
session.socket.write(`NICK ${nick}\r\n`);
|
||||
session.socket.write(`JOIN #${channelName}\r\n`);
|
||||
} catch (e) {
|
||||
appendDebugLog('chat-capture-handshake-failed', { streamer: channelName, error: String(e) });
|
||||
}
|
||||
appendDebugLog('chat-capture-connected', { streamer: channelName, nick });
|
||||
});
|
||||
|
||||
session.socket.on('data', (chunk: Buffer) => {
|
||||
session.buffer += chunk.toString('utf-8');
|
||||
const lines = session.buffer.split('\r\n');
|
||||
session.buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
handleIrcLine(session, line);
|
||||
}
|
||||
});
|
||||
|
||||
session.socket.on('error', (err: Error) => {
|
||||
appendDebugLog('chat-capture-socket-error', { streamer: channelName, error: String(err) });
|
||||
});
|
||||
|
||||
session.socket.on('close', () => {
|
||||
if (!session.closing) {
|
||||
appendDebugLog('chat-capture-disconnected', { streamer: channelName, messages: session.messageCount });
|
||||
}
|
||||
if (session.fileHandle !== null) {
|
||||
try { fs.closeSync(session.fileHandle); } catch { /* ignore */ }
|
||||
session.fileHandle = null;
|
||||
}
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function handleIrcLine(session: LiveChatSession, line: string): void {
|
||||
if (!line) return;
|
||||
if (line.startsWith('PING')) {
|
||||
try { session.socket.write('PONG' + line.slice(4) + '\r\n'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
let rest = line;
|
||||
let tagsStr = '';
|
||||
if (rest.startsWith('@')) {
|
||||
const sp = rest.indexOf(' ');
|
||||
if (sp < 0) return;
|
||||
tagsStr = rest.slice(1, sp);
|
||||
rest = rest.slice(sp + 1);
|
||||
}
|
||||
let prefix = '';
|
||||
if (rest.startsWith(':')) {
|
||||
const sp = rest.indexOf(' ');
|
||||
if (sp < 0) return;
|
||||
prefix = rest.slice(1, sp);
|
||||
rest = rest.slice(sp + 1);
|
||||
}
|
||||
const cmdSp = rest.indexOf(' ');
|
||||
const command = cmdSp < 0 ? rest : rest.slice(0, cmdSp);
|
||||
const params = cmdSp < 0 ? '' : rest.slice(cmdSp + 1);
|
||||
|
||||
if (command !== 'PRIVMSG' && command !== 'USERNOTICE' && command !== 'CLEARCHAT' && command !== 'CLEARMSG') return;
|
||||
|
||||
const colonIdx = params.indexOf(' :');
|
||||
const text = colonIdx >= 0 ? params.slice(colonIdx + 2) : '';
|
||||
|
||||
const tags: Record<string, string> = {};
|
||||
if (tagsStr) {
|
||||
for (const pair of tagsStr.split(';')) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
tags[pair.slice(0, eq)] = pair.slice(eq + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const login = (prefix.split('!')[0] || tags['login'] || '').toLowerCase();
|
||||
const message = {
|
||||
t: new Date().toISOString(),
|
||||
type: command === 'PRIVMSG' ? 'msg' : (command === 'USERNOTICE' ? 'notice' : command.toLowerCase()),
|
||||
u: tags['display-name'] || login,
|
||||
login,
|
||||
color: tags['color'] || '',
|
||||
msg: text,
|
||||
badges: tags['badges'] || '',
|
||||
bits: tags['bits'] || '',
|
||||
msgId: tags['msg-id'] || '',
|
||||
systemMsg: (tags['system-msg'] || '').replace(/\\s/g, ' ')
|
||||
};
|
||||
|
||||
if (session.fileHandle === null) return;
|
||||
try {
|
||||
fs.writeSync(session.fileHandle, JSON.stringify(message) + '\n');
|
||||
session.messageCount++;
|
||||
} catch (e) {
|
||||
appendDebugLog('chat-capture-write-failed', { error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
function stopLiveChatCapture(session: LiveChatSession): void {
|
||||
if (session.closing) return;
|
||||
session.closing = true;
|
||||
appendDebugLog('chat-capture-stopping', { streamer: session.streamer, messages: session.messageCount });
|
||||
try { session.socket.write(`PART #${session.streamer}\r\nQUIT\r\n`); } catch { /* ignore */ }
|
||||
try { session.socket.end(); } catch { /* ignore */ }
|
||||
setTimeout(() => {
|
||||
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function downloadLiveStream(
|
||||
item: QueueItem,
|
||||
onProgress: (progress: DownloadProgress) => void
|
||||
@ -3280,32 +3102,11 @@ async function downloadLiveStream(
|
||||
item.id
|
||||
);
|
||||
|
||||
// Optional: anonymous IRC chat capture for the duration of the
|
||||
// recording. Sibling .chat.jsonl file. We start it BEFORE streamlink
|
||||
// so the very first chat lines after JOIN aren't dropped, and stop it
|
||||
// AFTER streamlink exits so trailing messages (e.g. "stream offline"
|
||||
// user reactions) are still captured.
|
||||
let chatSession: LiveChatSession | null = null;
|
||||
if (config.capture_live_chat) {
|
||||
const chatPath = liveChatPathFor(filename);
|
||||
chatSession = startLiveChatCapture(item.streamer, chatPath);
|
||||
}
|
||||
|
||||
// No start/end times for live streams — streamlink records until the
|
||||
// stream actually ends or we kill it. downloadVODPart already handles
|
||||
// null start/end correctly.
|
||||
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
|
||||
|
||||
if (chatSession) {
|
||||
stopLiveChatCapture(chatSession);
|
||||
}
|
||||
|
||||
if (!result.success) return result;
|
||||
const outputs = [filename];
|
||||
if (chatSession && fs.existsSync(chatSession.outputPath)) {
|
||||
outputs.push(chatSession.outputPath);
|
||||
}
|
||||
return { ...result, outputFiles: outputs };
|
||||
return result.success ? { ...result, outputFiles: [filename] } : result;
|
||||
}
|
||||
|
||||
async function downloadVOD(
|
||||
|
||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -24,7 +24,6 @@ interface AppConfig {
|
||||
auto_record_streamers?: string[];
|
||||
auto_record_poll_seconds?: number;
|
||||
download_chat_replay?: boolean;
|
||||
capture_live_chat?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@ -75,8 +75,6 @@ const UI_TEXT_DE = {
|
||||
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
|
||||
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
|
||||
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.',
|
||||
captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).',
|
||||
streamlinkQualityLabel: 'Stream-Qualitaet',
|
||||
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
|
||||
streamlinkQualityBest: 'Best (Standard)',
|
||||
|
||||
@ -75,8 +75,6 @@ const UI_TEXT_EN = {
|
||||
streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.',
|
||||
downloadChatReplayLabel: 'Save chat replay alongside each VOD (.chat.json)',
|
||||
downloadChatReplayHint: 'After a VOD download completes, fetches the public chat replay via Twitch GQL and saves it as JSON next to the video. Twitch keeps chat replay only as long as the VOD itself.',
|
||||
captureLiveChatLabel: 'Capture live chat during recording (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Opens an anonymous IRC connection to Twitch chat during a live recording and appends every message to a sibling .chat.jsonl file (JSON Lines, one message per line) so a long capture can be killed mid-stream without corrupting earlier data.',
|
||||
streamlinkQualityLabel: 'Stream quality',
|
||||
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
|
||||
streamlinkQualityBest: 'Best (default)',
|
||||
|
||||
@ -390,7 +390,6 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
||||
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
||||
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
||||
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
||||
};
|
||||
@ -438,7 +437,6 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
effective.notify_on_each_completion === true,
|
||||
effective.streamlink_disable_ads !== false,
|
||||
effective.download_chat_replay === true,
|
||||
effective.capture_live_chat === true,
|
||||
effective.streamlink_quality ?? 'best',
|
||||
effective.metadata_cache_minutes ?? 10,
|
||||
effective.filename_template_vod ?? '{title}.mp4',
|
||||
@ -461,7 +459,6 @@ function syncSettingsFormFromConfig(): void {
|
||||
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
||||
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
||||
@ -578,7 +575,6 @@ function initSettingsAutoSave(): void {
|
||||
'notifyEachCompletionToggle',
|
||||
'streamlinkDisableAdsToggle',
|
||||
'downloadChatReplayToggle',
|
||||
'captureLiveChatToggle',
|
||||
'streamlinkQuality'
|
||||
] as const;
|
||||
|
||||
|
||||
@ -126,9 +126,6 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel);
|
||||
setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint);
|
||||
setTitle('downloadChatReplayToggle', UI_TEXT.static.downloadChatReplayHint);
|
||||
setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel);
|
||||
setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint);
|
||||
setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint);
|
||||
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
|
||||
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
|
||||
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user