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

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# Twitch VOD Manager
Twitch VOD Manager is a desktop app for browsing Twitch VODs, queueing downloads, creating clips, cutting local videos, and merging files.
The current codebase is TypeScript + Electron and ships Windows installer releases with in-app auto-update support.
## Documentation
- Full docs workspace: `docs/`
- Docs index: `docs/src/pages/index.astro`
Key guides:
- [Getting Started](docs/src/pages/getting-started.mdx)
- [Features](docs/src/pages/features.mdx)
- [Configuration](docs/src/pages/configuration.mdx)
- [Troubleshooting](docs/src/pages/troubleshooting.mdx)
- [Development](docs/src/pages/development.mdx)
- [Release Process](docs/src/pages/release-process.mdx)
## Main Features
- Streamer list with Twitch Helix VOD browser
- Queue-based VOD downloads
- Clip extraction workflow from VOD metadata
- Local video cutter with preview frame extraction
- Local video merge workflow
- GitHub release based in-app updates
## Requirements
- Windows 10/11
- Node.js 18+ and npm (for local development)
- `streamlink` in `PATH`
- `ffmpeg` and `ffprobe` in `PATH`
Optional (recommended for authenticated mode):
- Twitch app `Client ID` and `Client Secret`
## Run from source
```bash
cd "typescript-version"
npm install
npm run build
npm start
```
## Build installer
```bash
cd "typescript-version"
npm run dist:win
```
Output artifacts are generated in `typescript-version/release/`.
## Repository Structure
- `typescript-version/` - Electron app source and build config
- `docs/` - Astro + MDX documentation site
- `server_files/` - legacy release metadata files
## Auto-Update Notes
For updates to reach installed clients, each release must include:
- `latest.yml`
- `Twitch-VOD-Manager-Setup-<version>.exe`
- `Twitch-VOD-Manager-Setup-<version>.exe.blockmap`
See [Release Process](docs/src/pages/release-process.mdx) for the full checklist.

View File

@ -1,14 +1,30 @@
# Docs (Astro + MDX) # Twitch VOD Manager Docs
## Start Documentation site for users and contributors, built with Astro + MDX.
## Local development
```bash ```bash
npm install npm install
npm run dev npm run dev
``` ```
## Build ## Production build
```bash ```bash
npm run build npm run build
npm run preview
``` ```
## Writing docs
- Add pages in `src/pages/` (`.astro` or `.mdx`)
- Shared layout lives in `src/layouts/BaseLayout.astro`
- Global styles live in `src/styles/global.css`
- Keep command examples copy-paste ready
## Scope
- User setup and troubleshooting
- Feature documentation
- Developer architecture and release workflow

View File

@ -3,20 +3,60 @@ import '../styles/global.css';
interface Props { interface Props {
title: string; title: string;
description?: string;
} }
const { title } = Astro.props; const { title, description = 'Twitch VOD Manager documentation' } = Astro.props;
const nav = [
{ href: '/', label: 'Overview' },
{ href: '/getting-started', label: 'Getting Started' },
{ href: '/features', label: 'Features' },
{ href: '/configuration', label: 'Configuration' },
{ href: '/troubleshooting', label: 'Troubleshooting' },
{ href: '/development', label: 'Development' },
{ href: '/release-process', label: 'Release Process' }
];
const pathname = Astro.url.pathname.endsWith('/') ? Astro.url.pathname : `${Astro.url.pathname}/`;
const isActive = (href: string): boolean => {
if (href === '/') {
return pathname === '/';
}
const normalizedHref = href.endsWith('/') ? href : `${href}/`;
return pathname.startsWith(normalizedHref);
};
--- ---
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body> <body>
<main class="container"> <div class="layout">
<slot /> <aside class="sidebar">
</main> <h1 class="brand">Twitch VOD Manager</h1>
<p class="tagline">Product and developer documentation</p>
<nav>
<ul class="nav-list">
{nav.map((item) => (
<li>
<a href={item.href} class={isActive(item.href) ? 'active' : undefined}>{item.label}</a>
</li>
))}
</ul>
</nav>
</aside>
<main class="content">
<article class="doc">
<slot />
</article>
</main>
</div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,41 @@
---
layout: ../layouts/BaseLayout.astro
title: Configuration
description: File locations and configuration keys used by the app.
---
# Configuration
## File Locations
The app stores runtime data in:
`C:\ProgramData\Twitch_VOD_Manager`
| File | Purpose |
| --- | --- |
| `config.json` | User settings and Twitch credentials |
| `download_queue.json` | Persistent download queue state |
Default download path:
`%USERPROFILE%\Desktop\Twitch_VODs`
## Main Config Keys
| Key | Description |
| --- | --- |
| `client_id` | Twitch application client id |
| `client_secret` | Twitch application client secret |
| `download_path` | Base output folder |
| `streamers` | Sidebar streamer list |
| `theme` | UI theme (`twitch`, `discord`, `youtube`, `apple`) |
| `download_mode` | Full VOD or parts mode |
| `part_minutes` | Split length in minutes for parts mode |
## Notes
- Credentials are optional. If empty, the app uses public mode for streamer/VOD discovery.
- Credentials are currently stored in plain text config file.
- The app trims credential fields before saving to reduce whitespace issues.
- Legacy keys can exist in config from older versions; unknown keys are ignored.

View File

@ -0,0 +1,57 @@
---
layout: ../layouts/BaseLayout.astro
title: Development
description: Local development setup and architecture for contributors.
---
# Development
## Local Setup
```bash
cd "typescript-version"
npm install
npm run build
npm start
```
## Architecture
| Layer | File(s) | Responsibility |
| --- | --- | --- |
| Main process | `src/main.ts` | IPC handlers, Twitch API, downloads, ffmpeg/streamlink execution, updater |
| Preload bridge | `src/preload.ts` | Safe API surface exposed to renderer |
| Renderer shell | `src/index.html` + `src/styles.css` | UI markup and styling |
| Renderer modules | `src/renderer*.ts` | UI logic by feature (streamers, queue, settings, updates, shared state) |
## Renderer Module Split
- `renderer-shared.ts`: common state + DOM helper functions
- `renderer-streamers.ts`: streamer list + VOD loading
- `renderer-queue.ts`: queue rendering + start/stop behavior
- `renderer-settings.ts`: credentials, folder, and theme handling
- `renderer-updates.ts`: update banner and download/install flow
- `renderer.ts`: app init + clip/cutter/merge orchestration
## Useful Commands
```bash
# Build TypeScript only
npm run build
# Run app in dev mode
npm start
# Build Windows installer
npm run dist:win
```
## Docs Workspace
```bash
cd "docs"
npm install
npm run dev
```
Docs site is Astro + MDX and can be updated independently from app runtime code.

View File

@ -0,0 +1,44 @@
---
layout: ../layouts/BaseLayout.astro
title: Features
description: Complete feature overview for Twitch VOD Manager.
---
# Features
## Twitch VOD Browser
- Add streamers to a persistent sidebar list.
- Fetches user + archive VODs through Twitch Helix API when credentials are configured.
- Falls back to public Twitch GraphQL mode when no credentials are set.
- Automatic reconnect/re-auth if the app is temporarily disconnected.
## Download Queue
- Add full VODs to queue with one click.
- Queue supports start/stop and removal of completed entries.
- Download process persists in local queue file.
## Clip Creation from VODs
- Open **Clip** dialog from any VOD card.
- Set start/end time and optional part number.
- Queue clip jobs with metadata for naming strategy.
## Video Cutter (Local Files)
- Select any local video and inspect duration/fps/resolution.
- Extract preview frames via `ffprobe`/`ffmpeg`.
- Export cut segment as new MP4 file.
## Video Merge (Local Files)
- Choose multiple local video files.
- Reorder via up/down controls.
- Merge into one output file.
## In-App Auto-Update
- Uses GitHub release artifacts through `electron-updater`.
- Detects new versions, downloads update, then installs on restart.
- Requires release assets: installer, blockmap, and `latest.yml`.

View File

@ -0,0 +1,48 @@
---
layout: ../layouts/BaseLayout.astro
title: Getting Started
description: Install and configure Twitch VOD Manager quickly.
---
# Getting Started
## Requirements
- Windows 10/11 (installer and paths are currently Windows-first)
- `streamlink` available in `PATH`
- `ffmpeg` + `ffprobe` available in `PATH`
Optional but recommended:
- Twitch API app with `Client ID` and `Client Secret` (for authenticated Helix mode)
## Install
1. Download the latest setup from GitHub Releases.
2. Run `Twitch-VOD-Manager-Setup-<version>.exe`.
3. Launch the app.
## First-Time Setup
1. Open **Einstellungen**.
2. Choose your download path.
3. Optional: enter `Client ID` and `Client Secret` for authenticated mode.
4. Click **Speichern & Verbinden**.
5. Add a streamer in the header input and press `+`.
If everything is correct, VOD cards appear.
- With credentials: status is `Verbunden`.
- Without credentials: status shows `Ohne Login (Public Modus)` and VOD downloads still work.
## Verify Tools
In PowerShell or CMD:
```bash
streamlink --version
ffmpeg -version
ffprobe -version
```
If one command fails, install the missing tool and restart the app.

View File

@ -2,10 +2,50 @@
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
--- ---
<BaseLayout title="Twitch VOD Manager Docs"> <BaseLayout title="Twitch VOD Manager Docs" description="Official documentation for Twitch VOD Manager users and contributors.">
<h1>Twitch VOD Manager Documentation</h1> <h1>Twitch VOD Manager Documentation</h1>
<p>Die Dokumentation wird jetzt mit Astro + MDX gepflegt.</p> <p>
<ul> This documentation covers end-user setup, Twitch API configuration, troubleshooting,
<li><a href="/roadmap">Migration Roadmap</a></li> and developer workflows for the TypeScript/Electron app.
</ul> </p>
<div class="chips">
<span class="chip">Electron</span>
<span class="chip">TypeScript</span>
<span class="chip">Streamlink</span>
<span class="chip">FFmpeg</span>
<span class="chip">Auto-Update</span>
</div>
<div class="card-grid">
<a class="card" href="/getting-started">
<h3>Getting Started</h3>
<p>Install the app, set Twitch API credentials, and run your first VOD download.</p>
</a>
<a class="card" href="/features">
<h3>Features</h3>
<p>Learn VOD browsing, queue processing, clip creation, cutter, merge, and updates.</p>
</a>
<a class="card" href="/configuration">
<h3>Configuration</h3>
<p>Understand config paths, available settings, and how local files are stored.</p>
</a>
<a class="card" href="/troubleshooting">
<h3>Troubleshooting</h3>
<p>Fix common issues like "Keine VODs", missing tools, and update problems.</p>
</a>
<a class="card" href="/development">
<h3>Development</h3>
<p>Set up local development, architecture overview, and code structure details.</p>
</a>
<a class="card" href="/release-process">
<h3>Release Process</h3>
<p>Ship new installer builds and GitHub releases compatible with auto-updater.</p>
</a>
</div>
</BaseLayout> </BaseLayout>

View File

@ -0,0 +1,56 @@
---
layout: ../layouts/BaseLayout.astro
title: Release Process
description: Standardized release checklist for auto-update compatible builds.
---
# Release Process
This project uses GitHub Releases + `electron-updater`.
## 1) Bump Version
Update app version consistently:
- `typescript-version/package.json`
- `typescript-version/package-lock.json`
- `typescript-version/src/main.ts` (`APP_VERSION`)
- Optional visible fallback text in `src/index.html`
## 2) Build Installer Artifacts
```bash
cd "typescript-version"
npm run dist:win
```
Expected outputs in `typescript-version/release/`:
- `latest.yml`
- `Twitch-VOD-Manager-Setup-<version>.exe`
- `Twitch-VOD-Manager-Setup-<version>.exe.blockmap`
## 3) Commit + Push
Commit code/version changes and push to `master`.
## 4) Create GitHub Release
Tag format: `v<version>` (example: `v3.7.6`).
Attach exactly the 3 update artifacts from step 2.
## 5) Verify Update Path
- Open app on older version.
- Trigger update check.
- Confirm banner appears and update can be downloaded/installed.
## Quick Checklist
- [ ] Version bumped everywhere
- [ ] `npm run build` passes
- [ ] `npm run dist:win` passes
- [ ] Release tag created
- [ ] `latest.yml` + `.exe` + `.blockmap` uploaded
- [ ] Manual update flow verified

View File

@ -0,0 +1,56 @@
---
layout: ../layouts/BaseLayout.astro
title: Troubleshooting
description: Fix common Twitch VOD Manager issues quickly.
---
# Troubleshooting
## "Keine VODs" after adding streamer
1. Ensure the streamer exists and has archive VODs.
2. Press **Aktualisieren**.
3. If you use credentials, re-save `Client ID` + `Client Secret` in **Einstellungen**.
4. Check bottom status bar:
- `Verbunden` in authenticated mode
- `Ohne Login (Public Modus)` in credential-free mode
The app retries auth automatically on expired tokens and can continue in public mode without credentials.
## "Streamer nicht gefunden"
- Use login name only (no URL, no `@`).
- Names are normalized to lowercase.
- Verify streamer spelling directly on Twitch.
## Clip/Cutter/Merge duration shows 0 or preview fails
Usually caused by broken `ffprobe` detection.
Check:
```bash
ffprobe -version
```
If missing, install FFmpeg package that includes `ffprobe` and restart app.
## "Streamlink not found"
Install streamlink and verify:
```bash
streamlink --version
```
Then restart the app.
## Auto-update does not trigger
Release must include all of:
- `latest.yml`
- `Twitch-VOD-Manager-Setup-<version>.exe`
- `Twitch-VOD-Manager-Setup-<version>.exe.blockmap`
Tag version must match app version (example: `v3.7.6`).

View File

@ -1,22 +1,205 @@
:root { :root {
color-scheme: dark; color-scheme: dark;
font-family: 'Segoe UI', Tahoma, sans-serif; font-family: 'Segoe UI', Tahoma, sans-serif;
background: #101216; --bg-1: #0f1217;
color: #f2f4f8; --bg-2: #161c26;
--bg-3: #1d2734;
--text: #eef3fb;
--muted: #aab8cf;
--line: #2b3546;
--link: #7db6ff;
--link-hover: #a8cdff;
--chip: #22344b;
}
* {
box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: radial-gradient(circle at top, #1a2330 0%, #101216 55%); background: radial-gradient(circle at top, #1a2432 0%, var(--bg-1) 55%);
color: var(--text);
} }
.container { .layout {
max-width: 860px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 40px 20px; padding: 28px;
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
gap: 24px;
}
.sidebar {
position: sticky;
top: 18px;
align-self: start;
background: linear-gradient(180deg, var(--bg-2), #131922);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
}
.brand {
margin: 0;
font-size: 1.2rem;
}
.tagline {
margin-top: 8px;
color: var(--muted);
font-size: 0.9rem;
}
.nav-list {
list-style: none;
margin: 20px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-list a {
display: block;
padding: 8px 10px;
border-radius: 8px;
text-decoration: none;
color: var(--muted);
border: 1px solid transparent;
}
.nav-list a:hover {
color: var(--text);
border-color: var(--line);
background: rgba(125, 182, 255, 0.08);
}
.nav-list a.active {
color: var(--text);
background: rgba(125, 182, 255, 0.16);
border-color: rgba(125, 182, 255, 0.35);
}
.content {
min-width: 0;
}
.doc {
background: linear-gradient(180deg, var(--bg-2), #121822);
border: 1px solid var(--line);
border-radius: 14px;
padding: 26px;
}
h1,
h2,
h3 {
line-height: 1.3;
}
h1 {
margin-top: 0;
font-size: 2rem;
}
h2,
h3 {
margin-top: 1.7rem;
}
p,
li {
color: var(--muted);
line-height: 1.65;
} }
a { a {
color: #5ca3ff; color: var(--link);
}
a:hover {
color: var(--link-hover);
}
pre {
overflow-x: auto;
background: #0c1118;
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px;
}
code {
font-family: Consolas, 'Courier New', monospace;
font-size: 0.92em;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th,
td {
border: 1px solid var(--line);
text-align: left;
padding: 10px;
}
th {
background: #111a25;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 20px;
}
.card {
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #172233, #111824);
padding: 14px;
}
.card h3 {
margin-top: 0;
margin-bottom: 8px;
font-size: 1rem;
}
.card p {
margin: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border: 1px solid rgba(125, 182, 255, 0.35);
background: var(--chip);
color: #dbe9ff;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.84rem;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
padding: 16px;
}
.sidebar {
position: static;
}
} }

View File

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

@ -335,7 +335,7 @@
<div class="settings-card"> <div class="settings-card">
<h3>Updates</h3> <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> <button class="btn-secondary" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
</div> </div>
@ -346,7 +346,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v3.7.6</span> <span id="versionText">v3.7.7</span>
</div> </div>
</main> </main>
</div> </div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ========================================== // ==========================================
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = '3.7.6'; const APP_VERSION = '3.7.7';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
@ -21,6 +21,7 @@ const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
const API_TIMEOUT = 10000; const API_TIMEOUT = 10000;
const MAX_RETRY_ATTEMPTS = 3; const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_SECONDS = 5; const RETRY_DELAY_SECONDS = 5;
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
// Ensure directories exist // Ensure directories exist
if (!fs.existsSync(APPDATA_DIR)) { if (!fs.existsSync(APPDATA_DIR)) {
@ -163,6 +164,7 @@ let currentProcess: ChildProcess | null = null;
let currentDownloadCancelled = false; let currentDownloadCancelled = false;
let downloadStartTime = 0; let downloadStartTime = 0;
let downloadedBytes = 0; let downloadedBytes = 0;
const userIdLoginCache = new Map<string, string>();
// ========================================== // ==========================================
// TOOL PATHS // TOOL PATHS
@ -293,6 +295,11 @@ async function twitchLogin(): Promise<boolean> {
} }
async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> { async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
if (!config.client_id || !config.client_secret) {
accessToken = null;
return false;
}
if (!forceRefresh && accessToken) { if (!forceRefresh && accessToken) {
return true; return true;
} }
@ -300,12 +307,124 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
return await twitchLogin(); 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> { 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 () => { const fetchUser = async () => {
return await axios.get('https://api.twitch.tv/helix/users', { return await axios.get('https://api.twitch.tv/helix/users', {
params: { login: username }, params: { login },
headers: { headers: {
'Client-ID': config.client_id, 'Client-ID': config.client_id,
'Authorization': `Bearer ${accessToken}` 'Authorization': `Bearer ${accessToken}`
@ -316,25 +435,40 @@ async function getUserId(username: string): Promise<string | null> {
try { try {
const response = await fetchUser(); 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) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try { try {
const retryResponse = await fetchUser(); 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) { } catch (retryError) {
console.error('Error getting user after relogin:', retryError); console.error('Error getting user after relogin:', retryError);
return null; return await getUserViaPublicApi();
} }
} }
console.error('Error getting user:', e); console.error('Error getting user:', e);
return null; return await getUserViaPublicApi();
} }
} }
async function getVODs(userId: string): Promise<VOD[]> { 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 () => { const fetchVods = async () => {
return await axios.get('https://api.twitch.tv/helix/videos', { return await axios.get('https://api.twitch.tv/helix/videos', {
@ -353,20 +487,32 @@ async function getVODs(userId: string): Promise<VOD[]> {
try { try {
const response = await fetchVods(); 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) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
try { try {
const retryResponse = await fetchVods(); 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) { } catch (retryError) {
console.error('Error getting VODs after relogin:', retryError); console.error('Error getting VODs after relogin:', retryError);
return []; return await getVodsViaPublicApi();
} }
} }
console.error('Error getting VODs:', e); console.error('Error getting VODs:', e);
return []; return await getVodsViaPublicApi();
} }
} }
@ -840,6 +986,7 @@ async function processQueue(): Promise<void> {
isDownloading = true; isDownloading = true;
mainWindow?.webContents.send('download-started'); mainWindow?.webContents.send('download-started');
mainWindow?.webContents.send('queue-updated', downloadQueue);
for (const item of downloadQueue) { for (const item of downloadQueue) {
if (!isDownloading) break; if (!isDownloading) break;
@ -847,6 +994,7 @@ async function processQueue(): Promise<void> {
currentDownloadCancelled = false; currentDownloadCancelled = false;
item.status = 'downloading'; item.status = 'downloading';
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue); mainWindow?.webContents.send('queue-updated', downloadQueue);
const success = await downloadVOD(item, (progress) => { const success = await downloadVOD(item, (progress) => {
@ -860,6 +1008,8 @@ async function processQueue(): Promise<void> {
} }
isDownloading = false; isDownloading = false;
saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
mainWindow?.webContents.send('download-finished'); mainWindow?.webContents.send('download-finished');
} }
@ -955,7 +1105,15 @@ function setupAutoUpdater() {
ipcMain.handle('get-config', () => config); ipcMain.handle('get-config', () => config);
ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => { ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousClientId = config.client_id;
const previousClientSecret = config.client_secret;
config = { ...config, ...newConfig }; config = { ...config, ...newConfig };
if (config.client_id !== previousClientId || config.client_secret !== previousClientSecret) {
accessToken = null;
}
saveConfig(config); saveConfig(config);
return config; return config;
}); });
@ -983,22 +1141,31 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
}; };
downloadQueue.push(queueItem); downloadQueue.push(queueItem);
saveQueue(downloadQueue); saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue; return downloadQueue;
}); });
ipcMain.handle('remove-from-queue', (_, id: string) => { ipcMain.handle('remove-from-queue', (_, id: string) => {
downloadQueue = downloadQueue.filter(item => item.id !== id); downloadQueue = downloadQueue.filter(item => item.id !== id);
saveQueue(downloadQueue); saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue; return downloadQueue;
}); });
ipcMain.handle('clear-completed', () => { ipcMain.handle('clear-completed', () => {
downloadQueue = downloadQueue.filter(item => item.status !== 'completed'); downloadQueue = downloadQueue.filter(item => item.status !== 'completed');
saveQueue(downloadQueue); saveQueue(downloadQueue);
mainWindow?.webContents.send('queue-updated', downloadQueue);
return downloadQueue; return downloadQueue;
}); });
ipcMain.handle('start-download', async () => { ipcMain.handle('start-download', async () => {
const hasPendingItems = downloadQueue.some(item => item.status !== 'completed');
if (!hasPendingItems) {
mainWindow?.webContents.send('queue-updated', downloadQueue);
return false;
}
processQueue(); processQueue();
return true; return true;
}); });

View File

@ -20,8 +20,12 @@ async function clearCompleted(): Promise<void> {
} }
function renderQueue(): void { function renderQueue(): void {
if (!Array.isArray(queue)) {
queue = [];
}
const list = byId('queueList'); const list = byId('queueList');
byId('queueCount').textContent = queue.length; byId('queueCount').textContent = String(queue.length);
if (queue.length === 0) { 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>'; 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) => { list.innerHTML = queue.map((item: QueueItem) => {
const safeTitle = escapeHtml(item.title || 'Untitled');
const isClip = item.customClip ? '* ' : ''; const isClip = item.customClip ? '* ' : '';
return ` return `
<div class="queue-item"> <div class="queue-item">
<div class="status ${item.status}"></div> <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> <span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div> </div>
`; `;
@ -46,5 +51,9 @@ async function toggleDownload(): Promise<void> {
return; 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.');
}
} }

View File

@ -1,8 +1,15 @@
async function connect(): Promise<void> { 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); updateStatus('Verbinde...', false);
const success = await window.api.login(); const success = await window.api.login();
isConnected = success; isConnected = success;
updateStatus(success ? 'Verbunden' : 'Verbindung fehlgeschlagen', success); updateStatus(success ? 'Verbunden' : 'Verbindung fehlgeschlagen - Public Modus aktiv', success);
} }
function updateStatus(text: string, connected: boolean): void { function updateStatus(text: string, connected: boolean): void {

View File

@ -10,6 +10,15 @@ function queryAll<T = any>(selector: string): T[] {
return Array.from(document.querySelectorAll(selector)) as 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 config: AppConfig = {};
let currentStreamer: string | null = null; let currentStreamer: string | null = null;
let isConnected = false; let isConnected = false;

View File

@ -56,10 +56,10 @@ async function selectStreamer(name: string): Promise<void> {
if (!isConnected) { if (!isConnected) {
await connect(); 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>'; 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 thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = new Date(vod.created_at).toLocaleDateString('de-DE'); const date = new Date(vod.created_at).toLocaleDateString('de-DE');
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;'); const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || 'Untitled VOD');
return ` return `
<div class="vod-card"> <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>'"> <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-info">
<div class="vod-title">${vod.title}</div> <div class="vod-title">${safeDisplayTitle}</div>
<div class="vod-meta"> <div class="vod-meta">
<span>${date}</span> <span>${date}</span>
<span>${vod.duration}</span> <span>${vod.duration}</span>

View File

@ -1,6 +1,7 @@
async function init(): Promise<void> { async function init(): Promise<void> {
config = await window.api.getConfig(); 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(); const version = await window.api.getVersion();
byId('versionText').textContent = `v${version}`; byId('versionText').textContent = `v${version}`;
@ -17,16 +18,10 @@ async function init(): Promise<void> {
changeTheme(config.theme ?? 'twitch'); changeTheme(config.theme ?? 'twitch');
renderStreamers(); renderStreamers();
renderQueue(); renderQueue();
updateDownloadButtonState();
if (config.client_id && config.client_secret) {
await connect();
if (config.streamers && config.streamers.length > 0) {
await selectStreamer(config.streamers[0]);
}
}
window.api.onQueueUpdated((q: QueueItem[]) => { window.api.onQueueUpdated((q: QueueItem[]) => {
queue = q; queue = Array.isArray(q) ? q : [];
renderQueue(); renderQueue();
}); });
@ -42,14 +37,12 @@ async function init(): Promise<void> {
window.api.onDownloadStarted(() => { window.api.onDownloadStarted(() => {
downloading = true; downloading = true;
byId('btnStart').textContent = 'Stoppen'; updateDownloadButtonState();
byId('btnStart').classList.add('downloading');
}); });
window.api.onDownloadFinished(() => { window.api.onDownloadFinished(() => {
downloading = false; downloading = false;
byId('btnStart').textContent = 'Start'; updateDownloadButtonState();
byId('btnStart').classList.remove('downloading');
}); });
window.api.onCutProgress((percent: number) => { window.api.onCutProgress((percent: number) => {
@ -62,9 +55,41 @@ async function init(): Promise<void> {
byId('mergeProgressText').textContent = Math.round(percent) + '%'; 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(() => { setTimeout(() => {
void checkUpdateSilent(); void checkUpdateSilent();
}, 3000); }, 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 { function showTab(tab: string): void {