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>
This commit is contained in:
parent
49200f4ca6
commit
56261216a9
150
src/main.ts
150
src/main.ts
@ -572,6 +572,10 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null {
|
||||
if (files.length > 0) item.outputFiles = files;
|
||||
}
|
||||
|
||||
if (raw.isLive === true) {
|
||||
item.isLive = true;
|
||||
}
|
||||
|
||||
const customClip = sanitizeCustomClip(raw.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> {
|
||||
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
||||
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(
|
||||
item: QueueItem,
|
||||
onProgress: (progress: DownloadProgress) => void
|
||||
): 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);
|
||||
if (!isLikelyVodUrl(item.url) || !vodId) {
|
||||
return {
|
||||
@ -3923,6 +4029,50 @@ ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = fal
|
||||
|
||||
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'>) => {
|
||||
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
|
||||
runtimeMetrics.duplicateSkips += 1;
|
||||
|
||||
@ -64,6 +64,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
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),
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
|
||||
|
||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -81,6 +81,7 @@ interface QueueItem {
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
outputFiles?: string[];
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
@ -188,6 +189,7 @@ interface ApiBridge {
|
||||
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[]>;
|
||||
|
||||
@ -197,7 +197,15 @@ const UI_TEXT_DE = {
|
||||
ctxCopyUrl: 'URL kopieren',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
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: {
|
||||
noneTitle: 'Keine VODs',
|
||||
|
||||
@ -197,7 +197,15 @@ const UI_TEXT_EN = {
|
||||
ctxCopyUrl: 'Copy URL',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
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: {
|
||||
noneTitle: 'No VODs',
|
||||
|
||||
@ -499,12 +499,15 @@ function renderQueue(): void {
|
||||
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
|
||||
|
||||
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 isSelected = selectionIndex >= 0;
|
||||
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> '
|
||||
: '';
|
||||
const liveBadge = item.isLive
|
||||
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
|
||||
: '';
|
||||
const mergeMetaExtra = isMergeGroup
|
||||
? ` (${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="queue-main">
|
||||
<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>
|
||||
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
|
||||
|
||||
@ -406,6 +406,16 @@ function renderStreamers(): void {
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
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');
|
||||
removeSpan.className = 'remove';
|
||||
removeSpan.textContent = 'x';
|
||||
@ -413,7 +423,7 @@ function renderStreamers(): void {
|
||||
e.stopPropagation();
|
||||
void removeStreamer(streamer);
|
||||
});
|
||||
item.append(nameSpan, removeSpan);
|
||||
item.append(nameSpan, recBtn, removeSpan);
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
// Skip click if drag was just released — drop fires after dragend
|
||||
@ -831,6 +841,25 @@ function clearVodSelection(): void {
|
||||
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> {
|
||||
const urls = Array.from(selectedVodUrls);
|
||||
if (urls.length === 0) return;
|
||||
|
||||
@ -625,6 +625,43 @@ body {
|
||||
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 {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
|
||||
@ -46,6 +46,12 @@ export interface QueueItem {
|
||||
// for parts/merge-group splits). Persisted with the queue so completed
|
||||
// items keep their "Open file" / "Show in folder" actions across restarts.
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user