From 7f208cf3692d5258267fff2c145d7443a9a6b539 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Fri, 13 Feb 2026 12:01:09 +0100 Subject: [PATCH] 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. --- README.md | 73 +++++++ docs/README.md | 22 ++- docs/src/layouts/BaseLayout.astro | 48 ++++- docs/src/pages/configuration.mdx | 41 ++++ docs/src/pages/development.mdx | 57 ++++++ docs/src/pages/features.mdx | 44 +++++ docs/src/pages/getting-started.mdx | 48 +++++ docs/src/pages/index.astro | 50 ++++- docs/src/pages/release-process.mdx | 56 ++++++ docs/src/pages/troubleshooting.mdx | 56 ++++++ docs/src/styles/global.css | 197 ++++++++++++++++++- typescript-version/package-lock.json | 4 +- typescript-version/package.json | 2 +- typescript-version/src/index.html | 4 +- typescript-version/src/main.ts | 191 ++++++++++++++++-- typescript-version/src/renderer-queue.ts | 15 +- typescript-version/src/renderer-settings.ts | 9 +- typescript-version/src/renderer-shared.ts | 9 + typescript-version/src/renderer-streamers.ts | 11 +- typescript-version/src/renderer.ts | 51 +++-- 20 files changed, 930 insertions(+), 58 deletions(-) create mode 100644 README.md create mode 100644 docs/src/pages/configuration.mdx create mode 100644 docs/src/pages/development.mdx create mode 100644 docs/src/pages/features.mdx create mode 100644 docs/src/pages/getting-started.mdx create mode 100644 docs/src/pages/release-process.mdx create mode 100644 docs/src/pages/troubleshooting.mdx diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ea4aa3 --- /dev/null +++ b/README.md @@ -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-.exe` +- `Twitch-VOD-Manager-Setup-.exe.blockmap` + +See [Release Process](docs/src/pages/release-process.mdx) for the full checklist. diff --git a/docs/README.md b/docs/README.md index 54fb0f0..b4dd90d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 npm install npm run dev ``` -## Build +## Production build ```bash 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 diff --git a/docs/src/layouts/BaseLayout.astro b/docs/src/layouts/BaseLayout.astro index 760966d..f837a43 100644 --- a/docs/src/layouts/BaseLayout.astro +++ b/docs/src/layouts/BaseLayout.astro @@ -3,20 +3,60 @@ import '../styles/global.css'; interface Props { 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); +}; --- + {title} -
- -
+
+ +
+
+ +
+
+
diff --git a/docs/src/pages/configuration.mdx b/docs/src/pages/configuration.mdx new file mode 100644 index 0000000..ae52ec6 --- /dev/null +++ b/docs/src/pages/configuration.mdx @@ -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. diff --git a/docs/src/pages/development.mdx b/docs/src/pages/development.mdx new file mode 100644 index 0000000..8dd71c9 --- /dev/null +++ b/docs/src/pages/development.mdx @@ -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. diff --git a/docs/src/pages/features.mdx b/docs/src/pages/features.mdx new file mode 100644 index 0000000..dd57c47 --- /dev/null +++ b/docs/src/pages/features.mdx @@ -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`. diff --git a/docs/src/pages/getting-started.mdx b/docs/src/pages/getting-started.mdx new file mode 100644 index 0000000..dfaf074 --- /dev/null +++ b/docs/src/pages/getting-started.mdx @@ -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-.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. diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index c139c14..adae853 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -2,10 +2,50 @@ import BaseLayout from '../layouts/BaseLayout.astro'; --- - +

Twitch VOD Manager Documentation

-

Die Dokumentation wird jetzt mit Astro + MDX gepflegt.

- +

+ This documentation covers end-user setup, Twitch API configuration, troubleshooting, + and developer workflows for the TypeScript/Electron app. +

+ +
+ Electron + TypeScript + Streamlink + FFmpeg + Auto-Update +
+ +
diff --git a/docs/src/pages/release-process.mdx b/docs/src/pages/release-process.mdx new file mode 100644 index 0000000..e74b40a --- /dev/null +++ b/docs/src/pages/release-process.mdx @@ -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-.exe` +- `Twitch-VOD-Manager-Setup-.exe.blockmap` + +## 3) Commit + Push + +Commit code/version changes and push to `master`. + +## 4) Create GitHub Release + +Tag format: `v` (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 diff --git a/docs/src/pages/troubleshooting.mdx b/docs/src/pages/troubleshooting.mdx new file mode 100644 index 0000000..c2a08d3 --- /dev/null +++ b/docs/src/pages/troubleshooting.mdx @@ -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-.exe` +- `Twitch-VOD-Manager-Setup-.exe.blockmap` + +Tag version must match app version (example: `v3.7.6`). diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 970e496..39a65f3 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -1,22 +1,205 @@ :root { color-scheme: dark; font-family: 'Segoe UI', Tahoma, sans-serif; - background: #101216; - color: #f2f4f8; + --bg-1: #0f1217; + --bg-2: #161c26; + --bg-3: #1d2734; + --text: #eef3fb; + --muted: #aab8cf; + --line: #2b3546; + --link: #7db6ff; + --link-hover: #a8cdff; + --chip: #22344b; +} + +* { + box-sizing: border-box; } body { margin: 0; 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 { - max-width: 860px; +.layout { + max-width: 1280px; 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 { - 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; + } } diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json index d29e946..01086d4 100644 --- a/typescript-version/package-lock.json +++ b/typescript-version/package-lock.json @@ -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", diff --git a/typescript-version/package.json b/typescript-version/package.json index b43be6e..f9cfebf 100644 --- a/typescript-version/package.json +++ b/typescript-version/package.json @@ -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", diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html index 295574e..66a248f 100644 --- a/typescript-version/src/index.html +++ b/typescript-version/src/index.html @@ -335,7 +335,7 @@

Updates

-

Version: v3.7.6

+

Version: v3.7.7

@@ -346,7 +346,7 @@
Nicht verbunden - v3.7.6 + v3.7.7 diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts index 9f44510..3d8d3c5 100644 --- a/typescript-version/src/main.ts +++ b/typescript-version/src/main.ts @@ -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(); // ========================================== // TOOL PATHS @@ -293,6 +295,11 @@ async function twitchLogin(): Promise { } async function ensureTwitchAuth(forceRefresh = false): Promise { + 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 { 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(query: string, variables: Record): Promise { + 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 { + const login = normalizeLogin(username); + if (!login) return null; + + type UserQueryResult = { user: { id: string; login: string } | null }; + const data = await fetchPublicTwitchGql( + '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 { + 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( + '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 { - 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 { 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 { - 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 { 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 { 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 { 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 { } 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) => { + 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 { 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; }); diff --git a/typescript-version/src/renderer-queue.ts b/typescript-version/src/renderer-queue.ts index 61f7075..c706c2a 100644 --- a/typescript-version/src/renderer-queue.ts +++ b/typescript-version/src/renderer-queue.ts @@ -20,8 +20,12 @@ async function clearCompleted(): Promise { } 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 = '
Keine Downloads in der Warteschlange
'; @@ -29,11 +33,12 @@ function renderQueue(): void { } list.innerHTML = queue.map((item: QueueItem) => { + const safeTitle = escapeHtml(item.title || 'Untitled'); const isClip = item.customClip ? '* ' : ''; return `
-
${isClip}${item.title}
+
${isClip}${safeTitle}
x
`; @@ -46,5 +51,9 @@ async function toggleDownload(): Promise { 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.'); + } } diff --git a/typescript-version/src/renderer-settings.ts b/typescript-version/src/renderer-settings.ts index 8787eea..c5dc1eb 100644 --- a/typescript-version/src/renderer-settings.ts +++ b/typescript-version/src/renderer-settings.ts @@ -1,8 +1,15 @@ async function connect(): Promise { + 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 { diff --git a/typescript-version/src/renderer-shared.ts b/typescript-version/src/renderer-shared.ts index 12ca4a8..36e8eac 100644 --- a/typescript-version/src/renderer-shared.ts +++ b/typescript-version/src/renderer-shared.ts @@ -10,6 +10,15 @@ function queryAll(selector: string): T[] { return Array.from(document.querySelectorAll(selector)) as T[]; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + let config: AppConfig = {}; let currentStreamer: string | null = null; let isConnected = false; diff --git a/typescript-version/src/renderer-streamers.ts b/typescript-version/src/renderer-streamers.ts index 4a983a5..eb036b5 100644 --- a/typescript-version/src/renderer-streamers.ts +++ b/typescript-version/src/renderer-streamers.ts @@ -56,10 +56,10 @@ async function selectStreamer(name: string): Promise { if (!isConnected) { await connect(); - if (!isConnected) { - byId('vodGrid').innerHTML = '

Nicht verbunden

Bitte Twitch API Daten in den Einstellungen prufen.

'; - return; - } + } + + if (!isConnected) { + updateStatus('Ohne Login (Public Modus)', false); } byId('vodGrid').innerHTML = '

Lade VODs...

'; @@ -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, '"'); + const safeDisplayTitle = escapeHtml(vod.title || 'Untitled VOD'); return `
-
${vod.title}
+
${safeDisplayTitle}
${date} ${vod.duration} diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts index 727180c..81403c9 100644 --- a/typescript-version/src/renderer.ts +++ b/typescript-version/src/renderer.ts @@ -1,6 +1,7 @@ async function init(): Promise { 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 { 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 { 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 { 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 { + 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 {