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:
parent
46f7085342
commit
7f208cf369
73
README.md
Normal file
73
README.md
Normal 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.
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
41
docs/src/pages/configuration.mdx
Normal file
41
docs/src/pages/configuration.mdx
Normal 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.
|
||||||
57
docs/src/pages/development.mdx
Normal file
57
docs/src/pages/development.mdx
Normal 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.
|
||||||
44
docs/src/pages/features.mdx
Normal file
44
docs/src/pages/features.mdx
Normal 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`.
|
||||||
48
docs/src/pages/getting-started.mdx
Normal file
48
docs/src/pages/getting-started.mdx
Normal 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.
|
||||||
@ -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>
|
||||||
|
|||||||
56
docs/src/pages/release-process.mdx
Normal file
56
docs/src/pages/release-process.mdx
Normal 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
|
||||||
56
docs/src/pages/troubleshooting.mdx
Normal file
56
docs/src/pages/troubleshooting.mdx
Normal 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`).
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
let config: AppConfig = {};
|
let config: AppConfig = {};
|
||||||
let currentStreamer: string | null = null;
|
let currentStreamer: string | null = null;
|
||||||
let isConnected = false;
|
let isConnected = false;
|
||||||
|
|||||||
@ -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, '"');
|
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
||||||
|
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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user