Add public VOD mode, queue sync fixes, and full docs

Allow streamer/VOD browsing without Twitch credentials via public GraphQL fallback, harden queue visibility by syncing renderer state with backend updates, and ship a comprehensive Astro/MDX documentation set similar to established downloader projects.
This commit is contained in:
xRangerDE
2026-02-13 12:01:09 +01:00
parent 46f7085342
commit 7f208cf369
20 changed files with 930 additions and 58 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "3.7.6",
"version": "3.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "3.7.6",
"version": "3.7.7",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "3.7.6",
"version": "3.7.7",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",
+2 -2
View File
@@ -335,7 +335,7 @@
<div class="settings-card">
<h3>Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.7.6</p>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v3.7.7</p>
<button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
</div>
@@ -346,7 +346,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v3.7.6</span>
<span id="versionText">v3.7.7</span>
</div>
</main>
</div>
+179 -12
View File
@@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ==========================================
// CONFIG & CONSTANTS
// ==========================================
const APP_VERSION = '3.7.6';
const APP_VERSION = '3.7.7';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths
@@ -21,6 +21,7 @@ const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
const API_TIMEOUT = 10000;
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_SECONDS = 5;
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
// Ensure directories exist
if (!fs.existsSync(APPDATA_DIR)) {
@@ -163,6 +164,7 @@ let currentProcess: ChildProcess | null = null;
let currentDownloadCancelled = false;
let downloadStartTime = 0;
let downloadedBytes = 0;
const userIdLoginCache = new Map<string, string>();
// ==========================================
// TOOL PATHS
@@ -293,6 +295,11 @@ async function twitchLogin(): Promise<boolean> {
}
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
if (!config.client_id || !config.client_secret) {
accessToken = null;
return false;
}
if (!forceRefresh && accessToken) {
return true;
}
@@ -300,12 +307,124 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
return await twitchLogin();
}
function normalizeLogin(input: string): string {
return input.trim().replace(/^@+/, '').toLowerCase();
}
function formatTwitchDurationFromSeconds(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h${m}m${s}s`;
if (m > 0) return `${m}m${s}s`;
return `${s}s`;
}
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
try {
const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>(
'https://gql.twitch.tv/gql',
{ query, variables },
{
headers: {
'Client-ID': TWITCH_WEB_CLIENT_ID,
'Content-Type': 'application/json'
},
timeout: API_TIMEOUT
}
);
if (response.data.errors?.length) {
console.error('Public Twitch GQL errors:', response.data.errors.map((err) => err.message).join('; '));
return null;
}
return response.data.data || null;
} catch (e) {
console.error('Public Twitch GQL request failed:', e);
return null;
}
}
async function getPublicUserId(username: string): Promise<string | null> {
const login = normalizeLogin(username);
if (!login) return null;
type UserQueryResult = { user: { id: string; login: string } | null };
const data = await fetchPublicTwitchGql<UserQueryResult>(
'query($login:String!){ user(login:$login){ id login } }',
{ login }
);
const user = data?.user;
if (!user?.id) return null;
userIdLoginCache.set(user.id, user.login || login);
return user.id;
}
async function getPublicVODsByLogin(loginName: string): Promise<VOD[]> {
const login = normalizeLogin(loginName);
if (!login) return [];
type VideoNode = {
id: string;
title: string;
publishedAt: string;
lengthSeconds: number;
viewCount: number;
previewThumbnailURL: string;
};
type VodsQueryResult = {
user: {
videos: {
edges: Array<{ node: VideoNode }>;
};
} | null;
};
const data = await fetchPublicTwitchGql<VodsQueryResult>(
'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }',
{ login, first: 100 }
);
const edges = data?.user?.videos?.edges || [];
return edges
.map(({ node }) => {
const id = node?.id;
if (!id) return null;
return {
id,
title: node.title || 'Untitled VOD',
created_at: node.publishedAt || new Date(0).toISOString(),
duration: formatTwitchDurationFromSeconds(node.lengthSeconds || 0),
thumbnail_url: node.previewThumbnailURL || '',
url: `https://www.twitch.tv/videos/${id}`,
view_count: node.viewCount || 0,
stream_id: ''
} as VOD;
})
.filter((vod): vod is VOD => Boolean(vod));
}
async function getUserId(username: string): Promise<string | null> {
if (!(await ensureTwitchAuth())) return null;
const login = normalizeLogin(username);
if (!login) return null;
const getUserViaPublicApi = async () => {
return await getPublicUserId(login);
};
if (!(await ensureTwitchAuth())) return await getUserViaPublicApi();
const fetchUser = async () => {
return await axios.get('https://api.twitch.tv/helix/users', {
params: { login: username },
params: { login },
headers: {
'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}`
@@ -316,25 +435,40 @@ async function getUserId(username: string): Promise<string | null> {
try {
const response = await fetchUser();
return response.data.data[0]?.id || null;
const user = response.data.data[0];
if (!user?.id) return await getUserViaPublicApi();
userIdLoginCache.set(user.id, user.login || login);
return user.id;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try {
const retryResponse = await fetchUser();
return retryResponse.data.data[0]?.id || null;
const user = retryResponse.data.data[0];
if (!user?.id) return await getUserViaPublicApi();
userIdLoginCache.set(user.id, user.login || login);
return user.id;
} catch (retryError) {
console.error('Error getting user after relogin:', retryError);
return null;
return await getUserViaPublicApi();
}
}
console.error('Error getting user:', e);
return null;
return await getUserViaPublicApi();
}
}
async function getVODs(userId: string): Promise<VOD[]> {
if (!(await ensureTwitchAuth())) return [];
const getVodsViaPublicApi = async () => {
const login = userIdLoginCache.get(userId);
if (!login) return [];
return await getPublicVODsByLogin(login);
};
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
const fetchVods = async () => {
return await axios.get('https://api.twitch.tv/helix/videos', {
@@ -353,20 +487,32 @@ async function getVODs(userId: string): Promise<VOD[]> {
try {
const response = await fetchVods();
return response.data.data;
const vods = response.data.data || [];
const login = vods[0]?.user_login;
if (login) {
userIdLoginCache.set(userId, normalizeLogin(login));
}
return vods;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try {
const retryResponse = await fetchVods();
return retryResponse.data.data;
const vods = retryResponse.data.data || [];
const login = vods[0]?.user_login;
if (login) {
userIdLoginCache.set(userId, normalizeLogin(login));
}
return vods;
} catch (retryError) {
console.error('Error getting VODs after relogin:', retryError);
return [];
return await getVodsViaPublicApi();
}
}
console.error('Error getting VODs:', e);
return [];
return await getVodsViaPublicApi();
}
}
@@ -840,6 +986,7 @@ async function processQueue(): Promise<void> {
isDownloading = true;
mainWindow?.webContents.send('download-started');
mainWindow?.webContents.send('queue-updated', downloadQueue);
for (const item of downloadQueue) {
if (!isDownloading) break;
@@ -847,6 +994,7 @@ async function processQueue(): Promise<void> {
currentDownloadCancelled = false;
item.status = 'downloading';
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
const success = await downloadVOD(item, (progress) => {
@@ -860,6 +1008,8 @@ async function processQueue(): Promise<void> {
}
isDownloading = false;
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
mainWindow?.webContents.send('download-finished');
}
@@ -955,7 +1105,15 @@ function setupAutoUpdater() {
ipcMain.handle('get-config', () => config);
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousClientId = config.client_id;
const previousClientSecret = config.client_secret;
config = { ...config, ...newConfig };
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
accessToken = null;
}
saveConfig(config);
return config;
});
@@ -983,22 +1141,31 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
};
downloadQueue.push(queueItem);
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue;
});
ipcMain.handle('remove-from-queue', (_, id: string) => {
downloadQueue = downloadQueue.filter(item => item.id !== id);
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue;
});
ipcMain.handle('clear-completed', () => {
downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue;
});
ipcMain.handle('start-download', async () => {
const hasPendingItems = downloadQueue.some(item => item.status !== 'completed');
if (!hasPendingItems) {
mainWindow?.webContents.send('queue-updated', downloadQueue);
return false;
}
processQueue();
return true;
});
+12 -3
View File
@@ -20,8 +20,12 @@ async function clearCompleted(): Promise<void> {
}
function renderQueue(): void {
if (!Array.isArray(queue)) {
queue = [];
}
const list = byId('queueList');
byId('queueCount').textContent = queue.length;
byId('queueCount').textContent = String(queue.length);
if (queue.length === 0) {
list.innerHTML = '<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">Keine Downloads in der Warteschlange</div>';
@@ -29,11 +33,12 @@ function renderQueue(): void {
}
list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || 'Untitled');
const isClip = item.customClip ? '* ' : '';
return `
<div class="queue-item">
<div class="status ${item.status}"></div>
<div class="title" title="${item.title}">${isClip}${item.title}</div>
<div class="title" title="${safeTitle}">${isClip}${safeTitle}</div>
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div>
`;
@@ -46,5 +51,9 @@ async function toggleDownload(): Promise<void> {
return;
}
await window.api.startDownload();
const started = await window.api.startDownload();
if (!started) {
renderQueue();
alert('Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.');
}
}
+8 -1
View File
@@ -1,8 +1,15 @@
async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) {
isConnected = false;
updateStatus('Ohne Login (Public Modus)', false);
return;
}
updateStatus('Verbinde...', false);
const success = await window.api.login();
isConnected = success;
updateStatus(success ? 'Verbunden' : 'Verbindung fehlgeschlagen', success);
updateStatus(success ? 'Verbunden' : 'Verbindung fehlgeschlagen - Public Modus aktiv', success);
}
function updateStatus(text: string, connected: boolean): void {
@@ -10,6 +10,15 @@ function queryAll<T = any>(selector: string): T[] {
return Array.from(document.querySelectorAll(selector)) as T[];
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
let config: AppConfig = {};
let currentStreamer: string | null = null;
let isConnected = false;
+6 -5
View File
@@ -56,10 +56,10 @@ async function selectStreamer(name: string): Promise<void> {
if (!isConnected) {
await connect();
if (!isConnected) {
byId('vodGrid').innerHTML = '<div class="empty-state"><h3>Nicht verbunden</h3><p>Bitte Twitch API Daten in den Einstellungen prufen.</p></div>';
return;
}
}
if (!isConnected) {
updateStatus('Ohne Login (Public Modus)', false);
}
byId('vodGrid').innerHTML = '<div class="empty-state"><p>Lade VODs...</p></div>';
@@ -86,12 +86,13 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = new Date(vod.created_at).toLocaleDateString('de-DE');
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || 'Untitled VOD');
return `
<div class="vod-card">
<img class="vod-thumbnail" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-info">
<div class="vod-title">${vod.title}</div>
<div class="vod-title">${safeDisplayTitle}</div>
<div class="vod-meta">
<span>${date}</span>
<span>${vod.duration}</span>
+38 -13
View File
@@ -1,6 +1,7 @@
async function init(): Promise<void> {
config = await window.api.getConfig();
queue = await window.api.getQueue();
const initialQueue = await window.api.getQueue();
queue = Array.isArray(initialQueue) ? initialQueue : [];
const version = await window.api.getVersion();
byId('versionText').textContent = `v${version}`;
@@ -17,16 +18,10 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch');
renderStreamers();
renderQueue();
if (config.client_id && config.client_secret) {
await connect();
if (config.streamers && config.streamers.length > 0) {
await selectStreamer(config.streamers[0]);
}
}
updateDownloadButtonState();
window.api.onQueueUpdated((q: QueueItem[]) => {
queue = q;
queue = Array.isArray(q) ? q : [];
renderQueue();
});
@@ -42,14 +37,12 @@ async function init(): Promise<void> {
window.api.onDownloadStarted(() => {
downloading = true;
byId('btnStart').textContent = 'Stoppen';
byId('btnStart').classList.add('downloading');
updateDownloadButtonState();
});
window.api.onDownloadFinished(() => {
downloading = false;
byId('btnStart').textContent = 'Start';
byId('btnStart').classList.remove('downloading');
updateDownloadButtonState();
});
window.api.onCutProgress((percent: number) => {
@@ -62,9 +55,41 @@ async function init(): Promise<void> {
byId('mergeProgressText').textContent = Math.round(percent) + '%';
});
if (config.client_id && config.client_secret) {
await connect();
} else {
updateStatus('Ohne Login (Public Modus)', false);
}
if (config.streamers && config.streamers.length > 0) {
await selectStreamer(config.streamers[0]);
}
setTimeout(() => {
void checkUpdateSilent();
}, 3000);
setInterval(() => {
void syncQueueAndDownloadState();
}, 2000);
}
function updateDownloadButtonState(): void {
const btn = byId('btnStart');
btn.textContent = downloading ? 'Stoppen' : 'Start';
btn.classList.toggle('downloading', downloading);
}
async function syncQueueAndDownloadState(): Promise<void> {
const latestQueue = await window.api.getQueue();
queue = Array.isArray(latestQueue) ? latestQueue : [];
renderQueue();
const backendDownloading = await window.api.isDownloading();
if (backendDownloading !== downloading) {
downloading = backendDownloading;
updateDownloadButtonState();
}
}
function showTab(tab: string): void {