Compare commits
No commits in common. "1c5462b7fe2f0f2ede1f69559c2c86a304c1f452" and "49200f4ca6de0d57a44fa0b972f51682738f6d41" have entirely different histories.
1c5462b7fe
...
49200f4ca6
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.0",
|
"version": "4.5.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.0",
|
"version": "4.5.28",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.0",
|
"version": "4.5.28",
|
||||||
"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",
|
||||||
|
|||||||
150
src/main.ts
150
src/main.ts
@ -572,10 +572,6 @@ 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;
|
||||||
|
|
||||||
@ -2131,63 +2127,6 @@ 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) {
|
||||||
@ -2841,55 +2780,10 @@ 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 {
|
||||||
@ -4029,50 +3923,6 @@ 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;
|
||||||
|
|||||||
@ -64,7 +64,6 @@ 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'),
|
||||||
|
|||||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -81,7 +81,6 @@ interface QueueItem {
|
|||||||
customClip?: CustomClip;
|
customClip?: CustomClip;
|
||||||
mergeGroup?: MergeGroup;
|
mergeGroup?: MergeGroup;
|
||||||
outputFiles?: string[];
|
outputFiles?: string[];
|
||||||
isLive?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadProgress {
|
interface DownloadProgress {
|
||||||
@ -189,7 +188,6 @@ 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[]>;
|
||||||
|
|||||||
@ -197,15 +197,7 @@ 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',
|
||||||
|
|||||||
@ -197,15 +197,7 @@ 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',
|
||||||
|
|||||||
@ -499,15 +499,12 @@ 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 && !item.isLive;
|
const showSelector = item.status === 'pending' && !isMergeGroup;
|
||||||
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))})`
|
||||||
: '';
|
: '';
|
||||||
@ -521,7 +518,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">${liveBadge}${mergeIcon}${isClip}${safeTitle}</div>
|
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${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>
|
||||||
|
|||||||
@ -406,16 +406,6 @@ 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';
|
||||||
@ -423,7 +413,7 @@ function renderStreamers(): void {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void removeStreamer(streamer);
|
void removeStreamer(streamer);
|
||||||
});
|
});
|
||||||
item.append(nameSpan, recBtn, removeSpan);
|
item.append(nameSpan, 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
|
||||||
@ -841,25 +831,6 @@ 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;
|
||||||
|
|||||||
@ -625,43 +625,6 @@ 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;
|
||||||
|
|||||||
@ -46,12 +46,6 @@ 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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user