Compare commits

...

2 Commits

Author SHA1 Message Date
xRangerDE
1c5462b7fe release: 4.6.0 live stream recording
Major: each streamer now has a "REC" button. When the channel is
live, click captures into the queue with an open-ended streamlink
recording until the stream ends. Output goes to
{download_path}/{streamer}/live/{streamer}_LIVE_{date}_{time}.mp4.

VODs vanish from Twitch within weeks; this closes the gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:30:09 +02:00
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
11 changed files with 252 additions and 8 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.5.28", "version": "4.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.5.28", "version": "4.6.0",
"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.5.28", "version": "4.6.0",
"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

@ -572,6 +572,10 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null {
if (files.length > 0) item.outputFiles = files; if (files.length > 0) item.outputFiles = files;
} }
if (raw.isLive === true) {
item.isLive = true;
}
const customClip = sanitizeCustomClip(raw.customClip); const customClip = sanitizeCustomClip(raw.customClip);
if (customClip) item.customClip = customClip; if (customClip) item.customClip = customClip;
@ -2127,6 +2131,63 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
}); });
} }
interface LiveStreamInfo {
isLive: boolean;
title?: string;
gameName?: string;
}
// Returns whether the streamer is currently live + a little metadata if
// available. Tries Helix first (better data), falls back to public GQL when
// the user has no client_id/secret configured. A `null` return means we
// couldn't determine — caller should treat as "best-effort".
async function getLiveStreamInfo(login: string): Promise<LiveStreamInfo | null> {
const normalized = normalizeLogin(login);
if (!normalized) return null;
if (await ensureTwitchAuth()) {
try {
const response = await axios.get('https://api.twitch.tv/helix/streams', {
params: { user_login: normalized, first: 1 },
headers: {
'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}`
},
timeout: API_TIMEOUT
});
const entries = response.data?.data || [];
if (entries.length === 0) return { isLive: false };
const e = entries[0];
return {
isLive: e.type === 'live',
title: typeof e.title === 'string' ? e.title : undefined,
gameName: typeof e.game_name === 'string' ? e.game_name : undefined
};
} catch (e) {
appendDebugLog('helix-streams-failed', { login: normalized, error: String(e) });
// fall through to public GQL
}
}
type StreamQueryResult = {
user: {
stream: { id: string; type: string; title?: string; game?: { name?: string } } | null;
} | null;
};
const data = await fetchPublicTwitchGql<StreamQueryResult>(
'query($login:String!){ user(login:$login){ stream{ id type title game{ name } } } }',
{ login: normalized }
);
if (!data) return null;
const stream = data.user?.stream;
if (!stream) return { isLive: false };
return {
isLive: stream.type === 'live',
title: stream.title,
gameName: stream.game?.name
};
}
async function getClipInfo(clipId: string): Promise<any | null> { async function getClipInfo(clipId: string): Promise<any | null> {
const cachedClip = getCachedValue(clipInfoCache, clipId); const cachedClip = getCachedValue(clipInfoCache, clipId);
if (cachedClip !== undefined) { if (cachedClip !== undefined) {
@ -2780,10 +2841,55 @@ function downloadVODPart(
}); });
} }
async function downloadLiveStream(
item: QueueItem,
onProgress: (progress: DownloadProgress) => void
): Promise<DownloadResult> {
const streamlinkReady = await ensureStreamlinkInstalled();
if (!streamlinkReady) {
return { success: false, error: tBackend('streamlinkAutoInstallFailed') };
}
onProgress({
id: item.id,
progress: -1,
speed: '',
eta: '',
status: tBackend('statusDownloadStarted'),
currentPart: 0,
totalParts: 0
});
const safeStreamer = (item.streamer || 'live').replace(/[^a-zA-Z0-9_-]/g, '');
const now = new Date();
const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`;
const timeStr = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`;
const folder = path.join(config.download_path, safeStreamer, 'live');
fs.mkdirSync(folder, { recursive: true });
const filename = ensureUniqueFilename(
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
item.id
);
// 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);
return result.success ? { ...result, outputFiles: [filename] } : result;
}
async function downloadVOD( async function downloadVOD(
item: QueueItem, item: QueueItem,
onProgress: (progress: DownloadProgress) => void onProgress: (progress: DownloadProgress) => void
): Promise<DownloadResult> { ): Promise<DownloadResult> {
// Live-recording branch: URL is the channel page, no VOD id, no time
// window. Streamlink runs until the stream ends, then we treat the
// whole capture as a single output file.
if (item.isLive) {
return await downloadLiveStream(item, onProgress);
}
const vodId = parseVodId(item.url); const vodId = parseVodId(item.url);
if (!isLikelyVodUrl(item.url) || !vodId) { if (!isLikelyVodUrl(item.url) || !vodId) {
return { return {
@ -3923,6 +4029,50 @@ ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = fal
ipcMain.handle('get-queue', () => downloadQueue); ipcMain.handle('get-queue', () => downloadQueue);
ipcMain.handle('start-live-recording', async (_, streamerName: string) => {
if (typeof streamerName !== 'string' || !streamerName) {
return { success: false, error: 'Invalid streamer name' };
}
const login = normalizeLogin(streamerName);
if (!login) return { success: false, error: 'Invalid streamer name' };
const liveInfo = await getLiveStreamInfo(login);
if (liveInfo === null) {
return { success: false, error: 'Could not check live status. Try again.' };
}
if (!liveInfo.isLive) {
return { success: false, error: 'OFFLINE', streamer: login };
}
const channelUrl = `https://www.twitch.tv/${login}`;
const liveItem: QueueItem = {
id: generateQueueItemId(),
title: liveInfo.title || `${login} (LIVE)`,
url: channelUrl,
date: new Date().toISOString(),
streamer: login,
duration_str: '0s', // unknown — stream is in progress
status: 'pending',
progress: 0,
isLive: true
};
// Duplicate guard — refuse to start a second live recording of the
// same channel while one is already active or pending.
const dup = downloadQueue.some((it) => it.isLive && it.streamer === login
&& (it.status === 'pending' || it.status === 'downloading'));
if (dup) {
return { success: false, error: 'ALREADY_RECORDING', streamer: login };
}
downloadQueue.push(liveItem);
saveQueue(downloadQueue);
emitQueueUpdated();
if (!isDownloading) void processQueue();
appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title });
return { success: true, streamer: login, title: liveInfo.title || login };
});
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => { ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
runtimeMetrics.duplicateSkips += 1; runtimeMetrics.duplicateSkips += 1;

View File

@ -64,6 +64,7 @@ contextBridge.exposeInMainWorld('api', {
// Queue // Queue
getQueue: () => ipcRenderer.invoke('get-queue'), getQueue: () => ipcRenderer.invoke('get-queue'),
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item), 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), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
clearCompleted: () => ipcRenderer.invoke('clear-completed'), clearCompleted: () => ipcRenderer.invoke('clear-completed'),

View File

@ -81,6 +81,7 @@ interface QueueItem {
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup; mergeGroup?: MergeGroup;
outputFiles?: string[]; outputFiles?: string[];
isLive?: boolean;
} }
interface DownloadProgress { interface DownloadProgress {
@ -188,6 +189,7 @@ interface ApiBridge {
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>; getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
getQueue(): Promise<QueueItem[]>; getQueue(): Promise<QueueItem[]>;
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): 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[]>; removeFromQueue(id: string): Promise<QueueItem[]>;
reorderQueue(orderIds: string[]): Promise<QueueItem[]>; reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
clearCompleted(): Promise<QueueItem[]>; clearCompleted(): Promise<QueueItem[]>;

View File

@ -197,7 +197,15 @@ const UI_TEXT_DE = {
ctxCopyUrl: 'URL kopieren', ctxCopyUrl: 'URL kopieren',
ctxOpenOnTwitch: 'Auf Twitch oeffnen', ctxOpenOnTwitch: 'Auf Twitch oeffnen',
ctxRemove: 'Aus Queue entfernen', ctxRemove: 'Aus Queue entfernen',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.' ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet'
},
streamers: {
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
liveRecordingOffline: '{streamer} ist gerade offline.',
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.',
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden'
}, },
vods: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',

View File

@ -197,7 +197,15 @@ const UI_TEXT_EN = {
ctxCopyUrl: 'Copy URL', ctxCopyUrl: 'Copy URL',
ctxOpenOnTwitch: 'Open on Twitch', ctxOpenOnTwitch: 'Open on Twitch',
ctxRemove: 'Remove from queue', ctxRemove: 'Remove from queue',
ctxCopiedUrl: 'URL copied to clipboard.' ctxCopiedUrl: 'URL copied to clipboard.',
liveRecordingTitle: 'Live recording — captures until the stream ends'
},
streamers: {
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
liveRecordingStarted: 'Live recording started for {streamer}.',
liveRecordingOffline: '{streamer} is offline right now.',
liveRecordingAlreadyActive: 'Already recording {streamer}.',
liveRecordingFailed: 'Could not start live recording'
}, },
vods: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',

View File

@ -499,12 +499,15 @@ function renderQueue(): void {
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : ''; const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
const isMergeGroup = !!item.mergeGroup; const isMergeGroup = !!item.mergeGroup;
const showSelector = item.status === 'pending' && !isMergeGroup; const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
const selectionIndex = selectedQueueIds.indexOf(item.id); const selectionIndex = selectedQueueIds.indexOf(item.id);
const isSelected = selectionIndex >= 0; const isSelected = selectionIndex >= 0;
const mergeIcon = isMergeGroup const mergeIcon = isMergeGroup
? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> ' ? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: ''; : '';
const liveBadge = item.isLive
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
: '';
const mergeMetaExtra = isMergeGroup const mergeMetaExtra = isMergeGroup
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
: ''; : '';
@ -518,7 +521,7 @@ function renderQueue(): void {
<div class="status ${item.status}"></div> <div class="status ${item.status}"></div>
<div class="queue-main"> <div class="queue-main">
<div class="queue-title-row"> <div class="queue-title-row">
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${mergeIcon}${isClip}${safeTitle}</div> <div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${liveBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div> <div class="queue-status-label">${safeStatusLabel}</div>
</div> </div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div> <div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>

View File

@ -406,6 +406,16 @@ function renderStreamers(): void {
const nameSpan = document.createElement('span'); const nameSpan = document.createElement('span');
nameSpan.textContent = streamer; nameSpan.textContent = streamer;
// Live-record button — small red dot, only triggers a live capture
// when the streamer is currently online (server checks via Helix).
const recBtn = document.createElement('span');
recBtn.className = 'streamer-rec';
recBtn.textContent = 'REC';
recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now';
recBtn.addEventListener('click', (e) => {
e.stopPropagation();
void triggerLiveRecording(streamer);
});
const removeSpan = document.createElement('span'); const removeSpan = document.createElement('span');
removeSpan.className = 'remove'; removeSpan.className = 'remove';
removeSpan.textContent = 'x'; removeSpan.textContent = 'x';
@ -413,7 +423,7 @@ function renderStreamers(): void {
e.stopPropagation(); e.stopPropagation();
void removeStreamer(streamer); void removeStreamer(streamer);
}); });
item.append(nameSpan, removeSpan); item.append(nameSpan, recBtn, removeSpan);
item.addEventListener('click', () => { item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend // Skip click if drag was just released — drop fires after dragend
@ -831,6 +841,25 @@ function clearVodSelection(): void {
if (lastLoadedStreamer) renderVodGridFromCurrentState(); if (lastLoadedStreamer) renderVodGridFromCurrentState();
} }
async function triggerLiveRecording(streamer: string): Promise<void> {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
const result = await window.api.startLiveRecording(streamer);
if (!toast) return;
if (result.success) {
toast(UI_TEXT.streamers.liveRecordingStarted.replace('{streamer}', streamer), 'info');
return;
}
if (result.error === 'OFFLINE') {
toast(UI_TEXT.streamers.liveRecordingOffline.replace('{streamer}', streamer), 'warn');
return;
}
if (result.error === 'ALREADY_RECORDING') {
toast(UI_TEXT.streamers.liveRecordingAlreadyActive.replace('{streamer}', streamer), 'warn');
return;
}
toast(UI_TEXT.streamers.liveRecordingFailed + (result.error ? `: ${result.error}` : ''), 'warn');
}
async function bulkMarkSelectedDownloaded(mark: boolean): Promise<void> { async function bulkMarkSelectedDownloaded(mark: boolean): Promise<void> {
const urls = Array.from(selectedVodUrls); const urls = Array.from(selectedVodUrls);
if (urls.length === 0) return; if (urls.length === 0) return;

View File

@ -625,6 +625,43 @@ body {
opacity: 0.4; opacity: 0.4;
} }
.streamer-rec {
margin-left: auto;
margin-right: 6px;
color: #ff4444;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
cursor: pointer;
padding: 2px 5px;
border: 1px solid rgba(255, 68, 68, 0.4);
border-radius: 3px;
background: transparent;
transition: background 0.15s;
}
.streamer-rec:hover {
background: rgba(255, 68, 68, 0.15);
}
.queue-live-badge {
display: inline-block;
background: #ff4444;
color: white;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 3px;
vertical-align: middle;
animation: queue-live-pulse 1.5s ease-in-out infinite;
}
@keyframes queue-live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.vod-thumbnail { .vod-thumbnail {
width: 100%; width: 100%;
aspect-ratio: 16/9; aspect-ratio: 16/9;

View File

@ -46,6 +46,12 @@ export interface QueueItem {
// for parts/merge-group splits). Persisted with the queue so completed // for parts/merge-group splits). Persisted with the queue so completed
// items keep their "Open file" / "Show in folder" actions across restarts. // items keep their "Open file" / "Show in folder" actions across restarts.
outputFiles?: string[]; outputFiles?: string[];
// Live stream recording — when true, item.url is the channel URL
// (https://twitch.tv/{streamer}) and streamlink runs until the stream
// ends instead of using --hls-start-offset / --hls-duration. The output
// filename includes a timestamp so consecutive live recordings of the
// same streamer don't collide.
isLive?: boolean;
} }
export interface DownloadProgress { export interface DownloadProgress {