Compare commits
No commits in common. "v5.0.14" and "main" have entirely different histories.
9
.gitignore
vendored
9
.gitignore
vendored
@ -3,12 +3,3 @@ dist/
|
|||||||
release/
|
release/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Test-Artefakte
|
|
||||||
tmp_e2e_full/
|
|
||||||
tmp_bugtest/
|
|
||||||
tmp_dl/
|
|
||||||
tmp_ui_shots/
|
|
||||||
|
|
||||||
# Dev-Scripts ohne Token (Stub, nicht produktiv)
|
|
||||||
codeberg_api_upload.sh
|
|
||||||
|
|||||||
217
CHANGELOG.md
217
CHANGELOG.md
@ -1,217 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
Major-Bump 4.6.155 -> 5.0.0 (GA) + 5.1.0-alpha.1 (kontinuierliche post-GA Arbeit).
|
|
||||||
43 Commits, 20 neue Module in `src/main/`, 219 Unit-Tests (vorher 0).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5.1.0-alpha.1 (2026-05-12)
|
|
||||||
|
|
||||||
### Pillar 5 — UI Power (erste sichtbare Komponente)
|
|
||||||
- **Command Palette** (`Ctrl+K`): Modal mit 6 Tab-Wechsel-Befehlen (VODs, Queue, Streamers, Stats, Archive, Settings).
|
|
||||||
- ArrowUp/Down navigiert, Enter fuehrt aus, Esc/Overlay-Click schliesst.
|
|
||||||
- Prefix-Match auf Label + Synonyme (DE/EN).
|
|
||||||
- Registriert in `closeTopmostOpenModal` damit globaler Esc-Handler greift.
|
|
||||||
- Dateien: `src/index.html` (Modal-Markup), `src/styles.css` (.cp-* Klassen), `src/renderer-command-palette.ts`.
|
|
||||||
|
|
||||||
### Pillar 7 — Auto-Discovery (Scaffold)
|
|
||||||
- **`top-clips-crawler` Modul**: Helix `/clips` API + Pagination + Sortierung nach view_count desc.
|
|
||||||
- Konfigurierbar: `broadcaster_id`, `first` (1-100 geclamt), `started_at`/`ended_at`.
|
|
||||||
- `rangeLastDays(N)` Helper fuer ISO-Range "letzte N Tage".
|
|
||||||
- `fetchImpl` injizierbar fuer Tests (kein echter HTTP-Call in CI).
|
|
||||||
- 9 Tests (sortierung, snake-case→camelCase Mapping, query-string, clamping, error paths).
|
|
||||||
|
|
||||||
## 5.1.0-alpha.0 (2026-05-11)
|
|
||||||
|
|
||||||
### Pillar 1 — Live Recording (Storage-Layer)
|
|
||||||
- **`integrity-check` Modul**: ffprobe-JSON-Parser + Verdict-Assessor.
|
|
||||||
- `parseFfprobeJson(rawJson)` -> `ProbeResult` (streams, duration, size).
|
|
||||||
- `assessIntegrity(probe, opts)` -> `IntegrityVerdict` (ok, reasons[], hasVideo, hasAudio).
|
|
||||||
- Reasons: `no-video-stream`, `duration-too-short`, `duration-mismatch:actual=Xs,expected=Ys`.
|
|
||||||
- Spawn-frei (Caller liefert JSON-String) -> 12 Tests ohne ffprobe-Dependency.
|
|
||||||
|
|
||||||
### Pillar 6 — Smart-Resume (Archive-Index)
|
|
||||||
- **`archive-files-store` Modul**: CRUD auf `archive_files` Tabelle.
|
|
||||||
- upsert / get / list (filter by streamer, ordered by createdAt DESC NULLS LAST).
|
|
||||||
- setVerified, delete.
|
|
||||||
- `summaryByStreamer()` (Datei-Count + total bytes pro Streamer, sortiert).
|
|
||||||
- `totalBytes()` (sum aller archive_files).
|
|
||||||
- normalizeLogin auf streamer_login bei Write+Filter -> '@Alice'/'Alice'/'alice' kollabieren zu 'alice'.
|
|
||||||
- 10 Tests.
|
|
||||||
|
|
||||||
### Pillar 4 — Architecture Split (4 weitere Helpers extrahiert)
|
|
||||||
- **`src/main/infra/format-helpers.ts`** mit 4 pure Funktionen aus main.ts:
|
|
||||||
- `sanitizeFilenamePart` (Windows-FS-verbotene Chars + Path-Separators -> '_').
|
|
||||||
- `formatTwitchDurationFromSeconds` ('1h2m3s'-Style, NaN/negative geclamt auf 0).
|
|
||||||
- `formatDateWithPattern` (yyyy/yy/MM/M/dd/d/HH/H/hh/h/mm/m/ss/s Tokens, Backslash-strip).
|
|
||||||
- `getMergeGroupPhaseText` (DE/EN, mit Language als Parameter — main.ts hat 1-Arg-Adapter).
|
|
||||||
- 24 Tests.
|
|
||||||
|
|
||||||
## 5.0.0 (GA, 2026-05-11)
|
|
||||||
|
|
||||||
Major-Bump. 4.6.155-User sehen das ueber Auto-Updater (sobald Gitea-Release publiziert ist).
|
|
||||||
|
|
||||||
### Pillar 3 — SQLite-Migration (DONE, Breaking)
|
|
||||||
|
|
||||||
Source-of-Truth bleibt JSON (`C:\ProgramData\Twitch_VOD_Manager\config.json` + `download_queue.json`) — SQLite ist Shadow-Schreibziel. Cutover (SQLite wird Master) erfolgt in spaeterem Release.
|
|
||||||
|
|
||||||
- **Schema v5** in `src/main/infra/schema-v5.ts` (inline SQL-Konstante, kein non-TS Asset im Build):
|
|
||||||
- `schema_meta(key PK, value)` — version-tracking.
|
|
||||||
- `config_kv(key PK, value, updated_at)` — KV-Mirror der config.json fuer alle ungebundenen Keys.
|
|
||||||
- `queue_items(id PK, streamer_login, vod_id, clip_id, title, output_path, status, progress_pct, error_message, created_at, updated_at, completed_at, payload_json)` — Mirror der Queue, mit Index auf status / streamer_login / created_at.
|
|
||||||
- `downloaded_vods(vod_id PK, downloaded_at)` — bounded list von schon-runtergeladenen VODs.
|
|
||||||
- `streamers(login PK, auto_record, auto_vod_download, added_at)` — Streamer-Watchlist, Indices auf beiden auto_* Flags.
|
|
||||||
- `archive_files(path PK, streamer_login, size_bytes, duration_seconds, created_at, verified)` — Archiv-Index, Index auf streamer_login.
|
|
||||||
- `migrations_applied(name PK, applied_at, payload)` — Migrator-Marker (Idempotenz).
|
|
||||||
- `oauth_accounts(id PK AUTOINC, provider, twitch_user_id, login, display_name, encrypted_access_token, encrypted_refresh_token, expires_at, scopes_json, is_default, created_at, updated_at)` — UNIQUE(provider, twitch_user_id), Indices auf provider + is_default.
|
|
||||||
- `chunk_index(id PK AUTOINC, item_id, chunk_seq, sha1_hex, bytes, created_at)` — UNIQUE(item_id, chunk_seq), Indices auf item_id + sha1_hex.
|
|
||||||
|
|
||||||
- **`db.ts` Wrapper** (`src/main/infra/`):
|
|
||||||
- better-sqlite3 unter der Haube, WAL-Journal + busy_timeout 5000ms + foreign_keys ON.
|
|
||||||
- Public `DbHandle` Interface: run / get / all / transaction / runBatch / close / raw.
|
|
||||||
- 10 Tests (creates file, schema_meta=5, WAL aktiv, idempotenter open, roundtrip insert/select, transaction commit + rollback, oauth_accounts INSERT + UNIQUE, chunk_index INSERT + UNIQUE).
|
|
||||||
|
|
||||||
- **Migrator** (`src/main/domain/migrator.ts`):
|
|
||||||
- Idempotent (marker in migrations_applied, zweimaliger Aufruf = identischer Endzustand).
|
|
||||||
- Fail-soft (malformed JSON wird ins `errors[]` Array geschrieben, kein Crash).
|
|
||||||
- Per-source `.v4-backup` Copy nach erfolgreichem Migrations-Run.
|
|
||||||
- 31 whitelisted config_kv keys (language, performance_mode, alle filename_template_*, alle discord_*, alle auto_*, etc.).
|
|
||||||
- downloaded_vod_ids als Set-Insert in `downloaded_vods`.
|
|
||||||
- auto_record_streamers + auto_vod_download_streamers normalisiert via `normalizeLogin` (lowercase + @-Stripping), upserted in `streamers` mit dem entsprechenden Flag.
|
|
||||||
- download_queue.json -> queue_items, mit payload_json als kompletter JSON-Dump des Original-Items (so dass auch unbekannte Felder erhalten bleiben).
|
|
||||||
- 8 Tests (leeres AppData / config-keys / vod-ids / streamers / queue / idempotent / backup-files / malformed-json).
|
|
||||||
|
|
||||||
- **DB-Handle-Singleton** in main.ts:
|
|
||||||
- Long-lived `appDb: DbHandle | null` Modul-scope, geoeffnet im `app.whenReady` Block.
|
|
||||||
- `getAppDb()` Getter exportiert (Voraussetzung fuer kommende Recorder-Integration).
|
|
||||||
- `shutdownCleanup` schliesst die DB vor dem Debug-Log-Flush -> WAL-Checkpoint sauber.
|
|
||||||
- Fail-soft: bei better-sqlite3 Native-Build-Fehler bleibt `appDb = null` und die App startet trotzdem (JSON-Pfad ist weiterhin der Master).
|
|
||||||
- Logger-Output: JSON-serialisiertes MigrationResult landet in `debug.log` als `[ts] sqlite-migrator | {...}`.
|
|
||||||
|
|
||||||
### Pillar 2 — Twitch OAuth (Scaffold)
|
|
||||||
|
|
||||||
Twitch unterstuetzt **kein** Device Code Flow (im goal.md falsch angenommen). Korrektur: Authorization Code Flow + PKCE via System-Browser + Loopback-Redirect (RFC 8252). IPC-Wiring + Renderer-Login-Button + Twitch Dev App Registrierung folgen post-5.0.
|
|
||||||
|
|
||||||
- **`secure-storage` Modul** (`src/main/infra/`):
|
|
||||||
- `SecureStorage` Interface (isEncryptionAvailable, encrypt, decrypt).
|
|
||||||
- `MemorySecureStorage` (base64 ohne echte Crypto, fuer Tests / Headless-Envs — meldet `isEncryptionAvailable()=false`).
|
|
||||||
- `createElectronSecureStorage()` wrappt `electron.safeStorage` (Win Credential Manager / macOS Keychain / Linux libsecret).
|
|
||||||
- 7 Tests.
|
|
||||||
|
|
||||||
- **`token-store` Modul** (`src/main/domain/`):
|
|
||||||
- CRUD auf `oauth_accounts` mit Encryption-on-Write / Decryption-on-Read.
|
|
||||||
- upsert mit ON CONFLICT(provider, twitch_user_id) DO UPDATE — neue Token ueberschreiben alte.
|
|
||||||
- getDefault / setDefault sind provider-scoped (genau ein Default pro Provider).
|
|
||||||
- Scopes serialisiert als JSON-Array.
|
|
||||||
- 11 Tests (insert / update-not-duplicate / list / filter / default-toggle / getAccessToken decrypt / scopes / delete / expiresAt / list-by-id).
|
|
||||||
|
|
||||||
- **PKCE-Pair** (`src/main/domain/pkce.ts`):
|
|
||||||
- 32-Byte verifier (43-char base64url), S256 challenge.
|
|
||||||
- `generateState()` fuer CSRF-Schutz.
|
|
||||||
- 7 Tests (S256 marker, charset, sha256-derived, entropy).
|
|
||||||
|
|
||||||
- **Loopback-Server** (`src/main/infra/loopback-server.ts`):
|
|
||||||
- Ephemerer HTTP-Server auf 127.0.0.1:PORT (port=0 = OS waehlt, kein Firewall-Prompt).
|
|
||||||
- Path-Prefix-Filter (default `/oauth/callback`).
|
|
||||||
- HTML-Success / Error Antwort fuer den User-Browser.
|
|
||||||
- `awaitParams({timeoutMs})` returnt `URLSearchParams` oder rejected.
|
|
||||||
- 5 Tests.
|
|
||||||
|
|
||||||
- **Twitch-OAuth Flow** (`src/main/domain/twitch-oauth.ts`):
|
|
||||||
- `startLoginFlow({clientId, scopes})` -> Auth-URL + Loopback-Server.
|
|
||||||
- `awaitAuthorizationCode(flow)` validiert state (CSRF), wirft auf error= oder missing code.
|
|
||||||
- `exchangeCodeForToken(...)` POST an `id.twitch.tv/oauth2/token` mit PKCE verifier.
|
|
||||||
- `fetchTwitchUserInfo(accessToken, clientId)` -> Helix `/users` -> twitch_user_id + login + display_name.
|
|
||||||
- Alle Helix-Calls nehmen `fetchImpl` als optionalen Parameter (Testbarkeit ohne echten HTTP-Call).
|
|
||||||
- 9 Tests.
|
|
||||||
|
|
||||||
### Pillar 6 — Smart-Resume (Foundation)
|
|
||||||
|
|
||||||
- **`chunk-hash` Modul** (`src/main/infra/`):
|
|
||||||
- `hashBuffer(Buffer)` -> sha1 hex (sync).
|
|
||||||
- `hashFile(path)` -> Promise<sha1 hex> (streaming, blockiert keinen Event-Loop bei grossen Recorded-Segments).
|
|
||||||
- 8 Tests (known-vectors fuer 'hello' und empty, large-buffer determinism, file=buffer hash, missing-file rejects).
|
|
||||||
|
|
||||||
- **`chunk-index-store` Modul** (`src/main/domain/`):
|
|
||||||
- CRUD auf `chunk_index`.
|
|
||||||
- record / listForItem (sorted by chunk_seq) / countForItem / lookupBySha1 (dedupe candidates) / deleteForItem (returns count).
|
|
||||||
- ON CONFLICT(item_id, chunk_seq) DO UPDATE -> re-recorded segments ueberschreiben den alten Hash.
|
|
||||||
- 8 Tests.
|
|
||||||
|
|
||||||
### Pillar 4 — Architecture Split (Start)
|
|
||||||
|
|
||||||
main.ts ging von 7485 LoC auf 7276 LoC (-209). 20 Module in `src/main/{infra,domain}/`. 18 Test-Files.
|
|
||||||
|
|
||||||
**Foundation (5.0.0-alpha.0):**
|
|
||||||
- `vitest` als Test-Runner (replacements/erweiterung der bisherigen Playwright-only Suite).
|
|
||||||
- `test:unit` + `test:unit:watch` Scripts; `test:e2e:release` kettet jetzt build + unit + update-logic + 3 Playwright-Stages.
|
|
||||||
- 5 Initial-Extraktionen (alle pure, alle mit Tests):
|
|
||||||
- `src/main/infra/fs-atomic.ts` — `writeFileAtomicSync` (tmp+rename, EPERM-Retry-Pattern, Windows-Fallback copy+unlink). 6 Tests.
|
|
||||||
- `src/main/infra/duration.ts` — `parseDuration`, `formatDuration`, `formatDurationDashed`. 18 Tests.
|
|
||||||
- `src/main/domain/update-version-utils.ts` — verschoben von `src/`, `normalizeUpdateVersion`/`compareUpdateVersions`/`isNewerUpdateVersion`. 16 Tests.
|
|
||||||
- `src/main/domain/i18n-backend.ts` — `BACKEND_MESSAGES` (DE/EN, 38 keys) + pure `tBackend(key, params, lang)`. main.ts hat 2-arg Adapter der `config.language` injiziert. 8 Tests.
|
|
||||||
- `src/main/domain/config-normalize.ts` — 8 pure Normalizer (`normalizeAutoRecordPollSeconds`, `normalizeAutoRecordList`, `normalizeStreamlinkQuality`, `normalizeFilenameTemplate`, `normalizeMetadataCacheMinutes`, `normalizePerformanceMode`, `isPlainObject`, `normalizeLogin`) + `VALID_STREAMLINK_QUALITIES` + `PerformanceMode` type. 47 Tests.
|
|
||||||
|
|
||||||
**Post-Foundation (5.1.0-alpha.0):**
|
|
||||||
- `src/main/infra/format-helpers.ts` (siehe oben).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Breaking Changes
|
|
||||||
|
|
||||||
| # | Was bricht | Migration | User-Impact |
|
|
||||||
|---|---|---|---|
|
|
||||||
| BC-1 | Config-Speicherort additiv erweitert | Migrator on first 5.0 start, `.v4-backup` der JSONs bleibt liegen | Einmalig sek-30s Migrations-Dialog (silent, ins debug.log geloggt) |
|
|
||||||
| BC-2 | Auto-Updater Channel | 4.6-User auf `stable` erkennen 5.0.0 sobald Gitea-Release publiziert ist | Auto-Update wie immer (Klick Install bei Notification) |
|
|
||||||
|
|
||||||
Kein hard-break fuer existing State. Migrator ist additiv + idempotent. Wenn er fehlschlaegt, bleibt JSON der Master.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Vergleich: 4.6.155 vs 5.1.0-alpha.1
|
|
||||||
|
|
||||||
| Metric | 4.6.155 | 5.1.0-alpha.1 |
|
|
||||||
|---|---|---|
|
|
||||||
| Total LoC (src/) | 16016 | ~16700 (mit Tests) |
|
|
||||||
| main.ts | 7485 | 7276 |
|
|
||||||
| Module in src/main/ | 0 | 20 |
|
|
||||||
| Unit Tests (vitest) | 0 | 219 |
|
|
||||||
| E2E Tests (Playwright) | 4 stages | 4 stages (gleich) |
|
|
||||||
| Dependencies | axios, electron-updater | + better-sqlite3 |
|
|
||||||
| DevDeps | playwright, typescript, eslint | + vitest, @types/better-sqlite3 |
|
|
||||||
| SQLite-Schema | n/a | v5 (9 tables) |
|
|
||||||
| OAuth-Support | n/a | scaffold (AuthCode+PKCE) |
|
|
||||||
| Smart-Resume | n/a | chunk-hash + index store |
|
|
||||||
| Command Palette | n/a | Ctrl+K |
|
|
||||||
| Themes | 5 (Twitch, Discord, YouTube, Apple, Light) | gleich |
|
|
||||||
| Locales | DE, EN | gleich |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Was in 5.1.x noch kommt (Roadmap, post diesem Release)
|
|
||||||
|
|
||||||
- **Pillar 2 OAuth-Vervollstaendigung**: IPC-Handler `oauth-twitch-login`/`-logout`/`-whoami`, Renderer-Login-Button im Settings-Tab, Client-ID-Config-Feld (Twitch Dev App Registration ist User-seitig).
|
|
||||||
- **Pillar 4 Architektur-Split**: state-coupled Module aus main.ts (twitch-api, queue, download, record, cutter, stats, updater). Voraussetzung: State-Container-Design entscheiden.
|
|
||||||
- **Pillar 5 UI Power-Restpaket**: Virtual List fuer Queue/Archive (10k+ Eintraege), Mini-Player (HLS-Stream Vorschau via hls.js), Drag-Reorder, mehr Command-Palette-Commands (Streamer-Suche).
|
|
||||||
- **Pillar 6 Smart-Resume Integration**: chunk-hash-Producer in den live Recorder einklinken, Resume-on-startup Dialog.
|
|
||||||
- **Pillar 7 Auto-Discovery Erweiterung**: Top-Clip-Crawler Scheduler (alle X Tage), Follow-Sync (importiert die Follow-Liste des eingeloggten Twitch-Users), Top-Clip-Modal im Streamer-Tab.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Checklist fuer Endnutzer
|
|
||||||
|
|
||||||
Beim ersten Start von 5.0 (oder 5.1) auf einer bestehenden 4.6.x-Installation:
|
|
||||||
|
|
||||||
1. App starten — Migrator laeuft automatisch, fail-soft.
|
|
||||||
2. Pruefen: `%PROGRAMDATA%\Twitch_VOD_Manager\app.db` existiert.
|
|
||||||
3. Pruefen: `config.json.v4-backup` und `download_queue.json.v4-backup` liegen daneben.
|
|
||||||
4. debug.log nach `sqlite-migrator | ` Zeile suchen — sollte JSON mit `configMigrated:true, queueMigrated:true, downloadedVodsCount:N, streamersCount:N` enthalten.
|
|
||||||
5. Bei Problemen: einfach 4.6.155 reinstallieren — JSON ist unangetastet, SQLite war nur Shadow-Schreibziel.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build / Release
|
|
||||||
|
|
||||||
Setup.exe gebaut mit `npm run dist:win`. Auto-Updater pickt das via `latest.yml` (sha512 + size + releaseDate).
|
|
||||||
|
|
||||||
Co-Authored-By: Claude Opus 4.7 (1M context)
|
|
||||||
61
CLAUDE.md
61
CLAUDE.md
@ -1,61 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Build & Run Commands
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `npm run build` | Compile TypeScript (`tsc` → `dist/`) |
|
|
||||||
| `npm start` | Build + launch Electron app |
|
|
||||||
| `npm run test:unit` | Vitest Unit-Tests (`src/**/*.test.ts`) |
|
|
||||||
| `npm run test:unit:watch` | Vitest Watch-Mode |
|
|
||||||
| `npm run test:e2e:update-logic` | Unit test for version comparison logic (fast, no Playwright) |
|
|
||||||
| `npm run test:e2e` | Basic Playwright smoke test |
|
|
||||||
| `npm run test:e2e:guide` | Template guide smoke test |
|
|
||||||
| `npm run test:e2e:full` | Full end-to-end test suite |
|
|
||||||
| `npm run test:e2e:release` | Full pre-release verification (build + unit + update-logic + 3 Playwright-Stages) |
|
|
||||||
| `npm run dist:win` | Run all tests then build Windows NSIS installer |
|
|
||||||
| `npm run release:gitea` | Upload release assets to Gitea (requires `GITEA_TOKEN` env var) |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**Electron app** (TypeScript, no UI framework — vanilla HTML/CSS/JS in renderer).
|
|
||||||
|
|
||||||
### Process Model
|
|
||||||
|
|
||||||
- **Main process** (`src/main.ts`, ~7300 lines): Core logic — Twitch API, download queue, streamlink/ffmpeg orchestration, config persistence, auto-updater, caching
|
|
||||||
- **Preload** (`src/preload.ts`): Context bridge exposing a controlled `window.api` IPC surface
|
|
||||||
- **Renderer** (`src/renderer*.ts`): UI split across multiple files by concern (queue, settings, updates, streamers, localization)
|
|
||||||
- **Extrahierte Module** (`src/main/`): v5-Architektur-Umbau im Gang. Pure Helpers in `src/main/infra/` (fs-atomic, duration, format-helpers, chunk-hash, db, schema-v5, secure-storage, loopback-server) und Domain in `src/main/domain/` (update-version-utils, i18n-backend, config-normalize, migrator, token-store, chunk-index-store, integrity-check, archive-files-store, pkce, twitch-oauth). 19 Module gesamt + 17 Test-Files (210 unit-tests in vitest). Roadmap fuer den vollstaendigen Split: `tasks/v5.0.0-roadmap.md`.
|
|
||||||
|
|
||||||
### Key Patterns
|
|
||||||
|
|
||||||
- **IPC via context bridge only** — all main↔renderer communication goes through `preload.ts`. Never use `ipcRenderer` directly.
|
|
||||||
- **State**: Global variables in both processes. Main process is source of truth; renderer syncs via IPC events.
|
|
||||||
- **Persistence**: JSON files in `C:\ProgramData\Twitch_VOD_Manager\` (config, queue, debug log) bleiben Source-of-Truth. Config auto-saves with debounce. Seit v5.0.0-alpha.1 spiegelt der Migrator (`src/main/domain/migrator.ts`) Konfiguration, Queue, downloaded_vod_ids und Streamer-Listen einmalig nach `app.db` (SQLite, better-sqlite3). Schema: `src/main/infra/schema-v5.ts`. Cutover (SQLite wird Master) in spaeterem Plan.
|
|
||||||
- **Queue sync throttling**: Adaptive refresh rates based on window visibility (900ms active → 9000ms hidden).
|
|
||||||
- **Caching**: Multi-tier in-memory caches (user IDs, VOD lists, clip info) with configurable TTL and periodic cleanup.
|
|
||||||
- **Error classification**: Errors categorized as network/rate_limit/auth/tooling/integrity/io/validation/unknown with retry scheduling.
|
|
||||||
- **Secrets**: OAuth-Token (Twitch + spaeter) landen verschluesselt via Electron `safeStorage` (Win Credential Manager) in `oauth_accounts` Tabelle der SQLite. Zugriff ueber `src/main/domain/token-store.ts`. Memory-Impl fuer Tests.
|
|
||||||
- **Smart-Resume**: HLS-Chunk-sha1-Hashes koennen in `chunk_index` Tabelle protokolliert werden (`src/main/domain/chunk-index-store.ts`, `src/main/infra/chunk-hash.ts`). Vorbereitung fuer Crash-Recovery + Integrity-Check; Integration in Live-Recorder folgt post-5.0.
|
|
||||||
- **OAuth (Twitch)**: Authorization Code Flow + PKCE via System-Browser + Loopback (127.0.0.1:PORT). Module: `src/main/domain/{pkce,twitch-oauth}.ts`, `src/main/infra/loopback-server.ts`. IPC-Handler + Renderer-Button + Client-ID-Config in 5.1.x — Module sind in 5.0.0 scaffold-complete, brauchen lediglich Twitch-Dev-App-Registration.
|
|
||||||
- **Localization**: EN (`renderer-locale-en.ts`) and DE (`renderer-locale-de.ts`) string tables.
|
|
||||||
|
|
||||||
### External Tools
|
|
||||||
|
|
||||||
Downloads depend on **streamlink** and **ffmpeg** being available. The app caches their paths with 10-second TTL.
|
|
||||||
|
|
||||||
## Release Process
|
|
||||||
|
|
||||||
Documented in `README_AI_RELEASE.md`. Key points:
|
|
||||||
- Hosted on Gitea at `git.24-music.de`
|
|
||||||
- Auto-updater requires 3 assets per release: `.exe`, `.exe.blockmap`, `latest.yml`
|
|
||||||
- Version set via `npm version X.Y.Z --no-git-tag-version`, commit as `release: X.Y.Z`, tag as `vX.Y.Z`
|
|
||||||
- Never commit tokens; use `$env:GITEA_TOKEN` in PowerShell
|
|
||||||
|
|
||||||
## TypeScript Config
|
|
||||||
|
|
||||||
- Target: ES2022, strict mode enabled
|
|
||||||
- Source in `src/`, compiled to `dist/`
|
|
||||||
- No linter configured — TypeScript strict mode is the primary safety net
|
|
||||||
1786
package-lock.json
generated
1786
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "5.0.14",
|
"version": "4.6.155",
|
||||||
"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",
|
||||||
@ -8,13 +8,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "npm run build && electron .",
|
"start": "npm run build && electron .",
|
||||||
"test:unit": "vitest run --passWithNoTests",
|
|
||||||
"test:unit:watch": "vitest",
|
|
||||||
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
|
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
|
||||||
"test:e2e": "node scripts/smoke-test.js",
|
"test:e2e": "node scripts/smoke-test.js",
|
||||||
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
||||||
"test:e2e:full": "node scripts/smoke-test-full.js",
|
"test:e2e:full": "node scripts/smoke-test-full.js",
|
||||||
"test:e2e:release": "npm run build && npm run test:unit && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
"test:e2e:release": "npm run build && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
||||||
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
@ -23,22 +21,19 @@
|
|||||||
"test:merge-split": "node scripts/smoke-test-merge-split-logic.js"
|
"test:merge-split": "node scripts/smoke-test-merge-split-logic.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.6.0",
|
||||||
"better-sqlite3": "^12.10.0",
|
"electron-updater": "^6.1.0"
|
||||||
"electron-updater": "^6.8.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"electron": "^28.0.0",
|
"electron": "^28.0.0",
|
||||||
"electron-builder": "^24.9.0",
|
"electron-builder": "^24.9.0",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.1.0",
|
||||||
"eslint-plugin-security": "^4.0.0",
|
"eslint-plugin-security": "^4.0.0",
|
||||||
"playwright": "^1.60.0",
|
"playwright": "^1.59.1",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"typescript-eslint": "^8.59.4",
|
"typescript-eslint": "^8.57.1"
|
||||||
"vitest": "^4.1.6"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "de.24-music.twitch-vod-manager",
|
"appId": "de.24-music.twitch-vod-manager",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const {
|
|||||||
normalizeUpdateVersion,
|
normalizeUpdateVersion,
|
||||||
compareUpdateVersions,
|
compareUpdateVersions,
|
||||||
isNewerUpdateVersion
|
isNewerUpdateVersion
|
||||||
} = require(path.join(process.cwd(), 'dist', 'main', 'domain', 'update-version-utils.js'));
|
} = require(path.join(process.cwd(), 'dist', 'update-version-utils.js'));
|
||||||
|
|
||||||
function run() {
|
function run() {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
// UI-Screenshot-Harness — laedt die App, navigiert durch Tabs + Streamer
|
|
||||||
// + Themes und schreibt PNGs nach tmp_ui_shots/. Kein Test-Assert, nur
|
|
||||||
// visuelle Forensik fuer UI-Polishing.
|
|
||||||
//
|
|
||||||
// Run: node scripts/ui-screenshot.js
|
|
||||||
const { _electron: electron } = require('playwright');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const OUT_DIR = path.join(process.cwd(), 'tmp_ui_shots');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
fs.rmSync(OUT_DIR, { recursive: true, force: true });
|
|
||||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
||||||
|
|
||||||
const electronPath = require('electron');
|
|
||||||
const app = await electron.launch({
|
|
||||||
executablePath: electronPath,
|
|
||||||
args: ['.'],
|
|
||||||
cwd: process.cwd()
|
|
||||||
});
|
|
||||||
const win = await app.firstWindow();
|
|
||||||
await win.setViewportSize({ width: 1280, height: 900 });
|
|
||||||
await win.waitForTimeout(2500);
|
|
||||||
|
|
||||||
const shot = async (name) => {
|
|
||||||
const file = path.join(OUT_DIR, `${name}.png`);
|
|
||||||
try {
|
|
||||||
await win.screenshot({ path: file, animations: 'disabled', timeout: 8000 });
|
|
||||||
process.stdout.write(`shot: ${name}\n`);
|
|
||||||
} catch (e) {
|
|
||||||
process.stdout.write(`shot FAILED: ${name} — ${String(e).split('\n')[0]}\n`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Misst die Y-Position aller .vod-actions relativ zu ihrer Grid-Reihe.
|
|
||||||
// Buttons in derselben Reihe muessen die gleiche bottom-Y haben.
|
|
||||||
const checkButtonAlignment = async (label) => {
|
|
||||||
const rows = await win.evaluate(() => {
|
|
||||||
const cards = Array.from(document.querySelectorAll('.vod-card'));
|
|
||||||
const byRow = {};
|
|
||||||
for (const card of cards) {
|
|
||||||
const actions = card.querySelector('.vod-actions');
|
|
||||||
if (!actions) continue;
|
|
||||||
const cardRect = card.getBoundingClientRect();
|
|
||||||
const actRect = actions.getBoundingClientRect();
|
|
||||||
const rowKey = Math.round(cardRect.top / 10) * 10; // Reihe nach top gruppieren
|
|
||||||
(byRow[rowKey] = byRow[rowKey] || []).push(Math.round(actRect.top));
|
|
||||||
}
|
|
||||||
// Pro Reihe: max-min der button-tops (sollte ~0 sein wenn aligned)
|
|
||||||
const result = [];
|
|
||||||
for (const [row, tops] of Object.entries(byRow)) {
|
|
||||||
if (tops.length < 2) continue;
|
|
||||||
result.push({ row: Number(row), spread: Math.max(...tops) - Math.min(...tops), count: tops.length });
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
const maxSpread = rows.reduce((m, r) => Math.max(m, r.spread), 0);
|
|
||||||
process.stdout.write(` [align ${label}] rows=${rows.length} maxButtonTopSpread=${maxSpread}px ${maxSpread <= 2 ? 'OK' : 'MISALIGNED'}\n`);
|
|
||||||
return maxSpread;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Settings tab (alle Inputs/Selects/Checkboxes sichtbar)
|
|
||||||
await win.evaluate(() => window.showTab('settings'));
|
|
||||||
await win.waitForTimeout(600);
|
|
||||||
await shot('01-settings');
|
|
||||||
|
|
||||||
// 2. Streamer mit vielen VODs + variablen Titel-Laengen
|
|
||||||
const streamers = ['xqc', 'papaplatte', 'xrohat'];
|
|
||||||
for (const s of streamers) {
|
|
||||||
await win.evaluate(() => window.showTab('vods'));
|
|
||||||
await win.evaluate(async (name) => { await window.selectStreamer(name); }, s);
|
|
||||||
await win.waitForTimeout(4500);
|
|
||||||
const count = await win.locator('.vod-card').count();
|
|
||||||
process.stdout.write(` ${s}: ${count} vod-cards\n`);
|
|
||||||
await checkButtonAlignment(s);
|
|
||||||
await shot(`02-vods-${s}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Queue (mit einem item drin)
|
|
||||||
await win.evaluate(() => window.showTab('vods'));
|
|
||||||
const vodCount = await win.locator('.vod-card').count();
|
|
||||||
if (vodCount > 0) {
|
|
||||||
await win.locator('.vod-card .vod-btn.primary').first().click().catch(() => {});
|
|
||||||
await win.waitForTimeout(800);
|
|
||||||
}
|
|
||||||
await win.evaluate(() => window.showTab('queue'));
|
|
||||||
await win.waitForTimeout(500);
|
|
||||||
await shot('03-queue');
|
|
||||||
|
|
||||||
// 4. Clips / Cutter / Merge / Stats / Archive tabs
|
|
||||||
for (const tab of ['clips', 'cutter', 'merge', 'stats', 'archive']) {
|
|
||||||
await win.evaluate((t) => window.showTab(t), tab);
|
|
||||||
await win.waitForTimeout(600);
|
|
||||||
await shot(`04-tab-${tab}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Themes durchschalten — auf VODs-Tab mit xqc geladen
|
|
||||||
await win.evaluate(() => window.showTab('vods'));
|
|
||||||
await win.evaluate(async () => { await window.selectStreamer('xqc'); });
|
|
||||||
await win.waitForTimeout(3500);
|
|
||||||
for (const theme of ['discord', 'youtube', 'apple', 'light', 'twitch']) {
|
|
||||||
await win.evaluate((t) => window.changeTheme(t), theme);
|
|
||||||
await win.waitForTimeout(500);
|
|
||||||
await shot(`05-theme-${theme}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Settings im Light-Theme (Input-Backgrounds pruefen)
|
|
||||||
await win.evaluate(() => window.changeTheme('light'));
|
|
||||||
await win.evaluate(() => window.showTab('settings'));
|
|
||||||
await win.waitForTimeout(600);
|
|
||||||
await shot('06-settings-light');
|
|
||||||
await win.evaluate(() => window.changeTheme('twitch'));
|
|
||||||
|
|
||||||
await app.close();
|
|
||||||
process.stdout.write(`\nDone. PNGs in ${OUT_DIR}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((e) => { process.stderr.write(String(e) + '\n'); process.exit(1); });
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
// UI-Screenshot Teil 2 — Modals, Hover-States, schmales Fenster, Queue mit
|
|
||||||
// aktivem Download. Ergaenzt ui-screenshot.js fuer die selteneren UI-Pfade.
|
|
||||||
const { _electron: electron } = require('playwright');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const OUT_DIR = path.join(process.cwd(), 'tmp_ui_shots');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
||||||
const electronPath = require('electron');
|
|
||||||
const app = await electron.launch({ executablePath: electronPath, args: ['.'], cwd: process.cwd() });
|
|
||||||
const win = await app.firstWindow();
|
|
||||||
await win.setViewportSize({ width: 1280, height: 900 });
|
|
||||||
await win.waitForTimeout(2500);
|
|
||||||
|
|
||||||
const shot = async (name) => {
|
|
||||||
try { await win.screenshot({ path: path.join(OUT_DIR, `${name}.png`), animations: 'disabled', timeout: 8000 }); process.stdout.write(`shot: ${name}\n`); }
|
|
||||||
catch (e) { process.stdout.write(`shot FAILED: ${name} — ${String(e).split('\n')[0]}\n`); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// VODs mit xqc laden fuer Modal-Kontext
|
|
||||||
await win.evaluate(() => window.showTab('vods'));
|
|
||||||
await win.evaluate(async () => { await window.selectStreamer('xqc'); });
|
|
||||||
await win.waitForTimeout(4000);
|
|
||||||
|
|
||||||
// 1. Clip/Trim-Dialog Modal (Trim VOD auf erster Card)
|
|
||||||
const trimBtn = win.locator('.vod-card .vod-btn.secondary').first();
|
|
||||||
if (await trimBtn.count()) {
|
|
||||||
await trimBtn.click().catch(() => {});
|
|
||||||
await win.waitForTimeout(800);
|
|
||||||
await shot('07-modal-trim');
|
|
||||||
await win.keyboard.press('Escape');
|
|
||||||
await win.waitForTimeout(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Command Palette (Ctrl+K)
|
|
||||||
await win.keyboard.press('Control+K');
|
|
||||||
await win.waitForTimeout(500);
|
|
||||||
await shot('08-command-palette');
|
|
||||||
// Tippe etwas zum Filtern
|
|
||||||
await win.keyboard.type('set');
|
|
||||||
await win.waitForTimeout(300);
|
|
||||||
await shot('08-command-palette-filtered');
|
|
||||||
await win.keyboard.press('Escape');
|
|
||||||
await win.waitForTimeout(300);
|
|
||||||
|
|
||||||
// 3. Template Guide Modal (Settings -> Template-Guide oeffnen falls Button da)
|
|
||||||
await win.evaluate(() => window.showTab('settings'));
|
|
||||||
await win.waitForTimeout(500);
|
|
||||||
const tgOpened = await win.evaluate(() => {
|
|
||||||
if (typeof window.openTemplateGuide === 'function') { window.openTemplateGuide(); return true; }
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (tgOpened) {
|
|
||||||
await win.waitForTimeout(700);
|
|
||||||
await shot('09-template-guide');
|
|
||||||
await win.keyboard.press('Escape');
|
|
||||||
await win.waitForTimeout(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. VOD-Card Hover (Storyboard-Preview)
|
|
||||||
await win.evaluate(() => window.showTab('vods'));
|
|
||||||
await win.waitForTimeout(800);
|
|
||||||
const firstCard = win.locator('.vod-card').first();
|
|
||||||
if (await firstCard.count()) {
|
|
||||||
await firstCard.hover();
|
|
||||||
await win.waitForTimeout(1200); // debounce + storyboard fetch
|
|
||||||
await shot('10-vod-hover');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Schmales Fenster (responsive) — 760px
|
|
||||||
await win.setViewportSize({ width: 760, height: 900 });
|
|
||||||
await win.waitForTimeout(800);
|
|
||||||
await shot('11-narrow-vods');
|
|
||||||
await win.evaluate(() => window.showTab('settings'));
|
|
||||||
await win.waitForTimeout(500);
|
|
||||||
await shot('11-narrow-settings');
|
|
||||||
|
|
||||||
// 6. Sehr schmal — 520px
|
|
||||||
await win.setViewportSize({ width: 520, height: 800 });
|
|
||||||
await win.evaluate(() => window.showTab('vods'));
|
|
||||||
await win.waitForTimeout(800);
|
|
||||||
await shot('12-tiny-vods');
|
|
||||||
|
|
||||||
await app.close();
|
|
||||||
process.stdout.write(`\nDone part 2.\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((e) => { process.stderr.write(String(e) + '\n'); process.exit(1); });
|
|
||||||
@ -330,7 +330,7 @@
|
|||||||
<div class="video-preview" id="cutterPreview">
|
<div class="video-preview" id="cutterPreview">
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||||
<p id="cutterPreviewPlaceholder">Video auswaehlen um Vorschau zu sehen</p>
|
<p>Video auswahlen um Vorschau zu sehen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -811,23 +811,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
|
||||||
<div class="modal command-palette">
|
|
||||||
<h2 id="commandPaletteTitle" class="cp-title">Command Palette</h2>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="commandPaletteInput"
|
|
||||||
class="cp-input"
|
|
||||||
placeholder="Suche Befehl..."
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
aria-label="Command Palette"
|
|
||||||
/>
|
|
||||||
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Command results"></ul>
|
|
||||||
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="../dist/renderer-locale-de.js"></script>
|
<script src="../dist/renderer-locale-de.js"></script>
|
||||||
<script src="../dist/renderer-locale-en.js"></script>
|
<script src="../dist/renderer-locale-en.js"></script>
|
||||||
<script src="../dist/renderer-texts.js"></script>
|
<script src="../dist/renderer-texts.js"></script>
|
||||||
@ -840,7 +823,6 @@
|
|||||||
<script src="../dist/renderer-archive.js"></script>
|
<script src="../dist/renderer-archive.js"></script>
|
||||||
<script src="../dist/renderer-profile.js"></script>
|
<script src="../dist/renderer-profile.js"></script>
|
||||||
<script src="../dist/renderer-vod-hover.js"></script>
|
<script src="../dist/renderer-vod-hover.js"></script>
|
||||||
<script src="../dist/renderer-command-palette.js"></script>
|
|
||||||
<script src="../dist/renderer.js"></script>
|
<script src="../dist/renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
527
src/main.ts
527
src/main.ts
@ -5,31 +5,7 @@ import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
|
|||||||
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
import { connect as tlsConnect, TLSSocket } from 'node:tls';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils';
|
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
|
||||||
import { writeFileAtomicSync } from './main/infra/fs-atomic';
|
|
||||||
import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration';
|
|
||||||
import {
|
|
||||||
sanitizeFilenamePart,
|
|
||||||
formatTwitchDurationFromSeconds,
|
|
||||||
formatDateWithPattern,
|
|
||||||
getMergeGroupPhaseText as getMergeGroupPhaseTextCore,
|
|
||||||
} from './main/infra/format-helpers';
|
|
||||||
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
|
||||||
import type { DbHandle } from './main/infra/db';
|
|
||||||
import {
|
|
||||||
normalizeLogin,
|
|
||||||
normalizeAutoRecordPollSeconds,
|
|
||||||
normalizeAutoRecordList,
|
|
||||||
normalizeStreamlinkQuality,
|
|
||||||
normalizeFilenameTemplate,
|
|
||||||
normalizeMetadataCacheMinutes,
|
|
||||||
normalizePerformanceMode,
|
|
||||||
isPlainObject,
|
|
||||||
VALID_STREAMLINK_QUALITIES,
|
|
||||||
DEFAULT_METADATA_CACHE_MINUTES,
|
|
||||||
DEFAULT_PERFORMANCE_MODE,
|
|
||||||
type PerformanceMode,
|
|
||||||
} from './main/domain/config-normalize';
|
|
||||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types';
|
||||||
import {
|
import {
|
||||||
setDebugLogFn, initToolDirs,
|
setDebugLogFn, initToolDirs,
|
||||||
@ -64,9 +40,8 @@ const DEFAULT_DOWNLOAD_PATH = path.join(app.getPath('desktop'), 'Twitch_VODs');
|
|||||||
const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
|
const DEFAULT_FILENAME_TEMPLATE_VOD = '{title}.mp4';
|
||||||
const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
|
const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
|
||||||
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
|
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
|
||||||
// DEFAULT_METADATA_CACHE_MINUTES + DEFAULT_PERFORMANCE_MODE kommen aus
|
const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
||||||
// ./main/domain/config-normalize (Single-Source-Of-Truth, vermeidet
|
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
||||||
// Drift wenn man eine der Defaults aendert).
|
|
||||||
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
||||||
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
|
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
|
||||||
const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000;
|
const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000;
|
||||||
@ -90,22 +65,121 @@ const DEFAULT_RETRY_DELAY_SECONDS = 5;
|
|||||||
const MIN_FILE_BYTES = 256 * 1024;
|
const MIN_FILE_BYTES = 256 * 1024;
|
||||||
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||||
|
|
||||||
|
type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
||||||
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
||||||
type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
||||||
type UpdateDownloadSource = 'auto' | 'manual';
|
type UpdateDownloadSource = 'auto' | 'manual';
|
||||||
|
|
||||||
function getMergeGroupPhaseText(phase: string): string {
|
function getMergeGroupPhaseText(phase: string): string {
|
||||||
return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de');
|
const isEnglish = config.language === 'en';
|
||||||
|
switch (phase) {
|
||||||
|
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
||||||
|
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
||||||
|
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
||||||
|
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
||||||
|
default: return phase;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// BACKEND I18N
|
// BACKEND I18N
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Backend-Messages sind in src/main/domain/i18n-backend.ts.
|
// User-visible messages produced in main.ts. Keep keys stable — the renderer
|
||||||
// tBackend bleibt als 2-Arg-Adapter hier — pure Variante uebernimmt language
|
// no longer translates these (renderer.ts:downloadClip used to translate a
|
||||||
// als 3. Parameter, der hier aus config.language injected wird.
|
// hardcoded set, which was brittle as the strings drifted). Internal
|
||||||
|
// debug log messages stay English-only since they're developer-facing.
|
||||||
|
const BACKEND_MESSAGES = {
|
||||||
|
de: {
|
||||||
|
invalidVodUrl: 'Ungueltige VOD-URL',
|
||||||
|
invalidClipUrl: 'Ungueltige Clip-URL',
|
||||||
|
clipNotFound: 'Clip nicht gefunden',
|
||||||
|
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
||||||
|
streamlinkMissing: 'Streamlink fehlt.',
|
||||||
|
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
|
||||||
|
streamlinkExitCode: 'Streamlink Fehlercode {code}',
|
||||||
|
ffmpegMissing: 'FFmpeg fehlt.',
|
||||||
|
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
|
||||||
|
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
||||||
|
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
||||||
|
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
||||||
|
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
||||||
|
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
||||||
|
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
||||||
|
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
||||||
|
downloadCancelled: 'Download wurde abgebrochen.',
|
||||||
|
downloadPaused: 'Download wurde pausiert.',
|
||||||
|
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
||||||
|
unknownDownloadError: 'Unbekannter Fehler beim Download',
|
||||||
|
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
||||||
|
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
||||||
|
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
||||||
|
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
||||||
|
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
||||||
|
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
||||||
|
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
||||||
|
statusCheckingTools: 'Prufe Download-Tools...',
|
||||||
|
statusDownloadStarted: 'Download gestartet',
|
||||||
|
statusBytesDownloaded: '{bytes} heruntergeladen',
|
||||||
|
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
|
||||||
|
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
|
||||||
|
preflightNoInternet: 'Keine Internetverbindung erkannt.',
|
||||||
|
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
|
||||||
|
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
|
||||||
|
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
|
||||||
|
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
invalidVodUrl: 'Invalid VOD URL',
|
||||||
|
invalidClipUrl: 'Invalid clip URL',
|
||||||
|
clipNotFound: 'Clip not found',
|
||||||
|
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
|
||||||
|
streamlinkMissing: 'Streamlink is missing.',
|
||||||
|
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
|
||||||
|
streamlinkExitCode: 'Streamlink exit code {code}',
|
||||||
|
ffmpegMissing: 'FFmpeg is missing.',
|
||||||
|
ffmpegMergeFailed: 'FFmpeg merge failed.',
|
||||||
|
ffmpegSplitFailed: 'FFmpeg split failed.',
|
||||||
|
fileTooSmall: 'File too small ({bytes} bytes)',
|
||||||
|
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
|
||||||
|
integrityNoVideo: 'Integrity check failed: no video stream found.',
|
||||||
|
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
|
||||||
|
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
|
||||||
|
integrityFailedGeneric: 'Integrity check failed.',
|
||||||
|
downloadCancelled: 'Download was cancelled.',
|
||||||
|
downloadPaused: 'Download was paused.',
|
||||||
|
downloadFailedExitCode: 'Download failed (exit code {code})',
|
||||||
|
unknownDownloadError: 'Unknown download error',
|
||||||
|
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
|
||||||
|
notAllPartsDownloaded: 'Not all parts could be downloaded.',
|
||||||
|
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
|
||||||
|
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
|
||||||
|
diskSpaceShortGeneric: 'Not enough disk space.',
|
||||||
|
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
||||||
|
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
||||||
|
statusCheckingTools: 'Checking download tools...',
|
||||||
|
statusDownloadStarted: 'Download started',
|
||||||
|
statusBytesDownloaded: '{bytes} downloaded',
|
||||||
|
statusFetchingChatReplay: 'Fetching chat replay...',
|
||||||
|
statusChatMessagesFetched: 'Chat messages fetched: {count}',
|
||||||
|
preflightNoInternet: 'No internet connection detected.',
|
||||||
|
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
|
||||||
|
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
|
||||||
|
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
|
||||||
|
preflightDownloadPathNotWritable: 'Download folder is not writable.'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
||||||
|
|
||||||
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
function tBackend(key: BackendMessageKey, params?: Record<string, string | number>): string {
|
||||||
return tBackendCore(key, params, config?.language ?? 'de');
|
const lang: 'de' | 'en' = config.language === 'en' ? 'en' : 'de';
|
||||||
|
let template: string = BACKEND_MESSAGES[lang][key];
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
template = template.replace(`{${k}}`, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure directories exist
|
// Ensure directories exist
|
||||||
@ -298,15 +372,72 @@ const defaultConfig: Config = {
|
|||||||
delete_parts_after_merge: false
|
delete_parts_after_merge: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// normalize* helpers + VALID_STREAMLINK_QUALITIES + isPlainObject + normalizeLogin
|
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||||
// kommen aus ./main/domain/config-normalize. getStreamlinkStreamArg bleibt
|
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
|
||||||
// hier, da es config liest.
|
function normalizeAutoRecordPollSeconds(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) return 90;
|
||||||
|
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAutoRecordList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const v of value) {
|
||||||
|
if (typeof v !== 'string') continue;
|
||||||
|
const cleaned = normalizeLogin(v);
|
||||||
|
if (cleaned && !seen.has(cleaned)) {
|
||||||
|
seen.add(cleaned);
|
||||||
|
out.push(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist of streamlink stream specifiers we surface in Settings. The
|
||||||
|
// user's choice is passed to streamlink with "best" appended as a fallback
|
||||||
|
// (streamlink supports comma-separated stream lists, picks the first match)
|
||||||
|
// so a missing quality on the source stream still produces a download.
|
||||||
|
const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
|
||||||
|
|
||||||
|
function normalizeStreamlinkQuality(value: unknown): string {
|
||||||
|
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'best';
|
||||||
|
}
|
||||||
|
|
||||||
function getStreamlinkStreamArg(): string {
|
function getStreamlinkStreamArg(): string {
|
||||||
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
|
const choice = normalizeStreamlinkQuality(config.streamlink_quality);
|
||||||
if (choice === 'best') return 'best';
|
if (choice === 'best') return 'best';
|
||||||
|
// Fall back to "best" if the chosen rendition isn't offered (e.g. an
|
||||||
|
// older stream archived before that resolution existed).
|
||||||
return `${choice},best`;
|
return `${choice},best`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
||||||
|
const value = (template || '').trim();
|
||||||
|
return value || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetadataCacheMinutes(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_METADATA_CACHE_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(120, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
||||||
|
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_PERFORMANCE_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfigTemplates(input: Config): Config {
|
function normalizeConfigTemplates(input: Config): Config {
|
||||||
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
// downloaded_vod_ids is bounded so a long-running app doesn't accumulate
|
||||||
// an unbounded list across years of downloads. Latest entries kept.
|
// an unbounded list across years of downloads. Latest entries kept.
|
||||||
@ -387,6 +518,10 @@ function recordDownloadedVodId(vodId: string): void {
|
|||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function loadConfig(): Config {
|
function loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
@ -404,6 +539,30 @@ function loadConfig(): Config {
|
|||||||
return normalizeConfigTemplates(defaultConfig);
|
return normalizeConfigTemplates(defaultConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
|
||||||
|
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
|
||||||
|
const tmpPath = targetPath + '.tmp';
|
||||||
|
|
||||||
|
let fd: number | null = null;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(tmpPath, 'w');
|
||||||
|
fs.writeSync(fd, buffer, 0, buffer.length, 0);
|
||||||
|
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) {
|
||||||
|
try { fs.closeSync(fd); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, targetPath);
|
||||||
|
} catch {
|
||||||
|
// On Windows, rename can fail if target exists or is locked. Fall back to copy.
|
||||||
|
fs.copyFileSync(tmpPath, targetPath);
|
||||||
|
try { fs.unlinkSync(tmpPath); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function saveConfig(config: Config): void {
|
function saveConfig(config: Config): void {
|
||||||
try {
|
try {
|
||||||
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
writeFileAtomicSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||||
@ -937,6 +1096,38 @@ function appendDebugLog(message: string, details?: unknown): void {
|
|||||||
setDebugLogFn(appendDebugLog);
|
setDebugLogFn(appendDebugLog);
|
||||||
initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp'));
|
initToolDirs(TOOLS_STREAMLINK_DIR, TOOLS_FFMPEG_DIR, () => app.getPath('temp'));
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// DURATION HELPERS
|
||||||
|
// ==========================================
|
||||||
|
function parseDuration(duration: string): number {
|
||||||
|
let seconds = 0;
|
||||||
|
const hours = duration.match(/(\d+)h/);
|
||||||
|
const minutes = duration.match(/(\d+)m/);
|
||||||
|
const secs = duration.match(/(\d+)s/);
|
||||||
|
|
||||||
|
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||||
|
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||||
|
if (secs) seconds += parseInt(secs[1]);
|
||||||
|
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationDashed(seconds: number): string {
|
||||||
|
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
const claimedFilenames = new Set<string>();
|
const claimedFilenames = new Set<string>();
|
||||||
const itemClaimedFilenames = new Map<string, Set<string>>();
|
const itemClaimedFilenames = new Map<string, Set<string>>();
|
||||||
|
|
||||||
@ -969,6 +1160,36 @@ function releaseClaimedFilenamesForItem(itemId: string): void {
|
|||||||
itemClaimedFilenames.delete(itemId);
|
itemClaimedFilenames.delete(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
||||||
|
const cleaned = (input || '')
|
||||||
|
.replace(/[<>:"|?*\x00-\x1f]/g, '_')
|
||||||
|
.replace(/[\\/]/g, '_')
|
||||||
|
.trim();
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateWithPattern(date: Date, pattern: string): string {
|
||||||
|
const tokenMap: Record<string, string> = {
|
||||||
|
yyyy: date.getFullYear().toString(),
|
||||||
|
yy: date.getFullYear().toString().slice(-2),
|
||||||
|
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
||||||
|
M: (date.getMonth() + 1).toString(),
|
||||||
|
dd: date.getDate().toString().padStart(2, '0'),
|
||||||
|
d: date.getDate().toString(),
|
||||||
|
HH: date.getHours().toString().padStart(2, '0'),
|
||||||
|
H: date.getHours().toString(),
|
||||||
|
hh: date.getHours().toString().padStart(2, '0'),
|
||||||
|
h: date.getHours().toString(),
|
||||||
|
mm: date.getMinutes().toString().padStart(2, '0'),
|
||||||
|
m: date.getMinutes().toString(),
|
||||||
|
ss: date.getSeconds().toString().padStart(2, '0'),
|
||||||
|
s: date.getSeconds().toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
.replace(/yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g, (token) => tokenMap[token] ?? token)
|
||||||
|
.replace(/\\(.)/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
function formatSecondsWithPattern(totalSeconds: number, pattern: string): string {
|
||||||
const safe = Math.max(0, Math.floor(totalSeconds));
|
const safe = Math.max(0, Math.floor(totalSeconds));
|
||||||
@ -1341,18 +1562,11 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass {
|
|||||||
|
|
||||||
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
|
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
|
||||||
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
||||||
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden') || text.includes('subscriber only') || text.includes('sub-only') || text.includes('not subscribed')) return 'auth';
|
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
|
||||||
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns') || text.includes('http error') || text.includes('connectionerror') || text.includes('readerror')) return 'network';
|
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
|
||||||
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
if (text.includes('streamlink nicht gefunden') || text.includes('streamlink not found') || text.includes('streamlink is missing') || text.includes('ffmpeg') || text.includes('ffprobe') || text.includes('enoent')) return 'tooling';
|
||||||
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
|
if (text.includes('integritaet') || text.includes('integrity') || text.includes('kein videostream') || text.includes('no video stream')) return 'integrity';
|
||||||
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
|
if (text.includes('access denied') || text.includes('permission') || text.includes('disk') || text.includes('file') || text.includes('ordner') || text.includes('folder')) return 'io';
|
||||||
// Twitch-spezifische streamlink errors:
|
|
||||||
// "error: No playable streams found on this URL" — VOD weg / private / sub-only
|
|
||||||
// "error: Could not find any kind of stream" — gleich
|
|
||||||
// "error: Unable to validate session token" — Twitch-API rejected
|
|
||||||
// "error: Unable to fetch access token" — Auth pre-flight failed
|
|
||||||
if (text.includes('no playable streams') || text.includes('could not find any kind of stream')) return 'validation';
|
|
||||||
if (text.includes('access token') || text.includes('session token') || text.includes('signature') || text.includes('integrity token')) return 'auth';
|
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
@ -1693,6 +1907,21 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
|
|||||||
return await requestTwitchLogin();
|
return await requestTwitchLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
|
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
|
||||||
// 4xx (other than 408/429) are application errors and not retried.
|
// 4xx (other than 408/429) are application errors and not retried.
|
||||||
function isTransientAxiosError(err: unknown): boolean {
|
function isTransientAxiosError(err: unknown): boolean {
|
||||||
@ -3043,12 +3272,7 @@ function downloadVODPart(
|
|||||||
onProgress: (progress: DownloadProgress) => void,
|
onProgress: (progress: DownloadProgress) => void,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
partNum: number,
|
partNum: number,
|
||||||
totalParts: number,
|
totalParts: number
|
||||||
/** Erwartete Dauer in Sekunden fuer den Progress-Estimate. Wenn endTime
|
|
||||||
gesetzt ist, ueberschrieben aus dort. Wenn startTime und endTime null
|
|
||||||
sind (Full-VOD), kann Caller hier die VOD-Gesamtdauer reingeben,
|
|
||||||
damit der Bar nicht in indeterminate haengt. 0 = unknown. */
|
|
||||||
expectedTotalSec: number = 0
|
|
||||||
): Promise<DownloadResult> {
|
): Promise<DownloadResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const streamlinkCmd = getStreamlinkCommand();
|
const streamlinkCmd = getStreamlinkCommand();
|
||||||
@ -3058,19 +3282,7 @@ function downloadVODPart(
|
|||||||
// in the VOD output. Off only if the user explicitly disabled it.
|
// in the VOD output. Off only if the user explicitly disabled it.
|
||||||
args.push('--twitch-disable-ads');
|
args.push('--twitch-disable-ads');
|
||||||
}
|
}
|
||||||
// HLS-Segment-Resilience: bei vereinzelten CDN-Fehlern weiter retrien,
|
|
||||||
// statt komplett zu sterben. Twitch hat 2025/26 oefter transiente 403/
|
|
||||||
// timeout-Errors auf einzelne HLS-Segments. Default ist 3 — 5 ist ein
|
|
||||||
// pragmatischer Kompromiss zwischen Resilience und Failing-Fast.
|
|
||||||
args.push('--stream-segment-attempts', '5');
|
|
||||||
args.push('--stream-segment-timeout', '20');
|
|
||||||
args.push('--stream-timeout', '120');
|
|
||||||
// Streamlink-Plugin retry: bei "stream not found on URL"-Erstabfrage
|
|
||||||
// einmal nachhaken, bevor wir den ganzen Run failen.
|
|
||||||
args.push('--retry-streams', '3');
|
|
||||||
args.push('--retry-max', '2');
|
|
||||||
let lastErrorLine = '';
|
let lastErrorLine = '';
|
||||||
const stderrBuffer: string[] = [];
|
|
||||||
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
const expectedDurationSeconds = parseClockDurationSeconds(endTime);
|
||||||
let lastStreamlinkPercent = 0;
|
let lastStreamlinkPercent = 0;
|
||||||
|
|
||||||
@ -3132,31 +3344,9 @@ function downloadVODPart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bytes-basierte Schaetzung statt progress=-1, damit die Bar
|
|
||||||
// determinate bleibt + kontinuierlich waechst. Wenn streamlink
|
|
||||||
// spaeter eine echte % rausgibt (Path B), wird die ueber den
|
|
||||||
// bytes-Estimate gelegt (siehe lastStreamlinkPercent-Logik
|
|
||||||
// im stdout-handler).
|
|
||||||
// Quelle: endTime (--hls-duration arg) ODER expectedTotalSec
|
|
||||||
// Param (fuer Full-VOD wo Caller die Dauer kennt).
|
|
||||||
const expectedDurationSecForEstimate = parseClockDurationSeconds(endTime) || expectedTotalSec;
|
|
||||||
const expectedBytes = expectedDurationSecForEstimate > 0 ? expectedDurationSecForEstimate * 625_000 : 0;
|
|
||||||
let progressEstimate: number;
|
|
||||||
if (lastStreamlinkPercent > 0) {
|
|
||||||
// Streamlink hat % rausgegeben — vertrau dem (genauer als bytes).
|
|
||||||
progressEstimate = lastStreamlinkPercent;
|
|
||||||
} else if (expectedBytes > 0 && downloadedBytes > 0) {
|
|
||||||
// Bytes-Fallback: cap bei 95% damit der Bar nicht 100%
|
|
||||||
// vor dem tatsaechlichen Abschluss hinrennt.
|
|
||||||
progressEstimate = Math.min(95, (downloadedBytes / expectedBytes) * 100);
|
|
||||||
} else {
|
|
||||||
// Keine Info -> echtes Unknown, Bar geht in indeterminate.
|
|
||||||
progressEstimate = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
onProgress({
|
onProgress({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
progress: progressEstimate,
|
progress: -1, // Unknown total
|
||||||
speed: formatSpeed(speed),
|
speed: formatSpeed(speed),
|
||||||
eta: etaStr,
|
eta: etaStr,
|
||||||
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
|
status: tBackend('statusBytesDownloaded', { bytes: formatBytes(downloadedBytes) }),
|
||||||
@ -3169,24 +3359,13 @@ function downloadVODPart(
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const stdoutBuffer: string[] = [];
|
|
||||||
proc.stdout?.on('data', (data: Buffer) => {
|
proc.stdout?.on('data', (data: Buffer) => {
|
||||||
const line = data.toString();
|
const line = data.toString();
|
||||||
// Capture non-progress lines auch fuer Diagnose — streamlink-Windows-
|
// No per-line stdout — streamlink emits 10-100 lines/sec during
|
||||||
// Builds schreiben einige Errors auf stdout statt stderr (z.B. "error:
|
// an active download, which floods the terminal in dev and the
|
||||||
// No playable streams found on this URL"). Wenn stderr leer bleibt,
|
// electron-launched console in prod. Progress + tag parsing
|
||||||
// greift der close-handler auf stdout zurueck.
|
// below extracts everything we need; failures get logged via
|
||||||
stdoutBuffer.push(line);
|
// appendDebugLog from the consumer side.
|
||||||
if (stdoutBuffer.length > 200) stdoutBuffer.shift();
|
|
||||||
const lower = line.toLowerCase();
|
|
||||||
if (lower.includes('error:') || lower.includes('warning:')) {
|
|
||||||
appendDebugLog('download-part-stdout-err', { itemId, message: line.trim() });
|
|
||||||
if (lower.includes('error:')) {
|
|
||||||
// Letzte echte streamlink-Errorzeile, auch wenn auf stdout
|
|
||||||
const errLine = line.split('\n').map(l => l.trim()).filter(l => l.toLowerCase().includes('error:')).pop();
|
|
||||||
if (errLine) lastErrorLine = errLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse progress
|
// Parse progress
|
||||||
const match = line.match(/(\d+\.\d+)%/);
|
const match = line.match(/(\d+\.\d+)%/);
|
||||||
@ -3206,26 +3385,10 @@ function downloadVODPart(
|
|||||||
});
|
});
|
||||||
|
|
||||||
proc.stderr?.on('data', (data: Buffer) => {
|
proc.stderr?.on('data', (data: Buffer) => {
|
||||||
const message = data.toString();
|
const message = data.toString().trim();
|
||||||
if (message.trim()) {
|
if (message) {
|
||||||
stderrBuffer.push(message);
|
lastErrorLine = message.split('\n').pop() || message;
|
||||||
// Bounded buffer — wir wollen nicht 100MB stderr in RAM bei einem
|
appendDebugLog('download-part-stderr', { itemId, message: lastErrorLine });
|
||||||
// streamlink-loop. 200 chunks reichen fuer normale Diagnose.
|
|
||||||
if (stderrBuffer.length > 200) stderrBuffer.shift();
|
|
||||||
// Letzte echte Errorzeile fuer User-Surface. "[ ... ] log lines"
|
|
||||||
// ueberspringen, "error: ..." bevorzugen damit nicht ein triviales
|
|
||||||
// INFO-Statement als User-facing-Fehler landet.
|
|
||||||
const lines = message.split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
for (const line of lines) {
|
|
||||||
const lower = line.toLowerCase();
|
|
||||||
if (lower.startsWith('error:') || lower.includes('error:')) {
|
|
||||||
lastErrorLine = line;
|
|
||||||
} else if (!lastErrorLine && line.length > 0 && !lower.startsWith('[')) {
|
|
||||||
// Fallback: jede non-bracket non-INFO Zeile
|
|
||||||
lastErrorLine = line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appendDebugLog('download-part-stderr', { itemId, message: message.trim() });
|
|
||||||
console.error('Streamlink error:', message);
|
console.error('Streamlink error:', message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -3268,32 +3431,9 @@ function downloadVODPart(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volle stderr+stdout-History im Debug-Log fuer Forensik.
|
const genericError = lastErrorLine || tBackend('streamlinkExitCode', { code: String(code ?? -1) });
|
||||||
// Streamlink-Windows-Builds schreiben Errors gelegentlich auf
|
appendDebugLog('download-part-failed', { itemId, filename, code, error: genericError });
|
||||||
// stdout statt stderr ("No playable streams found on this URL"
|
resolve({ success: false, error: genericError });
|
||||||
// war historisch ein stdout-Error). Wir mergen beide Streams
|
|
||||||
// damit immer SICHTBAR ist was passiert.
|
|
||||||
const fullStderr = stderrBuffer.join('').trim();
|
|
||||||
const fullStdout = stdoutBuffer.join('').trim();
|
|
||||||
// Letzte Error-/Warning-Zeile aus beiden Streams suchen, falls
|
|
||||||
// lastErrorLine noch leer ist (z.B. weil streamlink ohne Output
|
|
||||||
// mit Code 1 exited — was bei pre-flight-Auth-Fails passiert).
|
|
||||||
let userFacingError = lastErrorLine;
|
|
||||||
if (!userFacingError) {
|
|
||||||
const combined = (fullStderr + '\n' + fullStdout).split('\n').map(l => l.trim()).filter(Boolean);
|
|
||||||
userFacingError = combined.filter(l => l.toLowerCase().includes('error:')).pop()
|
|
||||||
|| combined.filter(l => !l.startsWith('[')).pop()
|
|
||||||
|| '';
|
|
||||||
}
|
|
||||||
if (!userFacingError) {
|
|
||||||
userFacingError = tBackend('streamlinkExitCode', { code: String(code ?? -1) });
|
|
||||||
}
|
|
||||||
appendDebugLog('download-part-failed', {
|
|
||||||
itemId, filename, code, error: userFacingError,
|
|
||||||
stderrTail: fullStderr.slice(-2000),
|
|
||||||
stdoutTail: fullStdout.slice(-2000),
|
|
||||||
});
|
|
||||||
resolve({ success: false, error: userFacingError });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
@ -5350,9 +5490,7 @@ async function downloadVOD(
|
|||||||
|
|
||||||
// Check download mode
|
// Check download mode
|
||||||
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
|
if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) {
|
||||||
// Full download — totalDuration als expectedTotalSec damit der Bar
|
// Full download
|
||||||
// determinate-progress aus bytes/duration schaetzen kann (statt in
|
|
||||||
// indeterminate-Animation zu haengen).
|
|
||||||
const filename = ensureUniqueFilename(makeTemplateFilename(
|
const filename = ensureUniqueFilename(makeTemplateFilename(
|
||||||
config.filename_template_vod,
|
config.filename_template_vod,
|
||||||
DEFAULT_FILENAME_TEMPLATE_VOD,
|
DEFAULT_FILENAME_TEMPLATE_VOD,
|
||||||
@ -5360,18 +5498,13 @@ async function downloadVOD(
|
|||||||
0,
|
0,
|
||||||
totalDuration
|
totalDuration
|
||||||
), item.id);
|
), item.id);
|
||||||
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1, totalDuration);
|
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1);
|
||||||
return result.success ? { ...result, outputFiles: [filename] } : result;
|
return result.success ? { ...result, outputFiles: [filename] } : result;
|
||||||
} else {
|
} else {
|
||||||
// Part-based download — wrappt onProgress mit einem Aggregator, der
|
// Part-based download
|
||||||
// pro Part den letzten bekannten %-Wert haelt und einen weighted
|
|
||||||
// overallProgress (0-100%) zurueck an die UI emittiert. Ohne den
|
|
||||||
// Wrapper sah die UI nur "Part X bei Y%" und der Bar sprang bei
|
|
||||||
// Part-Wechsel von 100% zurueck auf 0%.
|
|
||||||
const partDuration = config.part_minutes * 60;
|
const partDuration = config.part_minutes * 60;
|
||||||
const numParts = Math.ceil(totalDuration / partDuration);
|
const numParts = Math.ceil(totalDuration / partDuration);
|
||||||
const downloadedFiles: string[] = [];
|
const downloadedFiles: string[] = [];
|
||||||
const partProgresses: number[] = Array(numParts).fill(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < numParts; i++) {
|
for (let i = 0; i < numParts; i++) {
|
||||||
if (cancelledItemIds.has(item.id)) break;
|
if (cancelledItemIds.has(item.id)) break;
|
||||||
@ -5393,32 +5526,16 @@ async function downloadVOD(
|
|||||||
partFilename,
|
partFilename,
|
||||||
formatDuration(startSec),
|
formatDuration(startSec),
|
||||||
formatDuration(duration),
|
formatDuration(duration),
|
||||||
(progress) => {
|
onProgress,
|
||||||
// Per-part %-Update — clampen, NaN/negativ filtern
|
|
||||||
if (Number.isFinite(progress.progress) && progress.progress > 0 && progress.progress <= 100) {
|
|
||||||
partProgresses[i] = Math.max(partProgresses[i], progress.progress);
|
|
||||||
}
|
|
||||||
// Overall: avg ueber alle Parts (parts haben gleiche
|
|
||||||
// Dauer per Definition, also avg = weighted avg)
|
|
||||||
const overall = partProgresses.reduce((s, p) => s + p, 0) / numParts;
|
|
||||||
onProgress({
|
|
||||||
...progress,
|
|
||||||
progress: overall,
|
|
||||||
currentPart: i + 1,
|
|
||||||
totalParts: numParts
|
|
||||||
});
|
|
||||||
},
|
|
||||||
item.id,
|
item.id,
|
||||||
i + 1,
|
i + 1,
|
||||||
numParts,
|
numParts
|
||||||
duration
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
partProgresses[i] = 100;
|
|
||||||
downloadedFiles.push(partFilename);
|
downloadedFiles.push(partFilename);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5495,39 +5612,15 @@ async function processDownloadMergeGroup(
|
|||||||
const vodWeight = vodDuration / totalDurationSec;
|
const vodWeight = vodDuration / totalDurationSec;
|
||||||
const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec;
|
const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec;
|
||||||
|
|
||||||
// Geschaetzte Bytes pro Part fuer den Fallback-Progress: Twitch-
|
|
||||||
// VOD Bitrate ~5 Mbit/s = ~625 KB/s. Wenn streamlink-stdout keine
|
|
||||||
// %-Lines emittiert (HLS ohne known total), nutzen wir
|
|
||||||
// downloadedBytes / estimatedTotalBytes als rough progress. Cap
|
|
||||||
// bei 95% damit der Bar nie 100% vorm tatsaechlichen Done erreicht.
|
|
||||||
const estimatedTotalBytes = Math.max(1, vodDuration * 625_000);
|
|
||||||
|
|
||||||
// Persistente per-part vodProgress. Quelle 1: streamlink stdout %
|
|
||||||
// (genau). Quelle 2: downloadedBytes / estimated (Fallback wenn
|
|
||||||
// % nicht reportet wird). Ohne den Fallback haengte der Bar auf
|
|
||||||
// dem indeterminate-Pattern (animierte 35%-Box) waehrend tatsaechlich
|
|
||||||
// schon ein paar 100 MB unten waren — User sieht das als "fest mittig
|
|
||||||
// links" weil die Animation schnell ist und nur Snapshots zeigen.
|
|
||||||
let lastVodProgress = 0;
|
|
||||||
const result = await downloadVODPart(
|
const result = await downloadVODPart(
|
||||||
vodItem.url,
|
vodItem.url,
|
||||||
tmpFilename,
|
tmpFilename,
|
||||||
null, // startTime: null = full VOD
|
null, // startTime: null = full VOD
|
||||||
null, // endTime: null = full VOD
|
null, // endTime: null = full VOD
|
||||||
(progress) => {
|
(progress) => {
|
||||||
if (progress.progress > 0 && progress.progress <= 100) {
|
|
||||||
lastVodProgress = progress.progress;
|
|
||||||
} else if (progress.downloadedBytes && progress.downloadedBytes > 0) {
|
|
||||||
// Fallback: bytes-basierte Schaetzung. Streamlink-stdout-%
|
|
||||||
// bleibt bevorzugt; bytes-Fallback wird nur genutzt wenn
|
|
||||||
// noch nie ein echter % rein kam (lastVodProgress noch 0).
|
|
||||||
if (lastVodProgress === 0) {
|
|
||||||
const bytePct = Math.min(95, (progress.downloadedBytes / estimatedTotalBytes) * 100);
|
|
||||||
lastVodProgress = bytePct;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Weighted progress: download phase = 0-70%
|
// Weighted progress: download phase = 0-70%
|
||||||
const overallProgress = (priorWeight + vodWeight * (lastVodProgress / 100)) * 70;
|
const vodProgress = progress.progress > 0 ? progress.progress : 0;
|
||||||
|
const overallProgress = (priorWeight + vodWeight * (vodProgress / 100)) * 70;
|
||||||
onProgress({
|
onProgress({
|
||||||
...progress,
|
...progress,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@ -7311,34 +7404,11 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// APP LIFECYCLE
|
// APP LIFECYCLE
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Long-lived SQLite-Handle (Plan 04b+ Voraussetzung). Wird in app.whenReady
|
|
||||||
// geoeffnet, in shutdownCleanup geschlossen. getAppDb() returnt null wenn
|
|
||||||
// Open fehlgeschlagen ist (Native-Build-Probleme) — Caller mussen das pruefen.
|
|
||||||
let appDb: DbHandle | null = null;
|
|
||||||
export function getAppDb(): DbHandle | null { return appDb; }
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
app.setAppUserModelId('com.twitch.vodmanager');
|
app.setAppUserModelId('com.twitch.vodmanager');
|
||||||
refreshBundledToolPaths(true);
|
refreshBundledToolPaths(true);
|
||||||
startMetadataCacheCleanup();
|
startMetadataCacheCleanup();
|
||||||
startDebugLogFlushTimer();
|
startDebugLogFlushTimer();
|
||||||
|
|
||||||
// SQLite-Open + Shadow-Migration. Long-lived handle in appDb (siehe oben).
|
|
||||||
// Lazy require, damit Native-Build-Fehler den App-Start nicht verhindern.
|
|
||||||
try {
|
|
||||||
const { openDatabase } = require('./main/infra/db');
|
|
||||||
const { migrateJsonToSqlite } = require('./main/domain/migrator');
|
|
||||||
const dbPath = path.join(APPDATA_DIR, 'app.db');
|
|
||||||
appDb = openDatabase(dbPath);
|
|
||||||
const result = migrateJsonToSqlite({ db: appDb, appDataDir: APPDATA_DIR });
|
|
||||||
appendDebugLog('sqlite-migrator', result);
|
|
||||||
} catch (e) {
|
|
||||||
appendDebugLog('sqlite-open-failed', {
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
appDb = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
restartAutoRecordPoller();
|
restartAutoRecordPoller();
|
||||||
restartAutoVodPoller();
|
restartAutoVodPoller();
|
||||||
restartLiveStatusPoller();
|
restartLiveStatusPoller();
|
||||||
@ -7398,13 +7468,6 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
|||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
flushQueueSave();
|
flushQueueSave();
|
||||||
|
|
||||||
// SQLite-Handle schliessen, falls geoeffnet — WAL-Checkpoint passiert beim
|
|
||||||
// close, sodass beim naechsten Start keine .wal/.shm orphans bleiben.
|
|
||||||
if (appDb) {
|
|
||||||
try { appDb.close(); } catch { /* already closed */ }
|
|
||||||
appDb = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush debug log AFTER persisting state so any errors saving config /
|
// Flush debug log AFTER persisting state so any errors saving config /
|
||||||
// queue land in the log before the timer is gone.
|
// queue land in the log before the timer is gone.
|
||||||
stopDebugLogFlushTimer(true);
|
stopDebugLogFlushTimer(true);
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from '../infra/db';
|
|
||||||
import { createArchiveFilesStore, type ArchiveFilesStore } from './archive-files-store';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let db: DbHandle;
|
|
||||||
let store: ArchiveFilesStore;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-'));
|
|
||||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
|
||||||
store = createArchiveFilesStore(db);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
db.close();
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createArchiveFilesStore', () => {
|
|
||||||
test('upsert + get roundtrip', () => {
|
|
||||||
const rec = store.upsert({
|
|
||||||
path: 'C:/vods/foo/2026-05-11.mp4',
|
|
||||||
streamerLogin: 'Foo',
|
|
||||||
sizeBytes: 1024 * 1024 * 100,
|
|
||||||
durationSeconds: 3600,
|
|
||||||
createdAt: 1700000000,
|
|
||||||
verified: true,
|
|
||||||
});
|
|
||||||
expect(rec.path).toBe('C:/vods/foo/2026-05-11.mp4');
|
|
||||||
expect(rec.streamerLogin).toBe('foo');
|
|
||||||
expect(rec.sizeBytes).toBe(1024 * 1024 * 100);
|
|
||||||
expect(rec.verified).toBe(true);
|
|
||||||
|
|
||||||
const fetched = store.get('C:/vods/foo/2026-05-11.mp4');
|
|
||||||
expect(fetched?.streamerLogin).toBe('foo');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('upsert same path updates instead of duplicating', () => {
|
|
||||||
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 100 });
|
|
||||||
store.upsert({ path: '/x', streamerLogin: 'a', sizeBytes: 200 });
|
|
||||||
const list = store.list();
|
|
||||||
expect(list).toHaveLength(1);
|
|
||||||
expect(list[0].sizeBytes).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('list returns all, ordered by created_at DESC NULLS LAST', () => {
|
|
||||||
store.upsert({ path: '/older', streamerLogin: 'a', createdAt: 1000 });
|
|
||||||
store.upsert({ path: '/newer', streamerLogin: 'a', createdAt: 2000 });
|
|
||||||
store.upsert({ path: '/no-date', streamerLogin: 'a' });
|
|
||||||
const list = store.list();
|
|
||||||
expect(list.map(r => r.path)).toEqual(['/newer', '/older', '/no-date']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('list(streamerLogin) filters and normalizes', () => {
|
|
||||||
store.upsert({ path: '/a1', streamerLogin: 'alice' });
|
|
||||||
store.upsert({ path: '/a2', streamerLogin: 'Alice' }); // normalized to alice
|
|
||||||
store.upsert({ path: '/b1', streamerLogin: 'bob' });
|
|
||||||
const aliceFiles = store.list('@Alice');
|
|
||||||
expect(aliceFiles).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setVerified toggles the flag', () => {
|
|
||||||
store.upsert({ path: '/v', verified: false });
|
|
||||||
store.setVerified('/v', true);
|
|
||||||
expect(store.get('/v')?.verified).toBe(true);
|
|
||||||
store.setVerified('/v', false);
|
|
||||||
expect(store.get('/v')?.verified).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete removes the record', () => {
|
|
||||||
store.upsert({ path: '/d', streamerLogin: 'x' });
|
|
||||||
store.delete('/d');
|
|
||||||
expect(store.get('/d')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('summaryByStreamer aggregates counts and total bytes', () => {
|
|
||||||
store.upsert({ path: '/a1', streamerLogin: 'alice', sizeBytes: 100 });
|
|
||||||
store.upsert({ path: '/a2', streamerLogin: 'alice', sizeBytes: 200 });
|
|
||||||
store.upsert({ path: '/b1', streamerLogin: 'bob', sizeBytes: 50 });
|
|
||||||
store.upsert({ path: '/orphan', sizeBytes: 999 }); // no streamer — excluded
|
|
||||||
|
|
||||||
const summary = store.summaryByStreamer();
|
|
||||||
// Sorted by total DESC: alice (300), bob (50)
|
|
||||||
expect(summary).toHaveLength(2);
|
|
||||||
expect(summary[0]).toEqual({ streamerLogin: 'alice', fileCount: 2, totalBytes: 300 });
|
|
||||||
expect(summary[1]).toEqual({ streamerLogin: 'bob', fileCount: 1, totalBytes: 50 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('totalBytes sums across everything', () => {
|
|
||||||
store.upsert({ path: '/1', sizeBytes: 100 });
|
|
||||||
store.upsert({ path: '/2', sizeBytes: 200 });
|
|
||||||
store.upsert({ path: '/3', sizeBytes: 300, streamerLogin: 'a' });
|
|
||||||
store.upsert({ path: '/4' }); // null bytes — coalesced to 0
|
|
||||||
expect(store.totalBytes()).toBe(600);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('get returns null for missing path', () => {
|
|
||||||
expect(store.get('/nope')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('totalBytes on empty table = 0', () => {
|
|
||||||
expect(store.totalBytes()).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
import type { DbHandle } from '../infra/db';
|
|
||||||
import { normalizeLogin } from './config-normalize';
|
|
||||||
|
|
||||||
export interface ArchiveFileRecord {
|
|
||||||
path: string;
|
|
||||||
streamerLogin: string | null;
|
|
||||||
sizeBytes: number | null;
|
|
||||||
durationSeconds: number | null;
|
|
||||||
createdAt: number | null;
|
|
||||||
verified: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchiveFileWriteInput {
|
|
||||||
path: string;
|
|
||||||
streamerLogin?: string;
|
|
||||||
sizeBytes?: number;
|
|
||||||
durationSeconds?: number;
|
|
||||||
createdAt?: number;
|
|
||||||
verified?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchiveStreamerSummary {
|
|
||||||
streamerLogin: string;
|
|
||||||
fileCount: number;
|
|
||||||
totalBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchiveFilesStore {
|
|
||||||
upsert(input: ArchiveFileWriteInput): ArchiveFileRecord;
|
|
||||||
get(path: string): ArchiveFileRecord | null;
|
|
||||||
list(streamerLogin?: string): ArchiveFileRecord[];
|
|
||||||
setVerified(path: string, verified: boolean): void;
|
|
||||||
delete(path: string): void;
|
|
||||||
summaryByStreamer(): ArchiveStreamerSummary[];
|
|
||||||
totalBytes(): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArchiveRow {
|
|
||||||
path: string;
|
|
||||||
streamer_login: string | null;
|
|
||||||
size_bytes: number | null;
|
|
||||||
duration_seconds: number | null;
|
|
||||||
created_at: number | null;
|
|
||||||
verified: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToRecord(row: ArchiveRow): ArchiveFileRecord {
|
|
||||||
return {
|
|
||||||
path: row.path,
|
|
||||||
streamerLogin: row.streamer_login,
|
|
||||||
sizeBytes: row.size_bytes,
|
|
||||||
durationSeconds: row.duration_seconds,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
verified: row.verified === 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createArchiveFilesStore(db: DbHandle): ArchiveFilesStore {
|
|
||||||
return {
|
|
||||||
upsert(input) {
|
|
||||||
const streamerLogin = input.streamerLogin
|
|
||||||
? normalizeLogin(input.streamerLogin)
|
|
||||||
: null;
|
|
||||||
const verified = input.verified ? 1 : 0;
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO archive_files(path, streamer_login, size_bytes, duration_seconds, created_at, verified)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
|
||||||
streamer_login = excluded.streamer_login,
|
|
||||||
size_bytes = excluded.size_bytes,
|
|
||||||
duration_seconds = excluded.duration_seconds,
|
|
||||||
created_at = excluded.created_at,
|
|
||||||
verified = excluded.verified`,
|
|
||||||
[
|
|
||||||
input.path,
|
|
||||||
streamerLogin,
|
|
||||||
input.sizeBytes ?? null,
|
|
||||||
input.durationSeconds ?? null,
|
|
||||||
input.createdAt ?? null,
|
|
||||||
verified,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [input.path]);
|
|
||||||
if (!row) throw new Error(`archive-files-store: upsert lookup failed for ${input.path}`);
|
|
||||||
return rowToRecord(row);
|
|
||||||
},
|
|
||||||
|
|
||||||
get(p) {
|
|
||||||
const row = db.get<ArchiveRow>('SELECT * FROM archive_files WHERE path = ?', [p]);
|
|
||||||
return row ? rowToRecord(row) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
list(streamerLogin) {
|
|
||||||
const rows = streamerLogin
|
|
||||||
? db.all<ArchiveRow>(
|
|
||||||
'SELECT * FROM archive_files WHERE streamer_login = ? ORDER BY created_at DESC NULLS LAST, path',
|
|
||||||
[normalizeLogin(streamerLogin)]
|
|
||||||
)
|
|
||||||
: db.all<ArchiveRow>('SELECT * FROM archive_files ORDER BY created_at DESC NULLS LAST, path');
|
|
||||||
return rows.map(rowToRecord);
|
|
||||||
},
|
|
||||||
|
|
||||||
setVerified(p, verified) {
|
|
||||||
db.run(
|
|
||||||
'UPDATE archive_files SET verified = ? WHERE path = ?',
|
|
||||||
[verified ? 1 : 0, p]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
delete(p) {
|
|
||||||
db.run('DELETE FROM archive_files WHERE path = ?', [p]);
|
|
||||||
},
|
|
||||||
|
|
||||||
summaryByStreamer() {
|
|
||||||
const rows = db.all<{ streamer_login: string | null; cnt: number; total: number | null }>(
|
|
||||||
`SELECT streamer_login, COUNT(*) AS cnt, COALESCE(SUM(size_bytes), 0) AS total
|
|
||||||
FROM archive_files
|
|
||||||
WHERE streamer_login IS NOT NULL
|
|
||||||
GROUP BY streamer_login
|
|
||||||
ORDER BY total DESC`
|
|
||||||
);
|
|
||||||
return rows
|
|
||||||
.filter((r): r is { streamer_login: string; cnt: number; total: number | null } => r.streamer_login !== null)
|
|
||||||
.map(r => ({
|
|
||||||
streamerLogin: r.streamer_login,
|
|
||||||
fileCount: r.cnt,
|
|
||||||
totalBytes: r.total ?? 0,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
totalBytes() {
|
|
||||||
const row = db.get<{ total: number | null }>(
|
|
||||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM archive_files'
|
|
||||||
);
|
|
||||||
return row?.total ?? 0;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from '../infra/db';
|
|
||||||
import { createChunkIndexStore, type ChunkIndexStore } from './chunk-index-store';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let db: DbHandle;
|
|
||||||
let store: ChunkIndexStore;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkstore-'));
|
|
||||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
|
||||||
store = createChunkIndexStore(db);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
db.close();
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createChunkIndexStore', () => {
|
|
||||||
test('record returns ChunkRecord with id > 0', () => {
|
|
||||||
const rec = store.record('item-1', 0, 'sha1-abc', 1024);
|
|
||||||
expect(rec.id).toBeGreaterThan(0);
|
|
||||||
expect(rec.itemId).toBe('item-1');
|
|
||||||
expect(rec.chunkSeq).toBe(0);
|
|
||||||
expect(rec.sha1Hex).toBe('sha1-abc');
|
|
||||||
expect(rec.bytes).toBe(1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('listForItem returns chunks ordered by chunk_seq', () => {
|
|
||||||
store.record('it', 2, 's2', 200);
|
|
||||||
store.record('it', 0, 's0', 100);
|
|
||||||
store.record('it', 1, 's1', 150);
|
|
||||||
const all = store.listForItem('it');
|
|
||||||
expect(all.map(r => r.chunkSeq)).toEqual([0, 1, 2]);
|
|
||||||
expect(all.map(r => r.sha1Hex)).toEqual(['s0', 's1', 's2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('UNIQUE(item_id, chunk_seq): same key updates, no duplicate', () => {
|
|
||||||
store.record('it', 0, 'first', 100);
|
|
||||||
store.record('it', 0, 'second', 200);
|
|
||||||
const list = store.listForItem('it');
|
|
||||||
expect(list).toHaveLength(1);
|
|
||||||
expect(list[0].sha1Hex).toBe('second');
|
|
||||||
expect(list[0].bytes).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('countForItem', () => {
|
|
||||||
expect(store.countForItem('it')).toBe(0);
|
|
||||||
store.record('it', 0, 'a', 1);
|
|
||||||
store.record('it', 1, 'b', 1);
|
|
||||||
expect(store.countForItem('it')).toBe(2);
|
|
||||||
expect(store.countForItem('other')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('lookupBySha1 finds dedupe candidates', () => {
|
|
||||||
store.record('item-A', 0, 'same-sha', 100);
|
|
||||||
store.record('item-B', 5, 'same-sha', 100);
|
|
||||||
store.record('item-C', 0, 'other-sha', 100);
|
|
||||||
|
|
||||||
const hits = store.lookupBySha1('same-sha');
|
|
||||||
expect(hits).toHaveLength(2);
|
|
||||||
expect(hits.map(r => r.itemId).sort()).toEqual(['item-A', 'item-B']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleteForItem removes all chunks for that item and returns count', () => {
|
|
||||||
store.record('it', 0, 'a', 1);
|
|
||||||
store.record('it', 1, 'b', 1);
|
|
||||||
store.record('keep', 0, 'c', 1);
|
|
||||||
|
|
||||||
const removed = store.deleteForItem('it');
|
|
||||||
expect(removed).toBe(2);
|
|
||||||
expect(store.countForItem('it')).toBe(0);
|
|
||||||
expect(store.countForItem('keep')).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleteForItem on missing returns 0, doesnt throw', () => {
|
|
||||||
expect(store.deleteForItem('does-not-exist')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bytes roundtrip', () => {
|
|
||||||
const rec = store.record('it', 0, 'sha', 1234567);
|
|
||||||
expect(rec.bytes).toBe(1234567);
|
|
||||||
const list = store.listForItem('it');
|
|
||||||
expect(list[0].bytes).toBe(1234567);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import type { DbHandle } from '../infra/db';
|
|
||||||
|
|
||||||
export interface ChunkRecord {
|
|
||||||
id: number;
|
|
||||||
itemId: string;
|
|
||||||
chunkSeq: number;
|
|
||||||
sha1Hex: string;
|
|
||||||
bytes: number;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChunkIndexStore {
|
|
||||||
/**
|
|
||||||
* Persistiert einen Chunk-Hash. Bei (itemId, chunkSeq)-Konflikt wird das
|
|
||||||
* bestehende Tupel ersetzt — die zuletzt geschriebene sha1 gewinnt
|
|
||||||
* (sinnvoll, falls dasselbe Segment neu geladen wurde).
|
|
||||||
*/
|
|
||||||
record(itemId: string, chunkSeq: number, sha1Hex: string, bytes: number): ChunkRecord;
|
|
||||||
listForItem(itemId: string): ChunkRecord[];
|
|
||||||
countForItem(itemId: string): number;
|
|
||||||
lookupBySha1(sha1Hex: string): ChunkRecord[];
|
|
||||||
deleteForItem(itemId: string): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChunkRow {
|
|
||||||
id: number;
|
|
||||||
item_id: string;
|
|
||||||
chunk_seq: number;
|
|
||||||
sha1_hex: string;
|
|
||||||
bytes: number;
|
|
||||||
created_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToRecord(row: ChunkRow): ChunkRecord {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
itemId: row.item_id,
|
|
||||||
chunkSeq: row.chunk_seq,
|
|
||||||
sha1Hex: row.sha1_hex,
|
|
||||||
bytes: row.bytes,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createChunkIndexStore(db: DbHandle): ChunkIndexStore {
|
|
||||||
return {
|
|
||||||
record(itemId, chunkSeq, sha1Hex, bytes) {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(item_id, chunk_seq) DO UPDATE SET
|
|
||||||
sha1_hex = excluded.sha1_hex,
|
|
||||||
bytes = excluded.bytes,
|
|
||||||
created_at = excluded.created_at`,
|
|
||||||
[itemId, chunkSeq, sha1Hex, bytes, now]
|
|
||||||
);
|
|
||||||
const row = db.get<ChunkRow>(
|
|
||||||
'SELECT * FROM chunk_index WHERE item_id = ? AND chunk_seq = ?',
|
|
||||||
[itemId, chunkSeq]
|
|
||||||
);
|
|
||||||
if (!row) throw new Error(`chunk-index-store: record lookup failed for ${itemId}/${chunkSeq}`);
|
|
||||||
return rowToRecord(row);
|
|
||||||
},
|
|
||||||
|
|
||||||
listForItem(itemId) {
|
|
||||||
const rows = db.all<ChunkRow>(
|
|
||||||
'SELECT * FROM chunk_index WHERE item_id = ? ORDER BY chunk_seq ASC',
|
|
||||||
[itemId]
|
|
||||||
);
|
|
||||||
return rows.map(rowToRecord);
|
|
||||||
},
|
|
||||||
|
|
||||||
countForItem(itemId) {
|
|
||||||
const row = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId]);
|
|
||||||
return row?.c ?? 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
lookupBySha1(sha1Hex) {
|
|
||||||
const rows = db.all<ChunkRow>(
|
|
||||||
'SELECT * FROM chunk_index WHERE sha1_hex = ? ORDER BY item_id, chunk_seq',
|
|
||||||
[sha1Hex]
|
|
||||||
);
|
|
||||||
return rows.map(rowToRecord);
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteForItem(itemId) {
|
|
||||||
const before = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM chunk_index WHERE item_id = ?', [itemId])?.c ?? 0;
|
|
||||||
db.run('DELETE FROM chunk_index WHERE item_id = ?', [itemId]);
|
|
||||||
return before;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import {
|
|
||||||
normalizeLogin,
|
|
||||||
normalizeAutoRecordPollSeconds,
|
|
||||||
normalizeAutoRecordList,
|
|
||||||
normalizeStreamlinkQuality,
|
|
||||||
normalizeFilenameTemplate,
|
|
||||||
normalizeMetadataCacheMinutes,
|
|
||||||
normalizePerformanceMode,
|
|
||||||
isPlainObject,
|
|
||||||
VALID_STREAMLINK_QUALITIES,
|
|
||||||
} from './config-normalize';
|
|
||||||
|
|
||||||
describe('normalizeLogin', () => {
|
|
||||||
test('trim + lowercase', () => {
|
|
||||||
expect(normalizeLogin(' Foo ')).toBe('foo');
|
|
||||||
});
|
|
||||||
test('strips single leading @', () => {
|
|
||||||
expect(normalizeLogin('@foo')).toBe('foo');
|
|
||||||
});
|
|
||||||
test('strips multiple leading @', () => {
|
|
||||||
expect(normalizeLogin('@@@foo')).toBe('foo');
|
|
||||||
});
|
|
||||||
test('preserves @ in middle of string', () => {
|
|
||||||
expect(normalizeLogin('foo@bar')).toBe('foo@bar');
|
|
||||||
});
|
|
||||||
test('empty stays empty', () => {
|
|
||||||
expect(normalizeLogin('')).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeAutoRecordPollSeconds', () => {
|
|
||||||
test('default 90 for non-numeric (NaN producer)', () => {
|
|
||||||
// Number('x') === NaN, Number(undefined) === NaN → default 90.
|
|
||||||
// Number(null) === 0 (finite) → clamp to 30, see boundary test below.
|
|
||||||
expect(normalizeAutoRecordPollSeconds('x')).toBe(90);
|
|
||||||
expect(normalizeAutoRecordPollSeconds(undefined)).toBe(90);
|
|
||||||
expect(normalizeAutoRecordPollSeconds({})).toBe(90);
|
|
||||||
});
|
|
||||||
test('null becomes 0 then clamps to 30', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(null)).toBe(30);
|
|
||||||
});
|
|
||||||
test('clamps low to 30', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(5)).toBe(30);
|
|
||||||
});
|
|
||||||
test('clamps high to 1800', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(99999)).toBe(1800);
|
|
||||||
});
|
|
||||||
test('passes valid mid-range', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(120)).toBe(120);
|
|
||||||
});
|
|
||||||
test('floors fractional', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(120.9)).toBe(120);
|
|
||||||
});
|
|
||||||
test('boundary 30 stays', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(30)).toBe(30);
|
|
||||||
});
|
|
||||||
test('boundary 1800 stays', () => {
|
|
||||||
expect(normalizeAutoRecordPollSeconds(1800)).toBe(1800);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeAutoRecordList', () => {
|
|
||||||
test('empty for non-array', () => {
|
|
||||||
expect(normalizeAutoRecordList(null)).toEqual([]);
|
|
||||||
expect(normalizeAutoRecordList('x')).toEqual([]);
|
|
||||||
expect(normalizeAutoRecordList(undefined)).toEqual([]);
|
|
||||||
});
|
|
||||||
test('empty array stays empty', () => {
|
|
||||||
expect(normalizeAutoRecordList([])).toEqual([]);
|
|
||||||
});
|
|
||||||
test('lowercases + trims + dedupes', () => {
|
|
||||||
expect(normalizeAutoRecordList(['Foo', 'foo', ' BAR '])).toEqual(['foo', 'bar']);
|
|
||||||
});
|
|
||||||
test('strips leading @ (twitch username paste-form)', () => {
|
|
||||||
expect(normalizeAutoRecordList(['@foo', 'foo', '@@bar'])).toEqual(['foo', 'bar']);
|
|
||||||
});
|
|
||||||
test('drops non-string entries', () => {
|
|
||||||
expect(normalizeAutoRecordList(['foo', 123, null, 'bar'])).toEqual(['foo', 'bar']);
|
|
||||||
});
|
|
||||||
test('drops empty strings after normalize', () => {
|
|
||||||
expect(normalizeAutoRecordList(['', '@', ' ', 'foo'])).toEqual(['foo']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeStreamlinkQuality', () => {
|
|
||||||
test('all valid values pass through', () => {
|
|
||||||
for (const q of VALID_STREAMLINK_QUALITIES) {
|
|
||||||
expect(normalizeStreamlinkQuality(q)).toBe(q);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
test('invalid string falls back to best', () => {
|
|
||||||
expect(normalizeStreamlinkQuality('foo')).toBe('best');
|
|
||||||
});
|
|
||||||
test('null/undefined/number fall back to best', () => {
|
|
||||||
expect(normalizeStreamlinkQuality(null)).toBe('best');
|
|
||||||
expect(normalizeStreamlinkQuality(undefined)).toBe('best');
|
|
||||||
expect(normalizeStreamlinkQuality(42)).toBe('best');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeFilenameTemplate', () => {
|
|
||||||
test('valid string used as-is', () => {
|
|
||||||
expect(normalizeFilenameTemplate('{title}.mp4', 'FB')).toBe('{title}.mp4');
|
|
||||||
});
|
|
||||||
test('trims whitespace', () => {
|
|
||||||
expect(normalizeFilenameTemplate(' hi ', 'FB')).toBe('hi');
|
|
||||||
});
|
|
||||||
test('empty string falls back', () => {
|
|
||||||
expect(normalizeFilenameTemplate('', 'FB')).toBe('FB');
|
|
||||||
});
|
|
||||||
test('whitespace-only falls back', () => {
|
|
||||||
expect(normalizeFilenameTemplate(' ', 'FB')).toBe('FB');
|
|
||||||
});
|
|
||||||
test('undefined falls back', () => {
|
|
||||||
expect(normalizeFilenameTemplate(undefined, 'FB')).toBe('FB');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeMetadataCacheMinutes', () => {
|
|
||||||
test('default 10 for NaN-producer', () => {
|
|
||||||
expect(normalizeMetadataCacheMinutes('x')).toBe(10);
|
|
||||||
expect(normalizeMetadataCacheMinutes(undefined)).toBe(10);
|
|
||||||
expect(normalizeMetadataCacheMinutes({})).toBe(10);
|
|
||||||
});
|
|
||||||
test('null becomes 0 then clamps to 1', () => {
|
|
||||||
expect(normalizeMetadataCacheMinutes(null)).toBe(1);
|
|
||||||
});
|
|
||||||
test('clamps low to 1', () => {
|
|
||||||
expect(normalizeMetadataCacheMinutes(0)).toBe(1);
|
|
||||||
expect(normalizeMetadataCacheMinutes(-5)).toBe(1);
|
|
||||||
});
|
|
||||||
test('clamps high to 120', () => {
|
|
||||||
expect(normalizeMetadataCacheMinutes(999)).toBe(120);
|
|
||||||
});
|
|
||||||
test('passes valid mid-range', () => {
|
|
||||||
expect(normalizeMetadataCacheMinutes(15)).toBe(15);
|
|
||||||
});
|
|
||||||
test('floors fractional', () => {
|
|
||||||
expect(normalizeMetadataCacheMinutes(15.9)).toBe(15);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizePerformanceMode', () => {
|
|
||||||
test('stability passes', () => {
|
|
||||||
expect(normalizePerformanceMode('stability')).toBe('stability');
|
|
||||||
});
|
|
||||||
test('balanced passes', () => {
|
|
||||||
expect(normalizePerformanceMode('balanced')).toBe('balanced');
|
|
||||||
});
|
|
||||||
test('speed passes', () => {
|
|
||||||
expect(normalizePerformanceMode('speed')).toBe('speed');
|
|
||||||
});
|
|
||||||
test('invalid string falls back to balanced', () => {
|
|
||||||
expect(normalizePerformanceMode('foo')).toBe('balanced');
|
|
||||||
});
|
|
||||||
test('null/undefined fall back to balanced', () => {
|
|
||||||
expect(normalizePerformanceMode(null)).toBe('balanced');
|
|
||||||
expect(normalizePerformanceMode(undefined)).toBe('balanced');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isPlainObject', () => {
|
|
||||||
test('true for object literal', () => {
|
|
||||||
expect(isPlainObject({})).toBe(true);
|
|
||||||
expect(isPlainObject({ a: 1 })).toBe(true);
|
|
||||||
});
|
|
||||||
test('false for array', () => {
|
|
||||||
expect(isPlainObject([])).toBe(false);
|
|
||||||
expect(isPlainObject([1, 2, 3])).toBe(false);
|
|
||||||
});
|
|
||||||
test('false for null', () => {
|
|
||||||
expect(isPlainObject(null)).toBe(false);
|
|
||||||
});
|
|
||||||
test('false for undefined', () => {
|
|
||||||
expect(isPlainObject(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
test('false for primitives', () => {
|
|
||||||
expect(isPlainObject('x')).toBe(false);
|
|
||||||
expect(isPlainObject(42)).toBe(false);
|
|
||||||
expect(isPlainObject(true)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
// Pure normalizer-Helpers fuer Config-Felder. Keine Side-Effects, keine Globals.
|
|
||||||
|
|
||||||
export type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
|
||||||
|
|
||||||
export const VALID_STREAMLINK_QUALITIES = ['best', 'source', '1080p60', '720p60', '720p', '480p', 'audio_only'] as const;
|
|
||||||
|
|
||||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
|
||||||
const AUTO_RECORD_POLL_MAX_SECONDS = 1800;
|
|
||||||
export const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
|
||||||
export const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
|
||||||
|
|
||||||
/** trim + strip leading @ + lowercase. Verbatim aus altem main.ts. */
|
|
||||||
export function normalizeLogin(input: string): string {
|
|
||||||
return input.trim().replace(/^@+/, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAutoRecordPollSeconds(value: unknown): number {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isFinite(parsed)) return 90;
|
|
||||||
return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAutoRecordList(value: unknown): string[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const v of value) {
|
|
||||||
if (typeof v !== 'string') continue;
|
|
||||||
const cleaned = normalizeLogin(v);
|
|
||||||
if (cleaned && !seen.has(cleaned)) {
|
|
||||||
seen.add(cleaned);
|
|
||||||
out.push(cleaned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStreamlinkQuality(value: unknown): string {
|
|
||||||
if (typeof value === 'string' && (VALID_STREAMLINK_QUALITIES as readonly string[]).includes(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return 'best';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
|
|
||||||
const value = (template || '').trim();
|
|
||||||
return value || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMetadataCacheMinutes(value: unknown): number {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isFinite(parsed)) {
|
|
||||||
return DEFAULT_METADATA_CACHE_MINUTES;
|
|
||||||
}
|
|
||||||
return Math.max(1, Math.min(120, Math.floor(parsed)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePerformanceMode(mode: unknown): PerformanceMode {
|
|
||||||
if (mode === 'stability' || mode === 'balanced' || mode === 'speed') {
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
return DEFAULT_PERFORMANCE_MODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import { tBackend, BACKEND_MESSAGES, type BackendMessageKey } from './i18n-backend';
|
|
||||||
|
|
||||||
describe('tBackend', () => {
|
|
||||||
test('returns DE message for known key (default language)', () => {
|
|
||||||
expect(tBackend('invalidVodUrl', undefined, 'de')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns EN message when language=en', () => {
|
|
||||||
expect(tBackend('invalidVodUrl', undefined, 'en')).toBe(BACKEND_MESSAGES.en.invalidVodUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown language falls back to de', () => {
|
|
||||||
expect(tBackend('invalidVodUrl', undefined, 'fr')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
|
||||||
expect(tBackend('invalidVodUrl', undefined, '')).toBe(BACKEND_MESSAGES.de.invalidVodUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('substitutes single {param}', () => {
|
|
||||||
const result = tBackend('streamlinkExitCode', { code: 42 }, 'en');
|
|
||||||
expect(result).toBe('Streamlink exit code 42');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('substitutes multiple {params}', () => {
|
|
||||||
const result = tBackend('integrityDurationMismatch', { actual: 100, expected: 120 }, 'de');
|
|
||||||
expect(result).toContain('100');
|
|
||||||
expect(result).toContain('120');
|
|
||||||
expect(result).not.toContain('{actual}');
|
|
||||||
expect(result).not.toContain('{expected}');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('numeric params stringify', () => {
|
|
||||||
const result = tBackend('fileTooSmall', { bytes: 256 }, 'en');
|
|
||||||
expect(result).toBe('File too small (256 bytes)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every DE key has an EN counterpart', () => {
|
|
||||||
const deKeys = Object.keys(BACKEND_MESSAGES.de) as BackendMessageKey[];
|
|
||||||
const enKeys = Object.keys(BACKEND_MESSAGES.en);
|
|
||||||
for (const k of deKeys) {
|
|
||||||
expect(enKeys).toContain(k);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no template literal left after substitution for typical params', () => {
|
|
||||||
// attemptFailed has {attempt}, {max}, {errorClass}, {error}
|
|
||||||
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
|
|
||||||
expect(result).toBe('Attempt 1/3 failed (network): ETIMEDOUT');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
|
|
||||||
// als Parameter uebergeben statt aus globalem config geholt.
|
|
||||||
|
|
||||||
export const BACKEND_MESSAGES = {
|
|
||||||
de: {
|
|
||||||
invalidVodUrl: 'Ungueltige VOD-URL',
|
|
||||||
invalidClipUrl: 'Ungueltige Clip-URL',
|
|
||||||
clipNotFound: 'Clip nicht gefunden',
|
|
||||||
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
|
|
||||||
streamlinkMissing: 'Streamlink fehlt.',
|
|
||||||
streamlinkNotFound: 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).',
|
|
||||||
streamlinkExitCode: 'Streamlink Fehlercode {code}',
|
|
||||||
ffmpegMissing: 'FFmpeg fehlt.',
|
|
||||||
ffmpegMergeFailed: 'FFmpeg Merge fehlgeschlagen.',
|
|
||||||
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
|
|
||||||
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
|
|
||||||
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
|
|
||||||
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
|
|
||||||
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
|
|
||||||
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
|
|
||||||
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
|
|
||||||
downloadCancelled: 'Download wurde abgebrochen.',
|
|
||||||
downloadPaused: 'Download wurde pausiert.',
|
|
||||||
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
|
|
||||||
unknownDownloadError: 'Unbekannter Fehler beim Download',
|
|
||||||
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
|
|
||||||
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
|
|
||||||
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
|
|
||||||
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
|
|
||||||
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
|
|
||||||
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
|
|
||||||
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
|
|
||||||
statusCheckingTools: 'Prufe Download-Tools...',
|
|
||||||
statusDownloadStarted: 'Download gestartet',
|
|
||||||
statusBytesDownloaded: '{bytes} heruntergeladen',
|
|
||||||
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
|
|
||||||
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
|
|
||||||
preflightNoInternet: 'Keine Internetverbindung erkannt.',
|
|
||||||
preflightStreamlinkMissing: 'Streamlink fehlt oder ist nicht startbar.',
|
|
||||||
preflightFfmpegMissing: 'FFmpeg fehlt oder ist nicht startbar.',
|
|
||||||
preflightFfprobeMissing: 'FFprobe fehlt oder ist nicht startbar.',
|
|
||||||
preflightDownloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar.'
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
invalidVodUrl: 'Invalid VOD URL',
|
|
||||||
invalidClipUrl: 'Invalid clip URL',
|
|
||||||
clipNotFound: 'Clip not found',
|
|
||||||
streamlinkAutoInstallFailed: 'Streamlink is missing and could not be auto-installed. See debug.log.',
|
|
||||||
streamlinkMissing: 'Streamlink is missing.',
|
|
||||||
streamlinkNotFound: 'Streamlink not found. Install streamlink or Python+streamlink (py -3 -m pip install streamlink).',
|
|
||||||
streamlinkExitCode: 'Streamlink exit code {code}',
|
|
||||||
ffmpegMissing: 'FFmpeg is missing.',
|
|
||||||
ffmpegMergeFailed: 'FFmpeg merge failed.',
|
|
||||||
ffmpegSplitFailed: 'FFmpeg split failed.',
|
|
||||||
fileTooSmall: 'File too small ({bytes} bytes)',
|
|
||||||
clipFileTooSmall: 'Clip file too small ({bytes} bytes) - Twitch may not have served the stream.',
|
|
||||||
integrityNoVideo: 'Integrity check failed: no video stream found.',
|
|
||||||
integrityTooShort: 'Integrity check failed: duration too short ({duration}s).',
|
|
||||||
integrityDurationMismatch: 'Integrity check failed: {actual}s instead of expected ~{expected}s.',
|
|
||||||
integrityFailedGeneric: 'Integrity check failed.',
|
|
||||||
downloadCancelled: 'Download was cancelled.',
|
|
||||||
downloadPaused: 'Download was paused.',
|
|
||||||
downloadFailedExitCode: 'Download failed (exit code {code})',
|
|
||||||
unknownDownloadError: 'Unknown download error',
|
|
||||||
notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.',
|
|
||||||
notAllPartsDownloaded: 'Not all parts could be downloaded.',
|
|
||||||
mergeGroupFileMissing: 'Downloaded file {index} is missing.',
|
|
||||||
diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.',
|
|
||||||
diskSpaceShortGeneric: 'Not enough disk space.',
|
|
||||||
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
|
|
||||||
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
|
|
||||||
statusCheckingTools: 'Checking download tools...',
|
|
||||||
statusDownloadStarted: 'Download started',
|
|
||||||
statusBytesDownloaded: '{bytes} downloaded',
|
|
||||||
statusFetchingChatReplay: 'Fetching chat replay...',
|
|
||||||
statusChatMessagesFetched: 'Chat messages fetched: {count}',
|
|
||||||
preflightNoInternet: 'No internet connection detected.',
|
|
||||||
preflightStreamlinkMissing: 'Streamlink is missing or not runnable.',
|
|
||||||
preflightFfmpegMissing: 'FFmpeg is missing or not runnable.',
|
|
||||||
preflightFfprobeMissing: 'FFprobe is missing or not runnable.',
|
|
||||||
preflightDownloadPathNotWritable: 'Download folder is not writable.'
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type BackendMessageKey = keyof typeof BACKEND_MESSAGES.de;
|
|
||||||
export type BackendLanguage = 'de' | 'en';
|
|
||||||
|
|
||||||
export function tBackend(
|
|
||||||
key: BackendMessageKey,
|
|
||||||
params: Record<string, string | number> | undefined,
|
|
||||||
language: BackendLanguage | string
|
|
||||||
): string {
|
|
||||||
const lang: BackendLanguage = (language === 'en') ? 'en' : 'de';
|
|
||||||
let template: string = BACKEND_MESSAGES[lang][key];
|
|
||||||
if (params) {
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
template = template.replace(`{${k}}`, String(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import { parseFfprobeJson, assessIntegrity, verifyIntegrityFromJson } from './integrity-check';
|
|
||||||
|
|
||||||
const FIXTURE_GOOD = JSON.stringify({
|
|
||||||
streams: [
|
|
||||||
{ index: 0, codec_type: 'video', codec_name: 'h264', width: 1920, height: 1080, duration: '600.5' },
|
|
||||||
{ index: 1, codec_type: 'audio', codec_name: 'aac', duration: '600.5' },
|
|
||||||
],
|
|
||||||
format: { duration: '600.5', size: '50000000' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const FIXTURE_NO_VIDEO = JSON.stringify({
|
|
||||||
streams: [
|
|
||||||
{ index: 0, codec_type: 'audio', codec_name: 'aac', duration: '10' },
|
|
||||||
],
|
|
||||||
format: { duration: '10', size: '500000' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const FIXTURE_EMPTY = JSON.stringify({
|
|
||||||
streams: [],
|
|
||||||
format: { duration: '0.04', size: '1234' },
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseFfprobeJson', () => {
|
|
||||||
test('parses streams + format', () => {
|
|
||||||
const r = parseFfprobeJson(FIXTURE_GOOD);
|
|
||||||
expect(r.streams).toHaveLength(2);
|
|
||||||
expect(r.streams[0].codecType).toBe('video');
|
|
||||||
expect(r.streams[0].codecName).toBe('h264');
|
|
||||||
expect(r.streams[0].width).toBe(1920);
|
|
||||||
expect(r.durationSeconds).toBe(600.5);
|
|
||||||
expect(r.sizeBytes).toBe(50000000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles missing format gracefully', () => {
|
|
||||||
const r = parseFfprobeJson(JSON.stringify({ streams: [] }));
|
|
||||||
expect(r.durationSeconds).toBe(0);
|
|
||||||
expect(r.sizeBytes).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on malformed JSON', () => {
|
|
||||||
expect(() => parseFfprobeJson('{not-valid')).toThrow(/parse failed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('coerces numeric strings to numbers', () => {
|
|
||||||
const r = parseFfprobeJson(JSON.stringify({
|
|
||||||
streams: [{ codec_type: 'video', duration: '12.34' }],
|
|
||||||
format: { duration: '12.34', size: '987654' },
|
|
||||||
}));
|
|
||||||
expect(r.durationSeconds).toBe(12.34);
|
|
||||||
expect(r.streams[0].durationSeconds).toBe(12.34);
|
|
||||||
expect(r.sizeBytes).toBe(987654);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assessIntegrity', () => {
|
|
||||||
test('valid file: ok=true, no reasons', () => {
|
|
||||||
const probe = parseFfprobeJson(FIXTURE_GOOD);
|
|
||||||
const v = assessIntegrity(probe);
|
|
||||||
expect(v.ok).toBe(true);
|
|
||||||
expect(v.reasons).toEqual([]);
|
|
||||||
expect(v.hasVideo).toBe(true);
|
|
||||||
expect(v.hasAudio).toBe(true);
|
|
||||||
expect(v.durationSeconds).toBe(600.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no-video stream rejected', () => {
|
|
||||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_NO_VIDEO));
|
|
||||||
expect(v.ok).toBe(false);
|
|
||||||
expect(v.reasons).toContain('no-video-stream');
|
|
||||||
expect(v.hasVideo).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zero-duration rejected as too-short', () => {
|
|
||||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_EMPTY));
|
|
||||||
expect(v.ok).toBe(false);
|
|
||||||
expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('expected-duration mismatch outside tolerance flagged', () => {
|
|
||||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
|
||||||
expectedDurationSeconds: 700,
|
|
||||||
durationToleranceSeconds: 5,
|
|
||||||
});
|
|
||||||
expect(v.ok).toBe(false);
|
|
||||||
expect(v.reasons.some(r => r.startsWith('duration-mismatch'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('expected-duration within tolerance accepted', () => {
|
|
||||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
|
||||||
expectedDurationSeconds: 598,
|
|
||||||
durationToleranceSeconds: 5,
|
|
||||||
});
|
|
||||||
expect(v.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('custom minDurationSeconds threshold', () => {
|
|
||||||
const v = assessIntegrity(parseFfprobeJson(FIXTURE_GOOD), {
|
|
||||||
minDurationSeconds: 700,
|
|
||||||
});
|
|
||||||
expect(v.ok).toBe(false);
|
|
||||||
expect(v.reasons.some(r => r.startsWith('duration-too-short'))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyIntegrityFromJson', () => {
|
|
||||||
test('one-shot parse + assess', () => {
|
|
||||||
const v = verifyIntegrityFromJson(FIXTURE_GOOD);
|
|
||||||
expect(v.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('propagates parse errors', () => {
|
|
||||||
expect(() => verifyIntegrityFromJson('{broken')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
// Wrappt ffprobe -show_streams -show_format -of json + entscheidet, ob eine
|
|
||||||
// fertige Recording-/Download-Datei strukturell valide ist.
|
|
||||||
// Pure-Parser-Layer ist getrennt testbar; das eigentliche Spawn ist im Caller.
|
|
||||||
|
|
||||||
export interface ProbeStream {
|
|
||||||
index: number;
|
|
||||||
codecType: string; // 'video' | 'audio' | 'subtitle' | ...
|
|
||||||
codecName?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
durationSeconds?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProbeResult {
|
|
||||||
streams: ProbeStream[];
|
|
||||||
durationSeconds: number;
|
|
||||||
sizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrityVerdict {
|
|
||||||
ok: boolean;
|
|
||||||
reasons: string[];
|
|
||||||
durationSeconds: number;
|
|
||||||
hasVideo: boolean;
|
|
||||||
hasAudio: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrityCheckOptions {
|
|
||||||
expectedDurationSeconds?: number;
|
|
||||||
durationToleranceSeconds?: number; // default 5
|
|
||||||
minDurationSeconds?: number; // default 1
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FfprobeJsonStream {
|
|
||||||
index?: number;
|
|
||||||
codec_type?: string;
|
|
||||||
codec_name?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
duration?: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FfprobeJson {
|
|
||||||
streams?: FfprobeJsonStream[];
|
|
||||||
format?: {
|
|
||||||
duration?: string | number;
|
|
||||||
size?: string | number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNumber(v: unknown, fallback = 0): number {
|
|
||||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
const n = Number(v);
|
|
||||||
if (Number.isFinite(n)) return n;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseFfprobeJson(rawJson: string): ProbeResult {
|
|
||||||
let parsed: FfprobeJson;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(rawJson) as FfprobeJson;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`integrity-check: ffprobe JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streams: ProbeStream[] = (parsed.streams ?? []).map((s, idx) => ({
|
|
||||||
index: typeof s.index === 'number' ? s.index : idx,
|
|
||||||
codecType: typeof s.codec_type === 'string' ? s.codec_type : 'unknown',
|
|
||||||
codecName: typeof s.codec_name === 'string' ? s.codec_name : undefined,
|
|
||||||
width: typeof s.width === 'number' ? s.width : undefined,
|
|
||||||
height: typeof s.height === 'number' ? s.height : undefined,
|
|
||||||
durationSeconds: s.duration !== undefined ? toNumber(s.duration) : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const formatDuration = toNumber(parsed.format?.duration, 0);
|
|
||||||
const formatSize = toNumber(parsed.format?.size, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
streams,
|
|
||||||
durationSeconds: formatDuration,
|
|
||||||
sizeBytes: formatSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assessIntegrity(probe: ProbeResult, opts: IntegrityCheckOptions = {}): IntegrityVerdict {
|
|
||||||
const minDuration = opts.minDurationSeconds ?? 1;
|
|
||||||
const tolerance = opts.durationToleranceSeconds ?? 5;
|
|
||||||
|
|
||||||
const hasVideo = probe.streams.some(s => s.codecType === 'video');
|
|
||||||
const hasAudio = probe.streams.some(s => s.codecType === 'audio');
|
|
||||||
|
|
||||||
const reasons: string[] = [];
|
|
||||||
|
|
||||||
if (!hasVideo) {
|
|
||||||
reasons.push('no-video-stream');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (probe.durationSeconds < minDuration) {
|
|
||||||
reasons.push(`duration-too-short:${probe.durationSeconds.toFixed(2)}s<${minDuration}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof opts.expectedDurationSeconds === 'number' && opts.expectedDurationSeconds > 0) {
|
|
||||||
const diff = Math.abs(probe.durationSeconds - opts.expectedDurationSeconds);
|
|
||||||
if (diff > tolerance) {
|
|
||||||
reasons.push(
|
|
||||||
`duration-mismatch:actual=${probe.durationSeconds.toFixed(2)}s,` +
|
|
||||||
`expected=${opts.expectedDurationSeconds.toFixed(2)}s,` +
|
|
||||||
`tolerance=${tolerance}s`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: reasons.length === 0,
|
|
||||||
reasons,
|
|
||||||
durationSeconds: probe.durationSeconds,
|
|
||||||
hasVideo,
|
|
||||||
hasAudio,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: vollstaendige integrity-check Pipeline. Caller liefert die
|
|
||||||
* ffprobe-JSON-Ausgabe als String (so bleibt das Modul Spawn-frei + leicht
|
|
||||||
* testbar; die main.ts hat schon ffprobe-Spawn-Helpers).
|
|
||||||
*/
|
|
||||||
export function verifyIntegrityFromJson(rawJson: string, opts?: IntegrityCheckOptions): IntegrityVerdict {
|
|
||||||
const probe = parseFfprobeJson(rawJson);
|
|
||||||
return assessIntegrity(probe, opts);
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from '../infra/db';
|
|
||||||
import { migrateJsonToSqlite } from './migrator';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let appDataDir: string;
|
|
||||||
let db: DbHandle;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrator-'));
|
|
||||||
appDataDir = path.join(tmpDir, 'appdata');
|
|
||||||
fs.mkdirSync(appDataDir, { recursive: true });
|
|
||||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
db.close();
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
function writeJson(name: string, payload: unknown): string {
|
|
||||||
const target = path.join(appDataDir, name);
|
|
||||||
fs.writeFileSync(target, JSON.stringify(payload, null, 2), 'utf-8');
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('migrateJsonToSqlite', () => {
|
|
||||||
test('no JSON files: writes migrations_applied marker', () => {
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.configMigrated).toBe(false);
|
|
||||||
expect(result.queueMigrated).toBe(false);
|
|
||||||
expect(result.downloadedVodsCount).toBe(0);
|
|
||||||
expect(result.streamersCount).toBe(0);
|
|
||||||
|
|
||||||
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']);
|
|
||||||
expect(marker?.name).toBe('v4-to-v5-jsons');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates config.json keys into config_kv', () => {
|
|
||||||
writeJson('config.json', {
|
|
||||||
language: 'de',
|
|
||||||
performance_mode: 'speed',
|
|
||||||
metadata_cache_minutes: 30,
|
|
||||||
downloaded_vod_ids: ['1', '2', '3'],
|
|
||||||
auto_record_streamers: ['foo', 'bar'],
|
|
||||||
});
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.configMigrated).toBe(true);
|
|
||||||
|
|
||||||
const lang = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
|
|
||||||
expect(JSON.parse(lang!.value)).toBe('de');
|
|
||||||
|
|
||||||
const perf = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['performance_mode']);
|
|
||||||
expect(JSON.parse(perf!.value)).toBe('speed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates downloaded_vod_ids', () => {
|
|
||||||
writeJson('config.json', { downloaded_vod_ids: ['100', '200', '300'] });
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.downloadedVodsCount).toBe(3);
|
|
||||||
const rows = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
|
||||||
expect(rows.map(r => r.vod_id)).toEqual(['100', '200', '300']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates streamers from both auto-record and auto-vod-download lists', () => {
|
|
||||||
writeJson('config.json', {
|
|
||||||
auto_record_streamers: ['Alice', '@bob'],
|
|
||||||
auto_vod_download_streamers: ['bob', 'carol'],
|
|
||||||
});
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.streamersCount).toBeGreaterThanOrEqual(3);
|
|
||||||
|
|
||||||
const alice = db.get<{ login: string; auto_record: number }>('SELECT login, auto_record FROM streamers WHERE login = ?', ['alice']);
|
|
||||||
expect(alice?.auto_record).toBe(1);
|
|
||||||
|
|
||||||
const bob = db.get<{ login: string; auto_record: number; auto_vod_download: number }>('SELECT login, auto_record, auto_vod_download FROM streamers WHERE login = ?', ['bob']);
|
|
||||||
expect(bob?.auto_record).toBe(1);
|
|
||||||
expect(bob?.auto_vod_download).toBe(1);
|
|
||||||
|
|
||||||
const carol = db.get<{ login: string; auto_vod_download: number }>('SELECT login, auto_vod_download FROM streamers WHERE login = ?', ['carol']);
|
|
||||||
expect(carol?.auto_vod_download).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates download_queue.json items', () => {
|
|
||||||
writeJson('download_queue.json', [
|
|
||||||
{ id: 'q1', status: 'pending', streamer: 'foo', vod_id: 'v1', created_at: 1000, updated_at: 1000 },
|
|
||||||
{ id: 'q2', status: 'completed', streamer: 'bar', vod_id: 'v2', created_at: 2000, updated_at: 3000, completed_at: 3000 },
|
|
||||||
]);
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.queueMigrated).toBe(true);
|
|
||||||
|
|
||||||
const all = db.all<{ id: string; status: string }>('SELECT id, status FROM queue_items ORDER BY id');
|
|
||||||
expect(all).toHaveLength(2);
|
|
||||||
expect(all[0].status).toBe('pending');
|
|
||||||
expect(all[1].status).toBe('completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('idempotent second run', () => {
|
|
||||||
writeJson('config.json', { downloaded_vod_ids: ['1', '2'] });
|
|
||||||
migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
const result2 = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result2.alreadyApplied).toBe(true);
|
|
||||||
const count = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
|
||||||
expect(count?.c).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes .v4-backup of source JSONs', () => {
|
|
||||||
const configPath = writeJson('config.json', { language: 'en' });
|
|
||||||
migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(fs.existsSync(configPath + '.v4-backup')).toBe(true);
|
|
||||||
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('malformed JSON is logged + skipped', () => {
|
|
||||||
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.configMigrated).toBe(false);
|
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
|
||||||
expect(result.errors[0].source).toBe('config.json');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import type { DbHandle } from '../infra/db';
|
|
||||||
import { normalizeLogin } from './config-normalize';
|
|
||||||
|
|
||||||
export interface MigratorOptions {
|
|
||||||
db: DbHandle;
|
|
||||||
appDataDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationError {
|
|
||||||
source: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationResult {
|
|
||||||
alreadyApplied: boolean;
|
|
||||||
configMigrated: boolean;
|
|
||||||
queueMigrated: boolean;
|
|
||||||
downloadedVodsCount: number;
|
|
||||||
streamersCount: number;
|
|
||||||
errors: MigrationError[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIGRATION_NAME = 'v4-to-v5-jsons';
|
|
||||||
|
|
||||||
const CONFIG_KV_KEYS = [
|
|
||||||
'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality',
|
|
||||||
'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat',
|
|
||||||
'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end',
|
|
||||||
'discord_notify_vod_complete', 'discord_notify_vod_auto_queued',
|
|
||||||
'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target',
|
|
||||||
'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes',
|
|
||||||
'auto_vod_max_age_hours', 'auto_resume_live_recording',
|
|
||||||
'auto_merge_resumed_parts', 'delete_parts_after_merge',
|
|
||||||
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
|
|
||||||
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
|
|
||||||
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
|
|
||||||
'notify_on_each_completion',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function backupOnce(srcPath: string): void {
|
|
||||||
const backupPath = srcPath + '.v4-backup';
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
|
||||||
fs.copyFileSync(srcPath, backupPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
||||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
|
|
||||||
let vodCount = 0;
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const key of CONFIG_KV_KEYS) {
|
|
||||||
if (key in config) {
|
|
||||||
db.run(
|
|
||||||
"INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))",
|
|
||||||
[key, JSON.stringify(config[key])]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
|
|
||||||
for (const id of vodIds) {
|
|
||||||
if (typeof id !== 'string' || !id) continue;
|
|
||||||
db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]);
|
|
||||||
vodCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : [];
|
|
||||||
for (const s of autoRec) {
|
|
||||||
if (typeof s !== 'string' || !s) continue;
|
|
||||||
const login = normalizeLogin(s);
|
|
||||||
if (!login) continue;
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1',
|
|
||||||
[login]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
|
|
||||||
for (const s of autoDl) {
|
|
||||||
if (typeof s !== 'string' || !s) continue;
|
|
||||||
const login = normalizeLogin(s);
|
|
||||||
if (!login) continue;
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1',
|
|
||||||
[login]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
backupOnce(configPath);
|
|
||||||
return { ok: true, vodCount };
|
|
||||||
} catch (e) {
|
|
||||||
errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) });
|
|
||||||
return { ok: false, vodCount: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(queuePath, 'utf-8');
|
|
||||||
const queue = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(queue)) return false;
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const rawItem of queue) {
|
|
||||||
if (!rawItem || typeof rawItem !== 'object') continue;
|
|
||||||
const item = rawItem as Record<string, unknown>;
|
|
||||||
const id = typeof item.id === 'string' ? item.id : null;
|
|
||||||
if (!id) continue;
|
|
||||||
db.run(
|
|
||||||
`INSERT OR REPLACE INTO queue_items
|
|
||||||
(id, streamer_login, vod_id, clip_id, title, output_path, status,
|
|
||||||
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
id,
|
|
||||||
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
|
|
||||||
typeof item.vod_id === 'string' ? item.vod_id : null,
|
|
||||||
typeof item.clip_id === 'string' ? item.clip_id : null,
|
|
||||||
typeof item.title === 'string' ? item.title : null,
|
|
||||||
typeof item.output_path === 'string' ? item.output_path : null,
|
|
||||||
typeof item.status === 'string' ? item.status : 'pending',
|
|
||||||
typeof item.progress_pct === 'number' ? item.progress_pct : null,
|
|
||||||
typeof item.error_message === 'string' ? item.error_message : null,
|
|
||||||
typeof item.created_at === 'number' ? item.created_at : now,
|
|
||||||
typeof item.updated_at === 'number' ? item.updated_at : now,
|
|
||||||
typeof item.completed_at === 'number' ? item.completed_at : null,
|
|
||||||
JSON.stringify(item),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
backupOnce(queuePath);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
|
|
||||||
const { db, appDataDir } = opts;
|
|
||||||
const errors: MigrationError[] = [];
|
|
||||||
|
|
||||||
const existing = db.get<{ name: string }>(
|
|
||||||
'SELECT name FROM migrations_applied WHERE name = ?',
|
|
||||||
[MIGRATION_NAME]
|
|
||||||
);
|
|
||||||
if (existing) {
|
|
||||||
return {
|
|
||||||
alreadyApplied: true,
|
|
||||||
configMigrated: false,
|
|
||||||
queueMigrated: false,
|
|
||||||
downloadedVodsCount: 0,
|
|
||||||
streamersCount: 0,
|
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let configMigrated = false;
|
|
||||||
let queueMigrated = false;
|
|
||||||
let downloadedVodsCount = 0;
|
|
||||||
|
|
||||||
const configPath = path.join(appDataDir, 'config.json');
|
|
||||||
if (fs.existsSync(configPath)) {
|
|
||||||
const r = migrateConfig(db, configPath, errors);
|
|
||||||
configMigrated = r.ok;
|
|
||||||
downloadedVodsCount = r.vodCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queuePath = path.join(appDataDir, 'download_queue.json');
|
|
||||||
if (fs.existsSync(queuePath)) {
|
|
||||||
queueMigrated = migrateQueue(db, queuePath, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0;
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
|
|
||||||
[
|
|
||||||
MIGRATION_NAME,
|
|
||||||
JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
alreadyApplied: false,
|
|
||||||
configMigrated,
|
|
||||||
queueMigrated,
|
|
||||||
downloadedVodsCount,
|
|
||||||
streamersCount,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import { createPkcePair, generateState } from './pkce';
|
|
||||||
|
|
||||||
describe('createPkcePair', () => {
|
|
||||||
test('returns S256 method', () => {
|
|
||||||
expect(createPkcePair().codeChallengeMethod).toBe('S256');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier is 43+ chars base64url-safe', () => {
|
|
||||||
const { codeVerifier } = createPkcePair();
|
|
||||||
expect(codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
||||||
// RFC 7636 unreserved chars only: [A-Z a-z 0-9 - . _ ~]
|
|
||||||
// base64url uses [A-Z a-z 0-9 - _], no = padding.
|
|
||||||
expect(/^[A-Za-z0-9_-]+$/.test(codeVerifier)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('challenge matches sha256(verifier) base64url-encoded', () => {
|
|
||||||
const pair = createPkcePair();
|
|
||||||
const expected = crypto.createHash('sha256').update(pair.codeVerifier).digest('base64')
|
|
||||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
expect(pair.codeChallenge).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two pairs differ (sufficient entropy)', () => {
|
|
||||||
const a = createPkcePair();
|
|
||||||
const b = createPkcePair();
|
|
||||||
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
|
||||||
expect(a.codeChallenge).not.toBe(b.codeChallenge);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateState', () => {
|
|
||||||
test('returns >= 16 chars', () => {
|
|
||||||
expect(generateState().length).toBeGreaterThanOrEqual(16);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('base64url-safe charset', () => {
|
|
||||||
expect(/^[A-Za-z0-9_-]+$/.test(generateState())).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two states differ', () => {
|
|
||||||
expect(generateState()).not.toBe(generateState());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PKCE (Proof Key for Code Exchange) Helper fuer OAuth 2.1 Authorization Code Flow.
|
|
||||||
* RFC 7636. Twitch unterstuetzt S256.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PkcePair {
|
|
||||||
codeVerifier: string; // 43-128 ASCII chars [A-Z a-z 0-9 - . _ ~]
|
|
||||||
codeChallenge: string; // base64url(sha256(codeVerifier))
|
|
||||||
codeChallengeMethod: 'S256';
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64url(buf: Buffer): string {
|
|
||||||
return buf.toString('base64')
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPkcePair(): PkcePair {
|
|
||||||
// 32 random bytes → 43-char base64url. Innerhalb der RFC-Range.
|
|
||||||
const verifier = base64url(crypto.randomBytes(32));
|
|
||||||
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
||||||
return {
|
|
||||||
codeVerifier: verifier,
|
|
||||||
codeChallenge: challenge,
|
|
||||||
codeChallengeMethod: 'S256',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateState(): string {
|
|
||||||
// 16 random bytes als base64url-State-Parameter (CSRF-Schutz).
|
|
||||||
return base64url(crypto.randomBytes(16));
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from '../infra/db';
|
|
||||||
import { MemorySecureStorage } from '../infra/secure-storage';
|
|
||||||
import { createTokenStore, type TokenStore } from './token-store';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let db: DbHandle;
|
|
||||||
let store: TokenStore;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-'));
|
|
||||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
|
||||||
store = createTokenStore(db, new MemorySecureStorage());
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
db.close();
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createTokenStore', () => {
|
|
||||||
test('upsert new account returns record with id > 0', () => {
|
|
||||||
const rec = store.upsert({
|
|
||||||
provider: 'twitch',
|
|
||||||
twitchUserId: 'u1',
|
|
||||||
login: 'alice',
|
|
||||||
accessToken: 'aaa.aaa.aaa',
|
|
||||||
});
|
|
||||||
expect(rec.id).toBeGreaterThan(0);
|
|
||||||
expect(rec.login).toBe('alice');
|
|
||||||
expect(rec.provider).toBe('twitch');
|
|
||||||
expect(rec.twitchUserId).toBe('u1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => {
|
|
||||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' });
|
|
||||||
const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' });
|
|
||||||
expect(updated.login).toBe('alice2');
|
|
||||||
const all = store.list('twitch');
|
|
||||||
expect(all).toHaveLength(1);
|
|
||||||
expect(all[0].login).toBe('alice2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('list() returns all accounts, list(provider) filters', () => {
|
|
||||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
|
||||||
store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
|
||||||
store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' });
|
|
||||||
expect(store.list()).toHaveLength(3);
|
|
||||||
expect(store.list('twitch')).toHaveLength(2);
|
|
||||||
expect(store.list('youtube')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getDefault returns null when nothing default', () => {
|
|
||||||
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
|
||||||
expect(store.getDefault('twitch')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('upsert with isDefault=true makes it default, demotes siblings', () => {
|
|
||||||
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
|
||||||
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true });
|
|
||||||
|
|
||||||
const def = store.getDefault('twitch');
|
|
||||||
expect(def?.id).toBe(b.id);
|
|
||||||
|
|
||||||
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
|
||||||
expect(aAgain?.isDefault).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setDefault toggles is_default exclusivity within provider', () => {
|
|
||||||
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
|
||||||
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
|
||||||
|
|
||||||
store.setDefault(b.id);
|
|
||||||
expect(store.getDefault('twitch')?.id).toBe(b.id);
|
|
||||||
|
|
||||||
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
|
||||||
expect(aAgain?.isDefault).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getAccessToken returns decrypted plaintext', () => {
|
|
||||||
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' });
|
|
||||||
expect(store.getAccessToken(rec.id)).toBe('super-secret-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getRefreshToken returns null if not provided, value if provided', () => {
|
|
||||||
const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' });
|
|
||||||
expect(store.getRefreshToken(noRefresh.id)).toBeNull();
|
|
||||||
|
|
||||||
const withRefresh = store.upsert({
|
|
||||||
provider: 'twitch', twitchUserId: 'u2', login: 'b',
|
|
||||||
accessToken: 't2', refreshToken: 'refresh-xyz',
|
|
||||||
});
|
|
||||||
expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scopes roundtrip as array', () => {
|
|
||||||
const rec = store.upsert({
|
|
||||||
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
|
||||||
accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'],
|
|
||||||
});
|
|
||||||
expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete removes the record', () => {
|
|
||||||
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
|
||||||
store.delete(rec.id);
|
|
||||||
expect(store.list('twitch')).toHaveLength(0);
|
|
||||||
expect(() => store.getAccessToken(rec.id)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('expiresAt roundtrip', () => {
|
|
||||||
const future = Math.floor(Date.now() / 1000) + 3600;
|
|
||||||
const rec = store.upsert({
|
|
||||||
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
|
||||||
accessToken: 't', expiresAt: future,
|
|
||||||
});
|
|
||||||
expect(rec.expiresAt).toBe(future);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
import type { DbHandle } from '../infra/db';
|
|
||||||
import type { SecureStorage } from '../infra/secure-storage';
|
|
||||||
|
|
||||||
export interface TokenRecord {
|
|
||||||
id: number;
|
|
||||||
provider: string;
|
|
||||||
twitchUserId: string | null;
|
|
||||||
login: string | null;
|
|
||||||
displayName: string | null;
|
|
||||||
expiresAt: number | null;
|
|
||||||
scopes: string[];
|
|
||||||
isDefault: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenWriteInput {
|
|
||||||
provider: string;
|
|
||||||
twitchUserId?: string;
|
|
||||||
login?: string;
|
|
||||||
displayName?: string;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
scopes?: string[];
|
|
||||||
isDefault?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenStore {
|
|
||||||
upsert(input: TokenWriteInput): TokenRecord;
|
|
||||||
list(provider?: string): TokenRecord[];
|
|
||||||
getDefault(provider: string): TokenRecord | null;
|
|
||||||
setDefault(id: number): void;
|
|
||||||
getAccessToken(id: number): string;
|
|
||||||
getRefreshToken(id: number): string | null;
|
|
||||||
delete(id: number): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TokenRow {
|
|
||||||
id: number;
|
|
||||||
provider: string;
|
|
||||||
twitch_user_id: string | null;
|
|
||||||
login: string | null;
|
|
||||||
display_name: string | null;
|
|
||||||
encrypted_access_token: string;
|
|
||||||
encrypted_refresh_token: string | null;
|
|
||||||
expires_at: number | null;
|
|
||||||
scopes_json: string | null;
|
|
||||||
is_default: number;
|
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToRecord(row: TokenRow): TokenRecord {
|
|
||||||
let scopes: string[] = [];
|
|
||||||
if (row.scopes_json) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(row.scopes_json);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
scopes = parsed.filter((s): s is string => typeof s === 'string');
|
|
||||||
}
|
|
||||||
} catch { /* malformed scopes payload — treat as empty */ }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
provider: row.provider,
|
|
||||||
twitchUserId: row.twitch_user_id,
|
|
||||||
login: row.login,
|
|
||||||
displayName: row.display_name,
|
|
||||||
expiresAt: row.expires_at,
|
|
||||||
scopes,
|
|
||||||
isDefault: row.is_default === 1,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
updatedAt: row.updated_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore {
|
|
||||||
function getRowOrThrow(id: number): TokenRow {
|
|
||||||
const row = db.get<TokenRow>('SELECT * FROM oauth_accounts WHERE id = ?', [id]);
|
|
||||||
if (!row) throw new Error(`token-store: account id=${id} not found`);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
upsert(input: TokenWriteInput): TokenRecord {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const encryptedAccess = storage.encrypt(input.accessToken);
|
|
||||||
const encryptedRefresh = input.refreshToken !== undefined
|
|
||||||
? storage.encrypt(input.refreshToken)
|
|
||||||
: null;
|
|
||||||
const scopesJson = input.scopes && input.scopes.length > 0
|
|
||||||
? JSON.stringify(input.scopes)
|
|
||||||
: null;
|
|
||||||
const isDefault = input.isDefault ? 1 : 0;
|
|
||||||
const twitchUserId = input.twitchUserId ?? null;
|
|
||||||
|
|
||||||
let resultId: number | null = null;
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
// Insert or update conditional on UNIQUE(provider, twitch_user_id).
|
|
||||||
// Sqlite's ON CONFLICT braucht den vollstaendigen Konflikt-Ausdruck.
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO oauth_accounts(
|
|
||||||
provider, twitch_user_id, login, display_name,
|
|
||||||
encrypted_access_token, encrypted_refresh_token,
|
|
||||||
expires_at, scopes_json, is_default, created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(provider, twitch_user_id) DO UPDATE SET
|
|
||||||
login = excluded.login,
|
|
||||||
display_name = excluded.display_name,
|
|
||||||
encrypted_access_token = excluded.encrypted_access_token,
|
|
||||||
encrypted_refresh_token = excluded.encrypted_refresh_token,
|
|
||||||
expires_at = excluded.expires_at,
|
|
||||||
scopes_json = excluded.scopes_json,
|
|
||||||
is_default = excluded.is_default,
|
|
||||||
updated_at = excluded.updated_at`,
|
|
||||||
[
|
|
||||||
input.provider,
|
|
||||||
twitchUserId,
|
|
||||||
input.login ?? null,
|
|
||||||
input.displayName ?? null,
|
|
||||||
encryptedAccess,
|
|
||||||
encryptedRefresh,
|
|
||||||
input.expiresAt ?? null,
|
|
||||||
scopesJson,
|
|
||||||
isDefault,
|
|
||||||
now,
|
|
||||||
now,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wenn dieser Eintrag default ist: alle anderen mit gleichem provider auf 0 setzen.
|
|
||||||
if (isDefault === 1) {
|
|
||||||
db.run(
|
|
||||||
`UPDATE oauth_accounts
|
|
||||||
SET is_default = 0, updated_at = ?
|
|
||||||
WHERE provider = ?
|
|
||||||
AND NOT (twitch_user_id IS ? AND provider IS ?)`,
|
|
||||||
[now, input.provider, twitchUserId, input.provider]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookup = db.get<{ id: number }>(
|
|
||||||
`SELECT id FROM oauth_accounts
|
|
||||||
WHERE provider = ?
|
|
||||||
AND (twitch_user_id IS ? OR (twitch_user_id IS NULL AND ? IS NULL))`,
|
|
||||||
[input.provider, twitchUserId, twitchUserId]
|
|
||||||
);
|
|
||||||
resultId = lookup?.id ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resultId === null) throw new Error('token-store: upsert lookup failed');
|
|
||||||
return rowToRecord(getRowOrThrow(resultId));
|
|
||||||
},
|
|
||||||
|
|
||||||
list(provider?: string): TokenRecord[] {
|
|
||||||
const rows = provider
|
|
||||||
? db.all<TokenRow>('SELECT * FROM oauth_accounts WHERE provider = ? ORDER BY id', [provider])
|
|
||||||
: db.all<TokenRow>('SELECT * FROM oauth_accounts ORDER BY id');
|
|
||||||
return rows.map(rowToRecord);
|
|
||||||
},
|
|
||||||
|
|
||||||
getDefault(provider: string): TokenRecord | null {
|
|
||||||
const row = db.get<TokenRow>(
|
|
||||||
'SELECT * FROM oauth_accounts WHERE provider = ? AND is_default = 1 LIMIT 1',
|
|
||||||
[provider]
|
|
||||||
);
|
|
||||||
return row ? rowToRecord(row) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
setDefault(id: number): void {
|
|
||||||
const target = getRowOrThrow(id);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
db.transaction(() => {
|
|
||||||
db.run(
|
|
||||||
'UPDATE oauth_accounts SET is_default = 0, updated_at = ? WHERE provider = ?',
|
|
||||||
[now, target.provider]
|
|
||||||
);
|
|
||||||
db.run(
|
|
||||||
'UPDATE oauth_accounts SET is_default = 1, updated_at = ? WHERE id = ?',
|
|
||||||
[now, id]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getAccessToken(id: number): string {
|
|
||||||
const row = getRowOrThrow(id);
|
|
||||||
return storage.decrypt(row.encrypted_access_token);
|
|
||||||
},
|
|
||||||
|
|
||||||
getRefreshToken(id: number): string | null {
|
|
||||||
const row = getRowOrThrow(id);
|
|
||||||
return row.encrypted_refresh_token
|
|
||||||
? storage.decrypt(row.encrypted_refresh_token)
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
|
|
||||||
delete(id: number): void {
|
|
||||||
db.run('DELETE FROM oauth_accounts WHERE id = ?', [id]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import { fetchTopClips, rangeLastDays } from './top-clips-crawler';
|
|
||||||
|
|
||||||
function fakeFetch(rows: Array<Record<string, unknown>>, status = 200): typeof fetch {
|
|
||||||
return (async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
||||||
// verify request shape lightly inside the fake
|
|
||||||
const headers = init?.headers as Record<string, string> | undefined;
|
|
||||||
if (status === 200 && (!headers?.['Authorization'] || !headers?.['Client-Id'])) {
|
|
||||||
return new Response('missing auth headers', { status: 401 });
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ data: rows }), {
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('fetchTopClips', () => {
|
|
||||||
test('returns parsed clips sorted by view_count desc', async () => {
|
|
||||||
const fakeRows = [
|
|
||||||
{
|
|
||||||
id: 'C2', url: 'u2', embed_url: 'e2', broadcaster_id: 'b', broadcaster_name: 'B',
|
|
||||||
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
|
|
||||||
title: 'mid', view_count: 50, created_at: '2026-05-10T00:00:00Z',
|
|
||||||
thumbnail_url: 't', duration: 30, vod_offset: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'C1', url: 'u1', embed_url: 'e1', broadcaster_id: 'b', broadcaster_name: 'B',
|
|
||||||
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
|
|
||||||
title: 'high', view_count: 200, created_at: '2026-05-09T00:00:00Z',
|
|
||||||
thumbnail_url: 't', duration: 45, vod_offset: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const clips = await fetchTopClips({
|
|
||||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'b',
|
|
||||||
fetchImpl: fakeFetch(fakeRows),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(clips).toHaveLength(2);
|
|
||||||
expect(clips[0].id).toBe('C1');
|
|
||||||
expect(clips[0].viewCount).toBe(200);
|
|
||||||
expect(clips[1].id).toBe('C2');
|
|
||||||
expect(clips[1].vodOffsetSeconds).toBe(120);
|
|
||||||
expect(clips[0].vodOffsetSeconds).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('snake_case → camelCase mapping for broadcaster fields', async () => {
|
|
||||||
const fakeRows = [
|
|
||||||
{
|
|
||||||
id: 'X', url: 'u', embed_url: 'e', broadcaster_id: 'bid', broadcaster_name: 'BName',
|
|
||||||
creator_id: 'cid', creator_name: 'CName', video_id: 'vid', game_id: 'gid',
|
|
||||||
language: 'de', title: 'T', view_count: 10, created_at: '2026-05-01T00:00:00Z',
|
|
||||||
thumbnail_url: 'th', duration: 12,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const [c] = await fetchTopClips({
|
|
||||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'bid',
|
|
||||||
fetchImpl: fakeFetch(fakeRows),
|
|
||||||
});
|
|
||||||
expect(c.broadcasterId).toBe('bid');
|
|
||||||
expect(c.broadcasterName).toBe('BName');
|
|
||||||
expect(c.creatorId).toBe('cid');
|
|
||||||
expect(c.creatorName).toBe('CName');
|
|
||||||
expect(c.videoId).toBe('vid');
|
|
||||||
expect(c.gameId).toBe('gid');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builds query string with broadcaster_id + first + date range', async () => {
|
|
||||||
let capturedUrl: string | null = null;
|
|
||||||
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
|
|
||||||
capturedUrl = String(url);
|
|
||||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
await fetchTopClips({
|
|
||||||
clientId: 'CID', accessToken: 'TOK', broadcasterId: '12345',
|
|
||||||
startedAt: '2026-05-01T00:00:00Z', endedAt: '2026-05-11T00:00:00Z',
|
|
||||||
first: 50, fetchImpl: captureFetch,
|
|
||||||
});
|
|
||||||
expect(capturedUrl).toContain('broadcaster_id=12345');
|
|
||||||
expect(capturedUrl).toContain('first=50');
|
|
||||||
expect(capturedUrl).toContain('started_at=2026-05-01T00%3A00%3A00Z');
|
|
||||||
expect(capturedUrl).toContain('ended_at=2026-05-11T00%3A00%3A00Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clamps first to [1, 100]', async () => {
|
|
||||||
let capturedUrl: string | null = null;
|
|
||||||
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
|
|
||||||
capturedUrl = String(url);
|
|
||||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 999, fetchImpl: captureFetch });
|
|
||||||
expect(capturedUrl).toContain('first=100');
|
|
||||||
|
|
||||||
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 0, fetchImpl: captureFetch });
|
|
||||||
expect(capturedUrl).toContain('first=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on non-2xx response', async () => {
|
|
||||||
await expect(fetchTopClips({
|
|
||||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b',
|
|
||||||
fetchImpl: fakeFetch([], 503),
|
|
||||||
})).rejects.toThrow(/503/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on malformed JSON', async () => {
|
|
||||||
const brokenFetch = (async (): Promise<Response> => new Response('{not-json', { status: 200 })) as unknown as typeof fetch;
|
|
||||||
await expect(fetchTopClips({
|
|
||||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch,
|
|
||||||
})).rejects.toThrow(/parse failed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty data returns empty array (not null)', async () => {
|
|
||||||
const emptyFetch = (async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch;
|
|
||||||
const clips = await fetchTopClips({
|
|
||||||
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: emptyFetch,
|
|
||||||
});
|
|
||||||
expect(clips).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rangeLastDays', () => {
|
|
||||||
test('produces ISO RFC3339 strings exactly N days apart', () => {
|
|
||||||
const now = new Date('2026-05-11T12:00:00Z');
|
|
||||||
const range = rangeLastDays(7, now);
|
|
||||||
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
|
|
||||||
expect(range.startedAt).toBe('2026-05-04T12:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('1-day range', () => {
|
|
||||||
const now = new Date('2026-05-11T12:00:00Z');
|
|
||||||
const range = rangeLastDays(1, now);
|
|
||||||
expect(range.startedAt).toBe('2026-05-10T12:00:00.000Z');
|
|
||||||
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl
|
|
||||||
// aufgerufen (Tests koennen mocken). Helix-Endpunkt:
|
|
||||||
// GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N
|
|
||||||
//
|
|
||||||
// Auth: Client-Credentials (app-token) reicht — kein User-Token noetig.
|
|
||||||
// Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen.
|
|
||||||
|
|
||||||
const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips';
|
|
||||||
|
|
||||||
export interface TopClip {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
embedUrl: string;
|
|
||||||
broadcasterId: string;
|
|
||||||
broadcasterName: string;
|
|
||||||
creatorId: string;
|
|
||||||
creatorName: string;
|
|
||||||
videoId: string;
|
|
||||||
gameId: string;
|
|
||||||
language: string;
|
|
||||||
title: string;
|
|
||||||
viewCount: number;
|
|
||||||
createdAt: string; // ISO timestamp
|
|
||||||
thumbnailUrl: string;
|
|
||||||
duration: number; // seconds
|
|
||||||
vodOffsetSeconds: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HelixClipRow {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
embed_url: string;
|
|
||||||
broadcaster_id: string;
|
|
||||||
broadcaster_name: string;
|
|
||||||
creator_id: string;
|
|
||||||
creator_name: string;
|
|
||||||
video_id: string;
|
|
||||||
game_id: string;
|
|
||||||
language: string;
|
|
||||||
title: string;
|
|
||||||
view_count: number;
|
|
||||||
created_at: string;
|
|
||||||
thumbnail_url: string;
|
|
||||||
duration: number;
|
|
||||||
vod_offset?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HelixClipsResponse {
|
|
||||||
data?: HelixClipRow[];
|
|
||||||
pagination?: { cursor?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchTopClipsOptions {
|
|
||||||
clientId: string;
|
|
||||||
accessToken: string;
|
|
||||||
broadcasterId: string;
|
|
||||||
startedAt?: string; // ISO RFC3339
|
|
||||||
endedAt?: string;
|
|
||||||
first?: number; // 1-100, default 20
|
|
||||||
fetchImpl?: typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowToClip(row: HelixClipRow): TopClip {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
url: row.url,
|
|
||||||
embedUrl: row.embed_url,
|
|
||||||
broadcasterId: row.broadcaster_id,
|
|
||||||
broadcasterName: row.broadcaster_name,
|
|
||||||
creatorId: row.creator_id,
|
|
||||||
creatorName: row.creator_name,
|
|
||||||
videoId: row.video_id,
|
|
||||||
gameId: row.game_id,
|
|
||||||
language: row.language,
|
|
||||||
title: row.title,
|
|
||||||
viewCount: row.view_count,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
thumbnailUrl: row.thumbnail_url,
|
|
||||||
duration: row.duration,
|
|
||||||
vodOffsetSeconds: row.vod_offset ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip[]> {
|
|
||||||
const fetchFn = opts.fetchImpl ?? fetch;
|
|
||||||
const first = Math.min(100, Math.max(1, opts.first ?? 20));
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
broadcaster_id: opts.broadcasterId,
|
|
||||||
first: String(first),
|
|
||||||
});
|
|
||||||
if (opts.startedAt) params.set('started_at', opts.startedAt);
|
|
||||||
if (opts.endedAt) params.set('ended_at', opts.endedAt);
|
|
||||||
|
|
||||||
const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${opts.accessToken}`,
|
|
||||||
'Client-Id': opts.clientId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
let parsed: HelixClipsResponse;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text) as HelixClipsResponse;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = parsed.data ?? [];
|
|
||||||
// Helix returns clips already sorted by view_count desc, but we re-sort
|
|
||||||
// defensively in case that order ever changes.
|
|
||||||
return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DateRange {
|
|
||||||
startedAt: string;
|
|
||||||
endedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet
|
|
||||||
* RFC3339 Format (`2026-05-11T00:00:00Z`).
|
|
||||||
*/
|
|
||||||
export function rangeLastDays(days: number, now: Date = new Date()): DateRange {
|
|
||||||
const end = new Date(now.getTime());
|
|
||||||
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
||||||
return {
|
|
||||||
startedAt: start.toISOString(),
|
|
||||||
endedAt: end.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import {
|
|
||||||
startLoginFlow,
|
|
||||||
awaitAuthorizationCode,
|
|
||||||
exchangeCodeForToken,
|
|
||||||
fetchTwitchUserInfo,
|
|
||||||
} from './twitch-oauth';
|
|
||||||
import * as http from 'http';
|
|
||||||
|
|
||||||
function httpGet(url: string): Promise<{ status: number }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.get(url, res => {
|
|
||||||
res.on('data', () => { /* drain */ });
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode ?? 0 }));
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('startLoginFlow', () => {
|
|
||||||
test('builds Twitch authorize URL with required params + PKCE + state', async () => {
|
|
||||||
const flow = await startLoginFlow({
|
|
||||||
clientId: 'test-client',
|
|
||||||
scopes: ['user:read:email', 'channel:read:subscriptions'],
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(flow.authUrl).toContain('https://id.twitch.tv/oauth2/authorize');
|
|
||||||
const url = new URL(flow.authUrl);
|
|
||||||
expect(url.searchParams.get('client_id')).toBe('test-client');
|
|
||||||
expect(url.searchParams.get('response_type')).toBe('code');
|
|
||||||
expect(url.searchParams.get('scope')).toBe('user:read:email channel:read:subscriptions');
|
|
||||||
expect(url.searchParams.get('state')).toBe(flow.state);
|
|
||||||
expect(url.searchParams.get('code_challenge')).toBe(flow.pkce.codeChallenge);
|
|
||||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
|
||||||
expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/callback$/);
|
|
||||||
} finally {
|
|
||||||
flow.server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('awaitAuthorizationCode', () => {
|
|
||||||
test('returns code on successful redirect with matching state', async () => {
|
|
||||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
|
||||||
try {
|
|
||||||
const captureP = awaitAuthorizationCode(flow, 3000);
|
|
||||||
await httpGet(`${flow.server.url}?code=AUTHCODE&state=${flow.state}`);
|
|
||||||
const result = await captureP;
|
|
||||||
expect(result.code).toBe('AUTHCODE');
|
|
||||||
expect(result.state).toBe(flow.state);
|
|
||||||
} finally {
|
|
||||||
flow.server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects on state mismatch (CSRF protection)', async () => {
|
|
||||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
|
||||||
try {
|
|
||||||
// .catch fangt unhandled rejection ab — wir pruefen den Error manuell.
|
|
||||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
|
||||||
await httpGet(`${flow.server.url}?code=AUTHCODE&state=WRONG_STATE`);
|
|
||||||
const err = await captureP;
|
|
||||||
expect(err).toBeInstanceOf(Error);
|
|
||||||
expect((err as Error).message).toMatch(/state mismatch/);
|
|
||||||
} finally {
|
|
||||||
flow.server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects on error parameter', async () => {
|
|
||||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
|
||||||
try {
|
|
||||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
|
||||||
await httpGet(`${flow.server.url}?error=access_denied&error_description=user+denied`);
|
|
||||||
const err = await captureP;
|
|
||||||
expect(err).toBeInstanceOf(Error);
|
|
||||||
expect((err as Error).message).toMatch(/access_denied/);
|
|
||||||
} finally {
|
|
||||||
flow.server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects on missing code', async () => {
|
|
||||||
const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] });
|
|
||||||
try {
|
|
||||||
const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e);
|
|
||||||
await httpGet(`${flow.server.url}?state=${flow.state}`);
|
|
||||||
const err = await captureP;
|
|
||||||
expect(err).toBeInstanceOf(Error);
|
|
||||||
expect((err as Error).message).toMatch(/missing code/);
|
|
||||||
} finally {
|
|
||||||
flow.server.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('exchangeCodeForToken', () => {
|
|
||||||
test('POSTs correct body and returns parsed token', async () => {
|
|
||||||
let capturedBody: string | null = null;
|
|
||||||
const fakeFetch = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
||||||
capturedBody = init?.body as string;
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
access_token: 'ACC',
|
|
||||||
refresh_token: 'REF',
|
|
||||||
expires_in: 14400,
|
|
||||||
scope: ['user:read:email'],
|
|
||||||
token_type: 'bearer',
|
|
||||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
};
|
|
||||||
const token = await exchangeCodeForToken({
|
|
||||||
clientId: 'cid', code: 'CODE', codeVerifier: 'VERIFIER',
|
|
||||||
redirectUri: 'http://127.0.0.1:5555/oauth/callback',
|
|
||||||
fetchImpl: fakeFetch as unknown as typeof fetch,
|
|
||||||
});
|
|
||||||
expect(token.access_token).toBe('ACC');
|
|
||||||
expect(token.refresh_token).toBe('REF');
|
|
||||||
expect(capturedBody).toContain('client_id=cid');
|
|
||||||
expect(capturedBody).toContain('code=CODE');
|
|
||||||
expect(capturedBody).toContain('code_verifier=VERIFIER');
|
|
||||||
expect(capturedBody).toContain('grant_type=authorization_code');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws on non-2xx response', async () => {
|
|
||||||
const fakeFetch = async (): Promise<Response> => new Response('bad request', { status: 400 });
|
|
||||||
await expect(exchangeCodeForToken({
|
|
||||||
clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x',
|
|
||||||
fetchImpl: fakeFetch as unknown as typeof fetch,
|
|
||||||
})).rejects.toThrow(/400/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchTwitchUserInfo', () => {
|
|
||||||
test('returns first user from helix /users response', async () => {
|
|
||||||
const fakeFetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
||||||
const headers = init?.headers as Record<string, string>;
|
|
||||||
expect(headers['Authorization']).toBe('Bearer TOKEN');
|
|
||||||
expect(headers['Client-Id']).toBe('CID');
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
data: [{ id: '12345', login: 'alice', display_name: 'Alice' }],
|
|
||||||
}), { status: 200 });
|
|
||||||
};
|
|
||||||
const user = await fetchTwitchUserInfo('TOKEN', 'CID', fakeFetch as unknown as typeof fetch);
|
|
||||||
expect(user.id).toBe('12345');
|
|
||||||
expect(user.login).toBe('alice');
|
|
||||||
expect(user.display_name).toBe('Alice');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws when no user in response', async () => {
|
|
||||||
const fakeFetch = async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
||||||
await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch))
|
|
||||||
.rejects.toThrow(/no user/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
import { createPkcePair, generateState, type PkcePair } from './pkce';
|
|
||||||
import { startLoopbackServer, type LoopbackServer } from '../infra/loopback-server';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twitch OAuth 2.1 Authorization Code Flow + PKCE.
|
|
||||||
*
|
|
||||||
* Twitch supports PKCE since ~2022. Endpoints:
|
|
||||||
* Authorize: https://id.twitch.tv/oauth2/authorize
|
|
||||||
* Token: https://id.twitch.tv/oauth2/token
|
|
||||||
* Validate: https://id.twitch.tv/oauth2/validate
|
|
||||||
* Helix /users (whoami): https://api.twitch.tv/helix/users
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. startLoginFlow({clientId, scopes}) → { authUrl, ... }
|
|
||||||
* 2. shell.openExternal(authUrl) im Caller (main.ts hat shell)
|
|
||||||
* 3. await completeLoginFlow(state) → wartet auf Loopback-Redirect
|
|
||||||
* 4. Exchange code+verifier gegen token via fetch
|
|
||||||
* 5. Helix /users mit Bearer-Token → twitch_user_id + login + display_name
|
|
||||||
*
|
|
||||||
* Plan 03b liefert NUR Module + Tests. Eigentlicher login-flow IPC handler
|
|
||||||
* + Renderer-Button kommt in Folgeplan, weil das Twitch-Account-Setup
|
|
||||||
* (Client-ID in Twitch Dev Console mit korrektem Redirect-URI) erst
|
|
||||||
* vorbereitet werden muss.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TWITCH_AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize';
|
|
||||||
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
|
|
||||||
const TWITCH_HELIX_USERS_URL = 'https://api.twitch.tv/helix/users';
|
|
||||||
|
|
||||||
export interface TwitchTokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
scope: string[];
|
|
||||||
token_type: 'bearer';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TwitchUserInfo {
|
|
||||||
id: string;
|
|
||||||
login: string;
|
|
||||||
display_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginStart {
|
|
||||||
authUrl: string;
|
|
||||||
state: string;
|
|
||||||
pkce: PkcePair;
|
|
||||||
server: LoopbackServer;
|
|
||||||
redirectUri: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginStartOptions {
|
|
||||||
clientId: string;
|
|
||||||
scopes: string[];
|
|
||||||
pathPrefix?: string; // default '/oauth/callback'
|
|
||||||
port?: number; // 0 = OS-chooses
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startLoginFlow(opts: LoginStartOptions): Promise<LoginStart> {
|
|
||||||
const server = await startLoopbackServer({
|
|
||||||
pathPrefix: opts.pathPrefix ?? '/oauth/callback',
|
|
||||||
port: opts.port,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pkce = createPkcePair();
|
|
||||||
const state = generateState();
|
|
||||||
const redirectUri = server.url;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: opts.clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
response_type: 'code',
|
|
||||||
scope: opts.scopes.join(' '),
|
|
||||||
state,
|
|
||||||
code_challenge: pkce.codeChallenge,
|
|
||||||
code_challenge_method: pkce.codeChallengeMethod,
|
|
||||||
force_verify: 'true',
|
|
||||||
});
|
|
||||||
const authUrl = `${TWITCH_AUTHORIZE_URL}?${params.toString()}`;
|
|
||||||
|
|
||||||
return { authUrl, state, pkce, server, redirectUri };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompleteLoginResult {
|
|
||||||
code: string;
|
|
||||||
state: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wartet auf Redirect-Capture und prueft state.
|
|
||||||
* Throws bei mismatch state, bei `?error=` Parameter, oder bei Timeout.
|
|
||||||
*/
|
|
||||||
export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise<CompleteLoginResult> {
|
|
||||||
const params = await login.server.awaitParams({ timeoutMs });
|
|
||||||
if (params.has('error')) {
|
|
||||||
const err = params.get('error') ?? 'unknown_error';
|
|
||||||
const desc = params.get('error_description') ?? '';
|
|
||||||
throw new Error(`twitch-oauth: provider error: ${err}${desc ? ` — ${desc}` : ''}`);
|
|
||||||
}
|
|
||||||
const returnedState = params.get('state') ?? '';
|
|
||||||
if (returnedState !== login.state) {
|
|
||||||
throw new Error('twitch-oauth: state mismatch (possible CSRF or stale flow)');
|
|
||||||
}
|
|
||||||
const code = params.get('code');
|
|
||||||
if (!code) {
|
|
||||||
throw new Error('twitch-oauth: missing code parameter');
|
|
||||||
}
|
|
||||||
return { code, state: returnedState };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenExchangeOptions {
|
|
||||||
clientId: string;
|
|
||||||
code: string;
|
|
||||||
codeVerifier: string;
|
|
||||||
redirectUri: string;
|
|
||||||
fetchImpl?: typeof fetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise<TwitchTokenResponse> {
|
|
||||||
const fetchFn = opts.fetchImpl ?? fetch;
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
client_id: opts.clientId,
|
|
||||||
code: opts.code,
|
|
||||||
code_verifier: opts.codeVerifier,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
redirect_uri: opts.redirectUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetchFn(TWITCH_TOKEN_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
return JSON.parse(text) as TwitchTokenResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTwitchUserInfo(
|
|
||||||
accessToken: string,
|
|
||||||
clientId: string,
|
|
||||||
fetchImpl?: typeof fetch
|
|
||||||
): Promise<TwitchUserInfo> {
|
|
||||||
const fetchFn = fetchImpl ?? fetch;
|
|
||||||
const res = await fetchFn(TWITCH_HELIX_USERS_URL, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Client-Id': clientId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const text = await res.text();
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`);
|
|
||||||
}
|
|
||||||
const json = JSON.parse(text) as { data?: TwitchUserInfo[] };
|
|
||||||
const first = json.data?.[0];
|
|
||||||
if (!first) throw new Error('twitch-oauth: helix /users returned no user');
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import {
|
|
||||||
normalizeUpdateVersion,
|
|
||||||
compareUpdateVersions,
|
|
||||||
isNewerUpdateVersion,
|
|
||||||
} from './update-version-utils';
|
|
||||||
|
|
||||||
describe('normalizeUpdateVersion', () => {
|
|
||||||
test('strips v-prefix lowercase', () => {
|
|
||||||
expect(normalizeUpdateVersion('v1.2.3')).toBe('1.2.3');
|
|
||||||
});
|
|
||||||
test('strips V-prefix uppercase', () => {
|
|
||||||
expect(normalizeUpdateVersion('V1.2.3')).toBe('1.2.3');
|
|
||||||
});
|
|
||||||
test('trims whitespace', () => {
|
|
||||||
expect(normalizeUpdateVersion(' 1.2.3 ')).toBe('1.2.3');
|
|
||||||
});
|
|
||||||
test('handles null and undefined as empty string', () => {
|
|
||||||
expect(normalizeUpdateVersion(null)).toBe('');
|
|
||||||
expect(normalizeUpdateVersion(undefined)).toBe('');
|
|
||||||
});
|
|
||||||
test('passes plain version unchanged', () => {
|
|
||||||
expect(normalizeUpdateVersion('4.6.155')).toBe('4.6.155');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('compareUpdateVersions', () => {
|
|
||||||
test('older < newer in same minor', () => {
|
|
||||||
expect(compareUpdateVersions('4.1.10', '4.1.18')).toBeLessThan(0);
|
|
||||||
});
|
|
||||||
test('newer > older in same minor', () => {
|
|
||||||
expect(compareUpdateVersions('4.1.18', '4.1.10')).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
test('equal versions return 0', () => {
|
|
||||||
expect(compareUpdateVersions('4.6.155', '4.6.155')).toBe(0);
|
|
||||||
});
|
|
||||||
test('v-prefix is normalized away', () => {
|
|
||||||
expect(compareUpdateVersions('v4.1.12', '4.1.12')).toBe(0);
|
|
||||||
});
|
|
||||||
test('extra trailing part is newer', () => {
|
|
||||||
expect(compareUpdateVersions('4.1.12', '4.1.12.1')).toBeLessThan(0);
|
|
||||||
});
|
|
||||||
test('major bump wins', () => {
|
|
||||||
expect(compareUpdateVersions('5.0.0', '4.99.99')).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
test('null versions sort lowest', () => {
|
|
||||||
expect(compareUpdateVersions(null, '1.0.0')).toBeLessThan(0);
|
|
||||||
expect(compareUpdateVersions('1.0.0', null)).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
test('both null returns 0', () => {
|
|
||||||
expect(compareUpdateVersions(null, null)).toBe(0);
|
|
||||||
expect(compareUpdateVersions('', '')).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isNewerUpdateVersion', () => {
|
|
||||||
test('strictly newer returns true', () => {
|
|
||||||
expect(isNewerUpdateVersion('4.1.18', '4.1.17')).toBe(true);
|
|
||||||
});
|
|
||||||
test('equal returns false', () => {
|
|
||||||
expect(isNewerUpdateVersion('4.1.18', '4.1.18')).toBe(false);
|
|
||||||
});
|
|
||||||
test('older returns false', () => {
|
|
||||||
expect(isNewerUpdateVersion('4.1.17', '4.1.18')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
// Stammverzeichnis fuer das v5-Architektur-Refactoring.
|
|
||||||
// Plan 04 macht daraus den Entry-Point statt src/main.ts.
|
|
||||||
export {};
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { hashBuffer, hashFile } from './chunk-hash';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chunkhash-'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hashBuffer', () => {
|
|
||||||
test('"hello" sha1', () => {
|
|
||||||
expect(hashBuffer(Buffer.from('hello', 'utf-8')))
|
|
||||||
.toBe('aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty buffer sha1', () => {
|
|
||||||
expect(hashBuffer(Buffer.alloc(0)))
|
|
||||||
.toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('large buffer hashes deterministically', () => {
|
|
||||||
const big = Buffer.alloc(1024 * 1024, 0x42); // 1MB of 'B' bytes
|
|
||||||
const a = hashBuffer(big);
|
|
||||||
const b = hashBuffer(big);
|
|
||||||
expect(a).toBe(b);
|
|
||||||
expect(a).toHaveLength(40); // sha1 = 40 hex chars
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different content produces different hashes', () => {
|
|
||||||
expect(hashBuffer(Buffer.from('a'))).not.toBe(hashBuffer(Buffer.from('b')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hashFile', () => {
|
|
||||||
test('file hash matches buffer hash for same content', async () => {
|
|
||||||
const content = 'roundtrip-test-payload';
|
|
||||||
const filePath = path.join(tmpDir, 'a.bin');
|
|
||||||
fs.writeFileSync(filePath, content, 'utf-8');
|
|
||||||
const fileHash = await hashFile(filePath);
|
|
||||||
const bufHash = hashBuffer(Buffer.from(content, 'utf-8'));
|
|
||||||
expect(fileHash).toBe(bufHash);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty file = empty-buffer sha1', async () => {
|
|
||||||
const filePath = path.join(tmpDir, 'empty.bin');
|
|
||||||
fs.writeFileSync(filePath, '');
|
|
||||||
const fileHash = await hashFile(filePath);
|
|
||||||
expect(fileHash).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('large file (4MB) hashes correctly', async () => {
|
|
||||||
const filePath = path.join(tmpDir, 'big.bin');
|
|
||||||
const payload = Buffer.alloc(4 * 1024 * 1024, 0x55);
|
|
||||||
fs.writeFileSync(filePath, payload);
|
|
||||||
const fileHash = await hashFile(filePath);
|
|
||||||
expect(fileHash).toBe(hashBuffer(payload));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing file rejects', async () => {
|
|
||||||
await expect(hashFile(path.join(tmpDir, 'does-not-exist'))).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import * as crypto from 'crypto';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
|
|
||||||
export function hashBuffer(b: Buffer): string {
|
|
||||||
return crypto.createHash('sha1').update(b).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streaming sha1-Hash einer Datei. Async, damit grosse Recorded-Segments
|
|
||||||
* (oft mehrere MB) nicht den Event-Loop blockieren.
|
|
||||||
*/
|
|
||||||
export function hashFile(filePath: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hash = crypto.createHash('sha1');
|
|
||||||
const stream = fs.createReadStream(filePath);
|
|
||||||
stream.on('error', reject);
|
|
||||||
stream.on('data', (chunk: Buffer | string) => {
|
|
||||||
if (typeof chunk === 'string') {
|
|
||||||
hash.update(chunk, 'utf-8');
|
|
||||||
} else {
|
|
||||||
hash.update(chunk);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stream.on('end', () => resolve(hash.digest('hex')));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from './db';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let db: DbHandle | null = null;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
try { db?.close(); } catch { /* ignore */ }
|
|
||||||
db = null;
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('openDatabase', () => {
|
|
||||||
test('creates a new file', () => {
|
|
||||||
const target = path.join(tmpDir, 'a.db');
|
|
||||||
db = openDatabase(target);
|
|
||||||
expect(fs.existsSync(target)).toBe(true);
|
|
||||||
expect(typeof db.run).toBe('function');
|
|
||||||
expect(typeof db.get).toBe('function');
|
|
||||||
expect(typeof db.all).toBe('function');
|
|
||||||
expect(typeof db.close).toBe('function');
|
|
||||||
expect(typeof db.transaction).toBe('function');
|
|
||||||
expect(typeof db.runBatch).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('schema_meta row exists with schema_version=5', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'b.db'));
|
|
||||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
|
||||||
expect(row?.value).toBe('5');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('WAL mode active', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'c.db'));
|
|
||||||
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
|
|
||||||
expect(row?.journal_mode).toBe('wal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('idempotent open: existing file keeps schema_version=5', () => {
|
|
||||||
const target = path.join(tmpDir, 'd.db');
|
|
||||||
db = openDatabase(target);
|
|
||||||
db.close();
|
|
||||||
db = openDatabase(target);
|
|
||||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
|
||||||
expect(row?.value).toBe('5');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('run + get + all roundtrip on downloaded_vods', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'e.db'));
|
|
||||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']);
|
|
||||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']);
|
|
||||||
const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']);
|
|
||||||
expect(one?.vod_id).toBe('1234');
|
|
||||||
const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
|
||||||
expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transaction commits as bracket', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'f.db'));
|
|
||||||
const handle = db;
|
|
||||||
const inserted = handle.transaction(() => {
|
|
||||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t1']);
|
|
||||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['t2']);
|
|
||||||
return 2;
|
|
||||||
});
|
|
||||||
expect(inserted).toBe(2);
|
|
||||||
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
|
||||||
expect(c?.c).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('chunk_index table accepts insert + UNIQUE(item_id, chunk_seq)', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'chunk.db'));
|
|
||||||
db.run(
|
|
||||||
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
|
|
||||||
['item1', 0, 'abc123', 1024]
|
|
||||||
);
|
|
||||||
const handle = db;
|
|
||||||
expect(() => {
|
|
||||||
handle.run(
|
|
||||||
'INSERT INTO chunk_index(item_id, chunk_seq, sha1_hex, bytes) VALUES (?, ?, ?, ?)',
|
|
||||||
['item1', 0, 'different', 2048]
|
|
||||||
);
|
|
||||||
}).toThrow(); // UNIQUE violation
|
|
||||||
const rows = handle.all<{ sha1_hex: string }>('SELECT sha1_hex FROM chunk_index WHERE item_id = ?', ['item1']);
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
expect(rows[0].sha1_hex).toBe('abc123');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('oauth_accounts table exists and accepts insert', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'oauth.db'));
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
['twitch', 'user-123', 'alice', 'ciphertext-blob']
|
|
||||||
);
|
|
||||||
const row = db.get<{ login: string; provider: string }>(
|
|
||||||
'SELECT login, provider FROM oauth_accounts WHERE twitch_user_id = ?',
|
|
||||||
['user-123']
|
|
||||||
);
|
|
||||||
expect(row?.login).toBe('alice');
|
|
||||||
expect(row?.provider).toBe('twitch');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('oauth_accounts UNIQUE(provider, twitch_user_id) enforced', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'oauth-unique.db'));
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
['twitch', 'u1', 'a', 'x']
|
|
||||||
);
|
|
||||||
const handle = db;
|
|
||||||
expect(() => {
|
|
||||||
handle.run(
|
|
||||||
`INSERT INTO oauth_accounts(provider, twitch_user_id, login, encrypted_access_token)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
['twitch', 'u1', 'b', 'y']
|
|
||||||
);
|
|
||||||
}).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('transaction rolls back on throw', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'g.db'));
|
|
||||||
const handle = db;
|
|
||||||
expect(() => {
|
|
||||||
handle.transaction(() => {
|
|
||||||
handle.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['x1']);
|
|
||||||
throw new Error('boom');
|
|
||||||
});
|
|
||||||
}).toThrow('boom');
|
|
||||||
const c = handle.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
|
||||||
expect(c?.c).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import Database, { type Database as DatabaseT } from 'better-sqlite3';
|
|
||||||
import { SCHEMA_V5_SQL } from './schema-v5';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public DB-Handle. Schmaler Wrapper um better-sqlite3.
|
|
||||||
*/
|
|
||||||
export interface DbHandle {
|
|
||||||
run(sql: string, params?: unknown[]): void;
|
|
||||||
get<T = unknown>(sql: string, params?: unknown[]): T | undefined;
|
|
||||||
all<T = unknown>(sql: string, params?: unknown[]): T[];
|
|
||||||
transaction<R>(fn: () => R): R;
|
|
||||||
runBatch(sql: string): void;
|
|
||||||
close(): void;
|
|
||||||
readonly raw: DatabaseT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitStatements(sql: string): string[] {
|
|
||||||
return sql
|
|
||||||
.split(';')
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(s => s.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runMultiStatement(db: DatabaseT, sql: string): void {
|
|
||||||
for (const stmt of splitStatements(sql)) {
|
|
||||||
db.prepare(stmt).run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openDatabase(filePath: string): DbHandle {
|
|
||||||
const db = new Database(filePath);
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.pragma('busy_timeout = 5000');
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
|
|
||||||
runMultiStatement(db, SCHEMA_V5_SQL);
|
|
||||||
|
|
||||||
const handle: DbHandle = {
|
|
||||||
run(sql, params) {
|
|
||||||
db.prepare(sql).run(...(params ?? []) as unknown[]);
|
|
||||||
},
|
|
||||||
get<T>(sql: string, params?: unknown[]): T | undefined {
|
|
||||||
return db.prepare(sql).get(...(params ?? []) as unknown[]) as T | undefined;
|
|
||||||
},
|
|
||||||
all<T>(sql: string, params?: unknown[]): T[] {
|
|
||||||
return db.prepare(sql).all(...(params ?? []) as unknown[]) as T[];
|
|
||||||
},
|
|
||||||
transaction<R>(fn: () => R): R {
|
|
||||||
return db.transaction(fn)();
|
|
||||||
},
|
|
||||||
runBatch(sql) {
|
|
||||||
runMultiStatement(db, sql);
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
db.close();
|
|
||||||
},
|
|
||||||
get raw() { return db; },
|
|
||||||
};
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import { parseDuration, formatDuration, formatDurationDashed } from './duration';
|
|
||||||
|
|
||||||
describe('parseDuration', () => {
|
|
||||||
test('1h2m3s = 3723', () => {
|
|
||||||
expect(parseDuration('1h2m3s')).toBe(3723);
|
|
||||||
});
|
|
||||||
test('45m = 2700', () => {
|
|
||||||
expect(parseDuration('45m')).toBe(2700);
|
|
||||||
});
|
|
||||||
test('10s = 10', () => {
|
|
||||||
expect(parseDuration('10s')).toBe(10);
|
|
||||||
});
|
|
||||||
test('empty string = 0', () => {
|
|
||||||
expect(parseDuration('')).toBe(0);
|
|
||||||
});
|
|
||||||
test('unknown format = 0', () => {
|
|
||||||
expect(parseDuration('abcdef')).toBe(0);
|
|
||||||
});
|
|
||||||
test('partial 2h = 7200', () => {
|
|
||||||
expect(parseDuration('2h')).toBe(7200);
|
|
||||||
});
|
|
||||||
test('h and s without m = 3601', () => {
|
|
||||||
expect(parseDuration('1h1s')).toBe(3601);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatDuration', () => {
|
|
||||||
test('3723 = 01:02:03', () => {
|
|
||||||
expect(formatDuration(3723)).toBe('01:02:03');
|
|
||||||
});
|
|
||||||
test('0 = 00:00:00', () => {
|
|
||||||
expect(formatDuration(0)).toBe('00:00:00');
|
|
||||||
});
|
|
||||||
test('negative = 00:00:00', () => {
|
|
||||||
expect(formatDuration(-1)).toBe('00:00:00');
|
|
||||||
});
|
|
||||||
test('Infinity = 00:00:00', () => {
|
|
||||||
expect(formatDuration(Infinity)).toBe('00:00:00');
|
|
||||||
});
|
|
||||||
test('NaN = 00:00:00', () => {
|
|
||||||
expect(formatDuration(NaN)).toBe('00:00:00');
|
|
||||||
});
|
|
||||||
test('3600 = 01:00:00', () => {
|
|
||||||
expect(formatDuration(3600)).toBe('01:00:00');
|
|
||||||
});
|
|
||||||
test('86399 = 23:59:59', () => {
|
|
||||||
expect(formatDuration(86399)).toBe('23:59:59');
|
|
||||||
});
|
|
||||||
test('fractional seconds floored', () => {
|
|
||||||
expect(formatDuration(3723.9)).toBe('01:02:03');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatDurationDashed', () => {
|
|
||||||
test('3723 = 01-02-03', () => {
|
|
||||||
expect(formatDurationDashed(3723)).toBe('01-02-03');
|
|
||||||
});
|
|
||||||
test('negative = 00-00-00', () => {
|
|
||||||
expect(formatDurationDashed(-1)).toBe('00-00-00');
|
|
||||||
});
|
|
||||||
test('NaN = 00-00-00', () => {
|
|
||||||
expect(formatDurationDashed(NaN)).toBe('00-00-00');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
export function parseDuration(duration: string): number {
|
|
||||||
let seconds = 0;
|
|
||||||
const hours = duration.match(/(\d+)h/);
|
|
||||||
const minutes = duration.match(/(\d+)m/);
|
|
||||||
const secs = duration.match(/(\d+)s/);
|
|
||||||
|
|
||||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
|
||||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
|
||||||
if (secs) seconds += parseInt(secs[1]);
|
|
||||||
|
|
||||||
return seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDuration(seconds: number): string {
|
|
||||||
if (!isFinite(seconds) || seconds < 0) return '00:00:00';
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = Math.floor(seconds % 60);
|
|
||||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDurationDashed(seconds: number): string {
|
|
||||||
if (!isFinite(seconds) || seconds < 0) return '00-00-00';
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
const s = Math.floor(seconds % 60);
|
|
||||||
return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import {
|
|
||||||
sanitizeFilenamePart,
|
|
||||||
formatTwitchDurationFromSeconds,
|
|
||||||
formatDateWithPattern,
|
|
||||||
getMergeGroupPhaseText,
|
|
||||||
} from './format-helpers';
|
|
||||||
|
|
||||||
describe('sanitizeFilenamePart', () => {
|
|
||||||
test('replaces Windows-invalid chars with underscore', () => {
|
|
||||||
expect(sanitizeFilenamePart('a<b>c:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h');
|
|
||||||
});
|
|
||||||
test('replaces path separators', () => {
|
|
||||||
expect(sanitizeFilenamePart('a/b\\c')).toBe('a_b_c');
|
|
||||||
});
|
|
||||||
test('strips control chars', () => {
|
|
||||||
expect(sanitizeFilenamePart('a\x00b\x1fc')).toBe('a_b_c');
|
|
||||||
});
|
|
||||||
test('trims whitespace', () => {
|
|
||||||
expect(sanitizeFilenamePart(' hi ')).toBe('hi');
|
|
||||||
});
|
|
||||||
test('empty falls back to default', () => {
|
|
||||||
expect(sanitizeFilenamePart('')).toBe('unnamed');
|
|
||||||
});
|
|
||||||
test('custom fallback', () => {
|
|
||||||
expect(sanitizeFilenamePart('', 'FB')).toBe('FB');
|
|
||||||
});
|
|
||||||
test('only-invalid-chars falls back', () => {
|
|
||||||
expect(sanitizeFilenamePart('////').trim()).not.toBe('');
|
|
||||||
// '////' becomes '____' which is non-empty, so no fallback
|
|
||||||
expect(sanitizeFilenamePart('////')).toBe('____');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatTwitchDurationFromSeconds', () => {
|
|
||||||
test('0 = 0s', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(0)).toBe('0s');
|
|
||||||
});
|
|
||||||
test('45 = 45s', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(45)).toBe('45s');
|
|
||||||
});
|
|
||||||
test('65 = 1m5s', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(65)).toBe('1m5s');
|
|
||||||
});
|
|
||||||
test('3725 = 1h2m5s', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(3725)).toBe('1h2m5s');
|
|
||||||
});
|
|
||||||
test('3600 = 1h0m0s', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(3600)).toBe('1h0m0s');
|
|
||||||
});
|
|
||||||
test('negative clamped to 0', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(-5)).toBe('0s');
|
|
||||||
});
|
|
||||||
test('NaN clamped to 0', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(NaN)).toBe('0s');
|
|
||||||
});
|
|
||||||
test('Infinity clamped to 0', () => {
|
|
||||||
expect(formatTwitchDurationFromSeconds(Infinity)).toBe('0s');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatDateWithPattern', () => {
|
|
||||||
const d = new Date(2026, 4, 11, 23, 5, 7); // 2026-05-11 23:05:07
|
|
||||||
|
|
||||||
test('yyyy-MM-dd', () => {
|
|
||||||
expect(formatDateWithPattern(d, 'yyyy-MM-dd')).toBe('2026-05-11');
|
|
||||||
});
|
|
||||||
test('yy MM dd', () => {
|
|
||||||
expect(formatDateWithPattern(d, 'yy/MM/dd')).toBe('26/05/11');
|
|
||||||
});
|
|
||||||
test('HH:mm:ss', () => {
|
|
||||||
expect(formatDateWithPattern(d, 'HH:mm:ss')).toBe('23:05:07');
|
|
||||||
});
|
|
||||||
test('combined pattern', () => {
|
|
||||||
expect(formatDateWithPattern(d, 'yyyy-MM-dd_HH-mm-ss')).toBe('2026-05-11_23-05-07');
|
|
||||||
});
|
|
||||||
test('backslashes are stripped after token substitution', () => {
|
|
||||||
// Note: \ does NOT escape the date-token (no negative-lookbehind in regex).
|
|
||||||
// It only removes the literal backslash from the output. So 'yyyy\\X' → 'YYYYX'.
|
|
||||||
expect(formatDateWithPattern(d, 'yyyy\\X')).toBe('2026X');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMergeGroupPhaseText', () => {
|
|
||||||
test('known DE phases', () => {
|
|
||||||
expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen');
|
|
||||||
expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...');
|
|
||||||
expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt');
|
|
||||||
expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...');
|
|
||||||
});
|
|
||||||
test('known EN phases', () => {
|
|
||||||
expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD');
|
|
||||||
expect(getMergeGroupPhaseText('merging', 'en')).toBe('Merging...');
|
|
||||||
expect(getMergeGroupPhaseText('splitting', 'en')).toBe('Splitting Part');
|
|
||||||
expect(getMergeGroupPhaseText('cleanup', 'en')).toBe('Cleaning up...');
|
|
||||||
});
|
|
||||||
test('unknown phase passes through', () => {
|
|
||||||
expect(getMergeGroupPhaseText('unknown', 'de')).toBe('unknown');
|
|
||||||
});
|
|
||||||
test('unknown language falls back to DE', () => {
|
|
||||||
expect(getMergeGroupPhaseText('downloading', 'fr')).toBe('VOD wird heruntergeladen');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
// Pure-Format-Helpers, extrahiert aus main.ts. Keine Globals, keine I/O.
|
|
||||||
|
|
||||||
const FILENAME_INVALID_RE = /[<>:"|?*\x00-\x1f]/g;
|
|
||||||
const FILENAME_PATH_SEP_RE = /[\\/]/g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfernt Windows-Filesystem-verbotene Zeichen und Pfad-Separatoren aus einem
|
|
||||||
* Datei-Namen-Teilstring. Fallback wird zurueckgegeben, wenn nach Cleanup
|
|
||||||
* nichts uebrig bleibt.
|
|
||||||
*/
|
|
||||||
export function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string {
|
|
||||||
const cleaned = (input || '')
|
|
||||||
.replace(FILENAME_INVALID_RE, '_')
|
|
||||||
.replace(FILENAME_PATH_SEP_RE, '_')
|
|
||||||
.trim();
|
|
||||||
return cleaned || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twitch-Style Duration-Format: `1h2m3s`, `2m5s`, `42s`. Negative oder
|
|
||||||
* NaN-Inputs werden auf 0 geclamt.
|
|
||||||
*/
|
|
||||||
export function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
|
||||||
const seconds = Math.max(0, Math.floor(Number.isFinite(totalSeconds) ? totalSeconds : 0));
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATE_TOKEN_RE = /yyyy|yy|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s/g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Date-Formatter mit Pattern-Tokens (yyyy, yy, MM, M, dd, d, HH, H, hh, h,
|
|
||||||
* mm, m, ss, s). Backslash-escapes (\T) lassen das Folgezeichen literal.
|
|
||||||
*/
|
|
||||||
export function formatDateWithPattern(date: Date, pattern: string): string {
|
|
||||||
const tokenMap: Record<string, string> = {
|
|
||||||
yyyy: date.getFullYear().toString(),
|
|
||||||
yy: date.getFullYear().toString().slice(-2),
|
|
||||||
MM: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
||||||
M: (date.getMonth() + 1).toString(),
|
|
||||||
dd: date.getDate().toString().padStart(2, '0'),
|
|
||||||
d: date.getDate().toString(),
|
|
||||||
HH: date.getHours().toString().padStart(2, '0'),
|
|
||||||
H: date.getHours().toString(),
|
|
||||||
hh: date.getHours().toString().padStart(2, '0'),
|
|
||||||
h: date.getHours().toString(),
|
|
||||||
mm: date.getMinutes().toString().padStart(2, '0'),
|
|
||||||
m: date.getMinutes().toString(),
|
|
||||||
ss: date.getSeconds().toString().padStart(2, '0'),
|
|
||||||
s: date.getSeconds().toString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return pattern
|
|
||||||
.replace(DATE_TOKEN_RE, token => tokenMap[token] ?? token)
|
|
||||||
.replace(/\\(.)/g, '$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MergeGroupLanguage = 'de' | 'en';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Label fuer den aktuellen Merge-Group-Phase-Status. Pure variant — Sprache
|
|
||||||
* wird vom Caller injiziert.
|
|
||||||
*/
|
|
||||||
export function getMergeGroupPhaseText(phase: string, language: MergeGroupLanguage | string): string {
|
|
||||||
const isEnglish = language === 'en';
|
|
||||||
switch (phase) {
|
|
||||||
case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen';
|
|
||||||
case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...';
|
|
||||||
case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt';
|
|
||||||
case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...';
|
|
||||||
default: return phase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { writeFileAtomicSync } from './fs-atomic';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fsatomic-'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('writeFileAtomicSync', () => {
|
|
||||||
test('writes a string payload', () => {
|
|
||||||
const target = path.join(tmpDir, 'a.txt');
|
|
||||||
writeFileAtomicSync(target, 'hello');
|
|
||||||
expect(fs.readFileSync(target, 'utf-8')).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes a buffer payload', () => {
|
|
||||||
const target = path.join(tmpDir, 'b.bin');
|
|
||||||
writeFileAtomicSync(target, Buffer.from([1, 2, 3, 4]));
|
|
||||||
expect(fs.readFileSync(target)).toEqual(Buffer.from([1, 2, 3, 4]));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overwrites existing file', () => {
|
|
||||||
const target = path.join(tmpDir, 'c.txt');
|
|
||||||
fs.writeFileSync(target, 'old');
|
|
||||||
writeFileAtomicSync(target, 'new');
|
|
||||||
expect(fs.readFileSync(target, 'utf-8')).toBe('new');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cleans up tmp file after success', () => {
|
|
||||||
const target = path.join(tmpDir, 'd.txt');
|
|
||||||
writeFileAtomicSync(target, 'x');
|
|
||||||
expect(fs.existsSync(target + '.tmp')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('utf-8 multibyte chars roundtrip', () => {
|
|
||||||
const target = path.join(tmpDir, 'e.txt');
|
|
||||||
writeFileAtomicSync(target, 'aeoeue-aeoeue');
|
|
||||||
expect(fs.readFileSync(target, 'utf-8')).toBe('aeoeue-aeoeue');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty payload writes empty file', () => {
|
|
||||||
const target = path.join(tmpDir, 'f.txt');
|
|
||||||
writeFileAtomicSync(target, '');
|
|
||||||
expect(fs.readFileSync(target, 'utf-8')).toBe('');
|
|
||||||
expect(fs.statSync(target).size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomic write via tmp + rename. Survives crash mid-write — either old or
|
|
||||||
* new content, never partial. Windows fallback: copy + unlink if rename
|
|
||||||
* fails (e.g. target locked by reader). fsync best-effort.
|
|
||||||
*/
|
|
||||||
export function writeFileAtomicSync(targetPath: string, payload: string | Buffer): void {
|
|
||||||
const buffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');
|
|
||||||
const tmpPath = targetPath + '.tmp';
|
|
||||||
|
|
||||||
let fd: number | null = null;
|
|
||||||
try {
|
|
||||||
fd = fs.openSync(tmpPath, 'w');
|
|
||||||
fs.writeSync(fd, buffer, 0, buffer.length, 0);
|
|
||||||
try { fs.fsyncSync(fd); } catch { /* fsync may fail on some FS; rename is still safer than nothing */ }
|
|
||||||
} finally {
|
|
||||||
if (fd !== null) {
|
|
||||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.renameSync(tmpPath, targetPath);
|
|
||||||
} catch {
|
|
||||||
fs.copyFileSync(tmpPath, targetPath);
|
|
||||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import * as http from 'http';
|
|
||||||
import { startLoopbackServer } from './loopback-server';
|
|
||||||
|
|
||||||
function httpGet(url: string): Promise<{ status: number; body: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.get(url, res => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', chunk => { body += chunk.toString(); });
|
|
||||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('startLoopbackServer', () => {
|
|
||||||
test('binds to 127.0.0.1 and returns url with pathPrefix', async () => {
|
|
||||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
|
||||||
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/cb$/);
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('captures redirect params (code + state)', async () => {
|
|
||||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
|
||||||
const captureP = server.awaitParams({ timeoutMs: 3000 });
|
|
||||||
const response = await httpGet(`${server.url}?code=abc123&state=xyz`);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
const params = await captureP;
|
|
||||||
expect(params.get('code')).toBe('abc123');
|
|
||||||
expect(params.get('state')).toBe('xyz');
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-matching path returns 404, capture not triggered', async () => {
|
|
||||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
|
||||||
const captureP = server.awaitParams({ timeoutMs: 500 });
|
|
||||||
const response = await httpGet(`${server.url.replace('/cb', '/other')}`);
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
await expect(captureP).rejects.toThrow(/timeout/);
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('error param renders errorHtml', async () => {
|
|
||||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
|
||||||
const captureP = server.awaitParams({ timeoutMs: 3000 });
|
|
||||||
const response = await httpGet(`${server.url}?error=access_denied`);
|
|
||||||
expect(response.body).toContain('Fehler');
|
|
||||||
const params = await captureP;
|
|
||||||
expect(params.get('error')).toBe('access_denied');
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('timeout rejects', async () => {
|
|
||||||
const server = await startLoopbackServer({ pathPrefix: '/cb' });
|
|
||||||
await expect(server.awaitParams({ timeoutMs: 200 })).rejects.toThrow(/timeout/);
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import * as http from 'http';
|
|
||||||
import { URL } from 'url';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ephemerer HTTP-Server auf localhost:PORT fuer OAuth-Redirect-Capture.
|
|
||||||
* RFC 8252 (OAuth 2.0 for Native Apps) — System-Browser + Loopback-Redirect.
|
|
||||||
*
|
|
||||||
* Lifecycle:
|
|
||||||
* const server = await startLoopbackServer({ pathPrefix: '/oauth/callback' });
|
|
||||||
* console.log(server.url); // http://127.0.0.1:54321/oauth/callback
|
|
||||||
* const params = await server.awaitParams({ timeoutMs: 5 * 60 * 1000 });
|
|
||||||
* server.close();
|
|
||||||
*
|
|
||||||
* Bindet immer auf 127.0.0.1 (nicht 0.0.0.0) — der OS-Listener ist nur lokal
|
|
||||||
* erreichbar, kein Firewall-Prompt unter Windows.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface LoopbackServerOptions {
|
|
||||||
pathPrefix: string; // z.B. '/oauth/callback'
|
|
||||||
port?: number; // 0 = OS waehlt freien Port
|
|
||||||
successHtml?: string; // HTML-Antwort beim Capture
|
|
||||||
errorHtml?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoopbackServer {
|
|
||||||
readonly url: string;
|
|
||||||
awaitParams(opts?: { timeoutMs?: number }): Promise<URLSearchParams>;
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title>Login erfolgreich</title>
|
|
||||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
||||||
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
|
|
||||||
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
|
|
||||||
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
|
|
||||||
|
|
||||||
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
|
|
||||||
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
||||||
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
|
|
||||||
h1{color:#ff4444;margin:0 0 0.5rem}</style></head>
|
|
||||||
<body><div class="box"><h1>Fehler</h1><p>Login abgebrochen.</p></div></body></html>`;
|
|
||||||
|
|
||||||
export function startLoopbackServer(opts: LoopbackServerOptions): Promise<LoopbackServer> {
|
|
||||||
const successHtml = opts.successHtml ?? DEFAULT_SUCCESS;
|
|
||||||
const errorHtml = opts.errorHtml ?? DEFAULT_ERROR;
|
|
||||||
const pathPrefix = opts.pathPrefix.startsWith('/') ? opts.pathPrefix : '/' + opts.pathPrefix;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let resolveCapture: ((p: URLSearchParams) => void) | null = null;
|
|
||||||
let rejectCapture: ((e: Error) => void) | null = null;
|
|
||||||
let captureSettled = false;
|
|
||||||
|
|
||||||
const captureP = new Promise<URLSearchParams>((res, rej) => {
|
|
||||||
resolveCapture = res;
|
|
||||||
rejectCapture = rej;
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
||||||
if (!url.pathname.startsWith(pathPrefix)) {
|
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = url.searchParams;
|
|
||||||
const hasError = params.has('error');
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
||||||
res.end(hasError ? errorHtml : successHtml);
|
|
||||||
if (!captureSettled && resolveCapture) {
|
|
||||||
captureSettled = true;
|
|
||||||
resolveCapture(params);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('internal error');
|
|
||||||
if (!captureSettled && rejectCapture) {
|
|
||||||
captureSettled = true;
|
|
||||||
rejectCapture(e instanceof Error ? e : new Error(String(e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on('error', reject);
|
|
||||||
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
|
||||||
const addr = server.address();
|
|
||||||
if (!addr || typeof addr === 'string') {
|
|
||||||
server.close();
|
|
||||||
reject(new Error('loopback-server: failed to determine bound port'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = `http://127.0.0.1:${addr.port}${pathPrefix}`;
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
url,
|
|
||||||
async awaitParams(awaitOpts) {
|
|
||||||
const timeoutMs = awaitOpts?.timeoutMs ?? 5 * 60 * 1000;
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
const timeoutP = new Promise<URLSearchParams>((_, rej) => {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (!captureSettled && rejectCapture) {
|
|
||||||
captureSettled = true;
|
|
||||||
rejectCapture(new Error('loopback-server: timeout waiting for redirect'));
|
|
||||||
}
|
|
||||||
rej(new Error('loopback-server: timeout waiting for redirect'));
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await Promise.race([captureP, timeoutP]);
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
try { server.close(); } catch { /* already closed */ }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
// SQLite-Schema v5 fuer Twitch VOD Manager.
|
|
||||||
// Inline-Konstante damit tsc kein non-TS-Asset kopieren muss.
|
|
||||||
// Alle Tabellen mit IF NOT EXISTS — Schema-Bootstrap ist idempotent.
|
|
||||||
// PRAGMA-Statements (WAL etc.) werden separat von db.ts vor dem Bootstrap gesetzt.
|
|
||||||
|
|
||||||
export const SCHEMA_V5_SQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5');
|
|
||||||
INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('created_at', CAST(strftime('%s','now') AS TEXT));
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config_kv (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS queue_items (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
streamer_login TEXT,
|
|
||||||
vod_id TEXT,
|
|
||||||
clip_id TEXT,
|
|
||||||
title TEXT,
|
|
||||||
output_path TEXT,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
progress_pct REAL,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
completed_at INTEGER,
|
|
||||||
payload_json TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_items(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_queue_streamer ON queue_items(streamer_login);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_queue_created ON queue_items(created_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS downloaded_vods (
|
|
||||||
vod_id TEXT PRIMARY KEY,
|
|
||||||
downloaded_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS streamers (
|
|
||||||
login TEXT PRIMARY KEY,
|
|
||||||
auto_record INTEGER NOT NULL DEFAULT 0,
|
|
||||||
auto_vod_download INTEGER NOT NULL DEFAULT 0,
|
|
||||||
added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_streamers_autorec ON streamers(auto_record);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_streamers_autodl ON streamers(auto_vod_download);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS archive_files (
|
|
||||||
path TEXT PRIMARY KEY,
|
|
||||||
streamer_login TEXT,
|
|
||||||
size_bytes INTEGER,
|
|
||||||
duration_seconds INTEGER,
|
|
||||||
created_at INTEGER,
|
|
||||||
verified INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_archive_streamer ON archive_files(streamer_login);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chunk_index (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
chunk_seq INTEGER NOT NULL,
|
|
||||||
sha1_hex TEXT NOT NULL,
|
|
||||||
bytes INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
UNIQUE(item_id, chunk_seq)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chunk_item ON chunk_index(item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chunk_sha1 ON chunk_index(sha1_hex);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
twitch_user_id TEXT,
|
|
||||||
login TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
encrypted_access_token TEXT NOT NULL,
|
|
||||||
encrypted_refresh_token TEXT,
|
|
||||||
expires_at INTEGER,
|
|
||||||
scopes_json TEXT,
|
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
UNIQUE(provider, twitch_user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_oauth_default ON oauth_accounts(is_default);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS migrations_applied (
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
payload TEXT
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { test, expect, describe } from 'vitest';
|
|
||||||
import { MemorySecureStorage, createElectronSecureStorage, type SecureStorage } from './secure-storage';
|
|
||||||
|
|
||||||
describe('MemorySecureStorage', () => {
|
|
||||||
test('isEncryptionAvailable returns false (kennzeichnet Memory-Mode)', () => {
|
|
||||||
const s: SecureStorage = new MemorySecureStorage();
|
|
||||||
expect(s.isEncryptionAvailable()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roundtrip ascii', () => {
|
|
||||||
const s = new MemorySecureStorage();
|
|
||||||
const cipher = s.encrypt('hello');
|
|
||||||
expect(cipher).not.toBe('hello'); // base64-Kodierung greift
|
|
||||||
expect(s.decrypt(cipher)).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roundtrip multi-byte', () => {
|
|
||||||
const s = new MemorySecureStorage();
|
|
||||||
expect(s.decrypt(s.encrypt('aeoeue-test'))).toBe('aeoeue-test');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roundtrip empty string', () => {
|
|
||||||
const s = new MemorySecureStorage();
|
|
||||||
expect(s.decrypt(s.encrypt(''))).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('long token (simuliert OAuth access_token Groesse)', () => {
|
|
||||||
const s = new MemorySecureStorage();
|
|
||||||
const token = 'a'.repeat(256);
|
|
||||||
expect(s.decrypt(s.encrypt(token))).toBe(token);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createElectronSecureStorage', () => {
|
|
||||||
test('is exported as function', () => {
|
|
||||||
expect(typeof createElectronSecureStorage).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws useful error if called outside Electron (vitest env)', () => {
|
|
||||||
// In vitest (Node-only) ist electron entweder nicht installiert oder hat keine
|
|
||||||
// app-context-Funktionen. Genaues Error-Wording ist nicht stable, aber Aufruf
|
|
||||||
// muss throwen statt undefined zurueckgeben.
|
|
||||||
expect(() => createElectronSecureStorage()).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
// Verschluesselt String-Payloads im OS-Keystore (Win Credential Manager via
|
|
||||||
// Electron safeStorage). MemorySecureStorage ist fuer Tests/Headless-Envs —
|
|
||||||
// gibt plaintext zurueck und meldet isEncryptionAvailable() === false, damit
|
|
||||||
// Caller das in den Log schreiben oder verweigern koennen.
|
|
||||||
|
|
||||||
export interface SecureStorage {
|
|
||||||
isEncryptionAvailable(): boolean;
|
|
||||||
encrypt(plaintext: string): string;
|
|
||||||
decrypt(ciphertext: string): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MemorySecureStorage implements SecureStorage {
|
|
||||||
isEncryptionAvailable(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
encrypt(plaintext: string): string {
|
|
||||||
// Base64 als Kennzeichnung — kein Schutz, nur damit `decrypt(encrypt(x)) === x`
|
|
||||||
// semantisch konsistent ist (kein literal plaintext zwischen den Methoden).
|
|
||||||
return Buffer.from(plaintext, 'utf-8').toString('base64');
|
|
||||||
}
|
|
||||||
decrypt(ciphertext: string): string {
|
|
||||||
return Buffer.from(ciphertext, 'base64').toString('utf-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SafeStorageLike {
|
|
||||||
isEncryptionAvailable(): boolean;
|
|
||||||
encryptString(plain: string): Buffer;
|
|
||||||
decryptString(buf: Buffer): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrappt electron.safeStorage. Setzt voraus, dass `app.whenReady()` gefired ist.
|
|
||||||
* Wird per Lazy-Require konstruiert, sodass Module ausserhalb von Electron
|
|
||||||
* (zB Tests) das Modul importieren koennen ohne Crash.
|
|
||||||
*/
|
|
||||||
export function createElectronSecureStorage(): SecureStorage {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const electron = require('electron');
|
|
||||||
const safeStorage = electron?.safeStorage as SafeStorageLike | undefined;
|
|
||||||
if (!safeStorage) {
|
|
||||||
throw new Error('Electron safeStorage not available (called before app.whenReady?)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isEncryptionAvailable(): boolean {
|
|
||||||
return safeStorage.isEncryptionAvailable();
|
|
||||||
},
|
|
||||||
encrypt(plaintext: string): string {
|
|
||||||
const buf = safeStorage.encryptString(plaintext);
|
|
||||||
return buf.toString('base64');
|
|
||||||
},
|
|
||||||
decrypt(ciphertext: string): string {
|
|
||||||
const buf = Buffer.from(ciphertext, 'base64');
|
|
||||||
return safeStorage.decryptString(buf);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
// Command Palette — Pillar 5 UI Power.
|
|
||||||
// Ctrl+K oeffnet ein Suchfeld + Liste schnell ausfuehrbarer Aktionen.
|
|
||||||
// MVP: 6 statische Tab-Wechsel-Befehle, prefix-match auf Label.
|
|
||||||
|
|
||||||
interface PaletteCommand {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
hint: string;
|
|
||||||
keywords: string; // fuer Match — Label kleingeschrieben + Synonyme
|
|
||||||
action: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
(function initCommandPalette() {
|
|
||||||
const STORE: { commands: PaletteCommand[]; activeIndex: number; filtered: PaletteCommand[] } = {
|
|
||||||
commands: [],
|
|
||||||
activeIndex: 0,
|
|
||||||
filtered: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildCommands(): PaletteCommand[] {
|
|
||||||
const w = window as unknown as {
|
|
||||||
showTab?: (tab: string) => void;
|
|
||||||
selectStreamer?: (name: string, forceRefresh?: boolean) => Promise<void>;
|
|
||||||
config?: { streamers?: Array<{ name: string }> };
|
|
||||||
};
|
|
||||||
const showTab = w.showTab;
|
|
||||||
if (typeof showTab !== 'function') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste
|
|
||||||
// ('druecke Tab') statt 'oeffnet diesen Tab'.
|
|
||||||
const tabs: Array<{ id: string; labels: string[]; hint: string }> = [
|
|
||||||
{ id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Open' },
|
|
||||||
{ id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Open' },
|
|
||||||
{ id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Open' },
|
|
||||||
{ id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Open' },
|
|
||||||
{ id: 'archive', labels: ['Archive', 'archiv'], hint: 'Open' },
|
|
||||||
{ id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Open' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabCommands: PaletteCommand[] = tabs.map(t => ({
|
|
||||||
id: 'tab:' + t.id,
|
|
||||||
label: t.labels[0],
|
|
||||||
hint: t.hint,
|
|
||||||
keywords: t.labels.join(' ').toLowerCase(),
|
|
||||||
action: () => showTab(t.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Streamer-Liste aus globalem config (gefuellt nach renderer-Init).
|
|
||||||
const streamerCommands: PaletteCommand[] = [];
|
|
||||||
const streamers = Array.isArray(w.config?.streamers) ? w.config.streamers : [];
|
|
||||||
const selectStreamer = w.selectStreamer;
|
|
||||||
if (typeof selectStreamer === 'function') {
|
|
||||||
for (const entry of streamers) {
|
|
||||||
if (!entry || typeof entry.name !== 'string') continue;
|
|
||||||
const name = entry.name;
|
|
||||||
streamerCommands.push({
|
|
||||||
id: 'streamer:' + name.toLowerCase(),
|
|
||||||
label: name,
|
|
||||||
hint: 'Streamer',
|
|
||||||
keywords: ('@' + name + ' ' + name).toLowerCase(),
|
|
||||||
action: () => {
|
|
||||||
showTab('vods');
|
|
||||||
void selectStreamer(name);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...tabCommands, ...streamerCommands];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModal(): HTMLElement | null {
|
|
||||||
return document.getElementById('commandPaletteModal');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInput(): HTMLInputElement | null {
|
|
||||||
return document.getElementById('commandPaletteInput') as HTMLInputElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getList(): HTMLUListElement | null {
|
|
||||||
return document.getElementById('commandPaletteList') as HTMLUListElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpen(): boolean {
|
|
||||||
return Boolean(getModal()?.classList.contains('show'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearList(list: HTMLUListElement) {
|
|
||||||
while (list.firstChild) list.removeChild(list.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const list = getList();
|
|
||||||
if (!list) return;
|
|
||||||
clearList(list);
|
|
||||||
STORE.filtered.forEach((cmd, idx) => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
|
|
||||||
li.dataset.cmdId = cmd.id;
|
|
||||||
li.setAttribute('role', 'option');
|
|
||||||
li.setAttribute('aria-selected', idx === STORE.activeIndex ? 'true' : 'false');
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'cp-item-label';
|
|
||||||
label.textContent = cmd.label;
|
|
||||||
li.appendChild(label);
|
|
||||||
|
|
||||||
const hint = document.createElement('span');
|
|
||||||
hint.className = 'cp-item-hint';
|
|
||||||
hint.textContent = cmd.hint;
|
|
||||||
li.appendChild(hint);
|
|
||||||
|
|
||||||
li.addEventListener('mouseenter', () => {
|
|
||||||
STORE.activeIndex = idx;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
li.addEventListener('click', () => {
|
|
||||||
executeAt(idx);
|
|
||||||
});
|
|
||||||
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilter(query: string) {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) {
|
|
||||||
STORE.filtered = STORE.commands.slice();
|
|
||||||
} else {
|
|
||||||
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
|
|
||||||
}
|
|
||||||
if (STORE.activeIndex >= STORE.filtered.length) {
|
|
||||||
STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0;
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeAt(idx: number) {
|
|
||||||
const cmd = STORE.filtered[idx];
|
|
||||||
if (!cmd) return;
|
|
||||||
close();
|
|
||||||
try {
|
|
||||||
cmd.action();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('command-palette: action failed', cmd.id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
const modal = getModal();
|
|
||||||
const input = getInput();
|
|
||||||
if (!modal || !input) return;
|
|
||||||
STORE.commands = buildCommands();
|
|
||||||
STORE.filtered = STORE.commands.slice();
|
|
||||||
STORE.activeIndex = 0;
|
|
||||||
input.value = '';
|
|
||||||
modal.classList.add('show');
|
|
||||||
requestAnimationFrame(() => input.focus());
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
const modal = getModal();
|
|
||||||
if (!modal) return;
|
|
||||||
modal.classList.remove('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
// Toggle: Ctrl+K (Linux/Windows) or Cmd+K (Mac)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (isOpen()) {
|
|
||||||
close();
|
|
||||||
} else {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen()) return;
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (STORE.filtered.length === 0) return;
|
|
||||||
STORE.activeIndex = (STORE.activeIndex + 1) % STORE.filtered.length;
|
|
||||||
render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (STORE.filtered.length === 0) return;
|
|
||||||
STORE.activeIndex = (STORE.activeIndex - 1 + STORE.filtered.length) % STORE.filtered.length;
|
|
||||||
render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
executeAt(STORE.activeIndex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function attach() {
|
|
||||||
const input = getInput();
|
|
||||||
if (input) {
|
|
||||||
input.addEventListener('input', () => applyFilter(input.value));
|
|
||||||
}
|
|
||||||
const modal = getModal();
|
|
||||||
if (modal) {
|
|
||||||
modal.addEventListener('click', e => {
|
|
||||||
if (e.target === modal) close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKeydown, { capture: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', attach);
|
|
||||||
} else {
|
|
||||||
attach();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expose for renderer.ts closeTopmostOpenModal integration.
|
|
||||||
(window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette = close;
|
|
||||||
})();
|
|
||||||
@ -20,10 +20,7 @@ const UI_TEXT_DE = {
|
|||||||
clipsInfoTitle: 'Info',
|
clipsInfoTitle: 'Info',
|
||||||
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
|
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
|
||||||
cutterSelectTitle: 'Video auswahlen',
|
cutterSelectTitle: 'Video auswahlen',
|
||||||
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen',
|
|
||||||
cutterBrowse: 'Durchsuchen',
|
cutterBrowse: 'Durchsuchen',
|
||||||
commandPaletteSearchPlaceholder: 'Befehl suchen...',
|
|
||||||
commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen',
|
|
||||||
mergeTitle: 'Videos zusammenfugen',
|
mergeTitle: 'Videos zusammenfugen',
|
||||||
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',
|
mergeDesc: 'Wahle mehrere Videos aus, um sie zu einem Video zusammenzufugen. Die Reihenfolge kann geandert werden.',
|
||||||
mergeAdd: '+ Videos hinzufugen',
|
mergeAdd: '+ Videos hinzufugen',
|
||||||
|
|||||||
@ -20,10 +20,7 @@ const UI_TEXT_EN = {
|
|||||||
clipsInfoTitle: 'Info',
|
clipsInfoTitle: 'Info',
|
||||||
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
|
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
|
||||||
cutterSelectTitle: 'Select video',
|
cutterSelectTitle: 'Select video',
|
||||||
cutterPreviewPlaceholder: 'Select a video to see a preview',
|
|
||||||
cutterBrowse: 'Browse',
|
cutterBrowse: 'Browse',
|
||||||
commandPaletteSearchPlaceholder: 'Search command...',
|
|
||||||
commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close',
|
|
||||||
mergeTitle: 'Merge videos',
|
mergeTitle: 'Merge videos',
|
||||||
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
|
mergeDesc: 'Select multiple videos to merge into one file. You can change the order before merging.',
|
||||||
mergeAdd: '+ Add videos',
|
mergeAdd: '+ Add videos',
|
||||||
|
|||||||
@ -114,14 +114,10 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
|
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
|
||||||
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
|
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
|
||||||
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
|
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
|
||||||
setText('btnClip', UI_TEXT.clips.downloadButton);
|
|
||||||
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
|
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
|
||||||
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
|
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
|
||||||
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
||||||
setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder);
|
|
||||||
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
||||||
setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder);
|
|
||||||
setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint);
|
|
||||||
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
|
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
|
||||||
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
|
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
|
||||||
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
|
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
|
||||||
|
|||||||
@ -12,7 +12,6 @@ interface ActiveHover {
|
|||||||
vodId: string;
|
vodId: string;
|
||||||
intervalId: number;
|
intervalId: number;
|
||||||
overlay: HTMLElement;
|
overlay: HTMLElement;
|
||||||
card: HTMLElement; // .vod-card, fuer preview-active toggle (separat vom overlay-host)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
||||||
@ -80,7 +79,8 @@ function clearHoverPreview(): void {
|
|||||||
pendingHoverVodId = null;
|
pendingHoverVodId = null;
|
||||||
if (!activeHover) return;
|
if (!activeHover) return;
|
||||||
window.clearInterval(activeHover.intervalId);
|
window.clearInterval(activeHover.intervalId);
|
||||||
activeHover.card.classList.remove('preview-active');
|
const card = activeHover.overlay.parentElement;
|
||||||
|
if (card) card.classList.remove('preview-active');
|
||||||
// Brief opacity fade-out, then remove from DOM.
|
// Brief opacity fade-out, then remove from DOM.
|
||||||
activeHover.overlay.style.opacity = '0';
|
activeHover.overlay.style.opacity = '0';
|
||||||
const overlayToRemove = activeHover.overlay;
|
const overlayToRemove = activeHover.overlay;
|
||||||
@ -124,47 +124,31 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<v
|
|||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'vod-storyboard-preview';
|
overlay.className = 'vod-storyboard-preview';
|
||||||
|
// Scale the sprite so a single cell exactly fills the card width.
|
||||||
// Anchor an .vod-thumb-wrap. Wrap-Element hat exakt Thumbnail-Bounds.
|
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
|
||||||
const anchor = card.querySelector('.vod-thumb-wrap') as HTMLElement | null;
|
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
|
||||||
const host = anchor ?? card;
|
const cardWidth = card.getBoundingClientRect().width;
|
||||||
const hostRect = host.getBoundingClientRect();
|
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
|
||||||
const width = hostRect.width;
|
const scale = cardWidth / storyboard.cellWidth;
|
||||||
const height = hostRect.height;
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0) return;
|
|
||||||
if (storyboard.cellWidth <= 0 || storyboard.cellHeight <= 0) return;
|
|
||||||
|
|
||||||
// Position + Size voll inline gesetzt — kein CSS aspect-ratio mehr, das
|
|
||||||
// sich mit JS-Dimensionen streiten koennte (siehe styles.css, die Klasse
|
|
||||||
// gibt nur noch Visual + Stacking, keine Geometrie).
|
|
||||||
overlay.style.top = '0';
|
|
||||||
overlay.style.left = '0';
|
|
||||||
overlay.style.width = `${width}px`;
|
|
||||||
overlay.style.height = `${height}px`;
|
|
||||||
|
|
||||||
// Skaliere X und Y unabhaengig, damit eine Cell die Overlay-Box exakt
|
|
||||||
// fuellt — Twitch-Cell-Aspect kann von 16:9 minimal abweichen.
|
|
||||||
const scaleX = width / storyboard.cellWidth;
|
|
||||||
const scaleY = height / storyboard.cellHeight;
|
|
||||||
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
||||||
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scaleX}px ${storyboard.rows * storyboard.cellHeight * scaleY}px`;
|
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
|
||||||
overlay.style.backgroundRepeat = 'no-repeat';
|
overlay.style.height = `${cardWidth / cellAspect}px`;
|
||||||
|
// Initial position = first chosen cell.
|
||||||
const first = cellsToShow[0];
|
const first = cellsToShow[0];
|
||||||
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scaleX}px -${first.row * storyboard.cellHeight * scaleY}px`;
|
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
|
||||||
|
|
||||||
host.appendChild(overlay);
|
card.appendChild(overlay);
|
||||||
// Trigger CSS transition to opacity:1 on the next frame.
|
// Trigger CSS transition to opacity:1 on the next frame.
|
||||||
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
||||||
|
|
||||||
let frameIdx = 1;
|
let frameIdx = 1;
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
||||||
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scaleX}px -${cell.row * storyboard.cellHeight * scaleY}px`;
|
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scale}px -${cell.row * storyboard.cellHeight * scale}px`;
|
||||||
frameIdx++;
|
frameIdx++;
|
||||||
}, FRAME_INTERVAL_MS);
|
}, FRAME_INTERVAL_MS);
|
||||||
|
|
||||||
activeHover = { vodId, intervalId, overlay, card };
|
activeHover = { vodId, intervalId, overlay };
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
||||||
|
|||||||
@ -536,15 +536,6 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
|||||||
|
|
||||||
function closeTopmostOpenModal(): boolean {
|
function closeTopmostOpenModal(): boolean {
|
||||||
// Try each known modal in priority order
|
// Try each known modal in priority order
|
||||||
const commandPaletteModal = document.getElementById('commandPaletteModal');
|
|
||||||
if (commandPaletteModal?.classList.contains('show')) {
|
|
||||||
const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette;
|
|
||||||
if (typeof closeCp === 'function') {
|
|
||||||
closeCp();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
||||||
if (eventsViewerModal?.classList.contains('show')) {
|
if (eventsViewerModal?.classList.contains('show')) {
|
||||||
closeEventsViewer();
|
closeEventsViewer();
|
||||||
|
|||||||
212
src/styles.css
212
src/styles.css
@ -693,32 +693,6 @@ body {
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GLOBAL TEXT-INPUT POLISH — focus ring + smooth transitions
|
GLOBAL TEXT-INPUT POLISH — focus ring + smooth transitions
|
||||||
============================================ */
|
============================================ */
|
||||||
/* Basis-Dark-Theme fuer ALLE text-artigen Inputs + Selects, damit nie ein
|
|
||||||
OS-default-weisses Feld durchkommt wenn ein Input mal nicht in einem
|
|
||||||
.form-group / .form-stack / .header-search etc. Wrapper sitzt (z.B.
|
|
||||||
#cutterFilePath in .settings-card > .form-row). Per-Container-Regeln
|
|
||||||
ueberschreiben Groesse/Padding spaeter — Farbe/Border kommen von hier. */
|
|
||||||
input[type="text"],
|
|
||||||
input[type="search"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="password"],
|
|
||||||
input[type="email"],
|
|
||||||
textarea {
|
|
||||||
background-color: var(--bg-main);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border-soft);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]::placeholder,
|
|
||||||
input[type="search"]::placeholder,
|
|
||||||
input[type="number"]::placeholder,
|
|
||||||
input[type="password"]::placeholder,
|
|
||||||
input[type="email"]::placeholder,
|
|
||||||
textarea::placeholder {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
@ -782,21 +756,20 @@ input[type="checkbox"]:hover:not(:disabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked {
|
input[type="checkbox"]:checked {
|
||||||
/* Inline-SVG-Checkmark statt rotated-border-Trick — robuster ueber
|
background: var(--accent);
|
||||||
DPI-Skalierungen und Remote-Desktop-Renderings (RDP / mstsc), wo
|
|
||||||
die kleinen border-rotation-Pseudo-Elemente teilweise als
|
|
||||||
Dreiecke / Pfeile gerendert wurden. */
|
|
||||||
background-color: var(--accent);
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3.5 8.5 6.5 11.5 12.5 5'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
background-size: 12px;
|
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked::after {
|
input[type="checkbox"]:checked::after {
|
||||||
/* Pseudo-Element nicht mehr noetig — Checkmark kommt aus background. */
|
content: '';
|
||||||
content: none;
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 0.5px;
|
||||||
|
width: 5px;
|
||||||
|
height: 9px;
|
||||||
|
border: solid #fff;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:focus-visible {
|
input[type="checkbox"]:focus-visible {
|
||||||
@ -1469,12 +1442,6 @@ select option {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
/* Flex-Column + stretch (grid default) macht alle Cards einer Reihe
|
|
||||||
gleich hoch. Die Actions unten kriegen margin-top:auto und docken
|
|
||||||
damit am Boden an — egal ob der Titel 1 oder 2 Zeilen hat. Vorher
|
|
||||||
sass der Button bei 1-Zeilen-Titeln hoeher als bei 2-Zeilen-Nachbarn. */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-card:hover {
|
.vod-card:hover {
|
||||||
@ -1697,9 +1664,6 @@ select option {
|
|||||||
padding: 10px 15px 15px;
|
padding: 10px 15px 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
/* Dockt am Card-Boden an, sodass Trim/Queue-Buttons ueber alle Cards
|
|
||||||
einer Reihe auf gleicher Hoehe liegen — unabhaengig von Titel-Zeilen. */
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-btn {
|
.vod-btn {
|
||||||
@ -1795,16 +1759,9 @@ select option {
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:not([type="checkbox"]):not([type="radio"]),
|
.form-group input:not([type="checkbox"]):not([type="radio"]), .form-group select {
|
||||||
.form-group select,
|
|
||||||
.form-stack input:not([type="checkbox"]):not([type="radio"]),
|
|
||||||
.form-stack select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* background-color (nicht background shorthand) — sonst wuerden
|
background: var(--bg-main);
|
||||||
background-image (Chevron-SVG), background-repeat, background-size
|
|
||||||
und background-position aus der globalen `select`-Regel resettet,
|
|
||||||
was zu tiled Chevrons im Dropdown gefuehrt hat. */
|
|
||||||
background-color: var(--bg-main);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@ -1812,18 +1769,13 @@ select option {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:not([type="checkbox"]):not([type="radio"]):focus,
|
.form-group input:not([type="checkbox"]):not([type="radio"]):focus, .form-group select:focus {
|
||||||
.form-group select:focus,
|
|
||||||
.form-stack input:not([type="checkbox"]):not([type="radio"]):focus,
|
|
||||||
.form-stack select:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:not([type="checkbox"]):not([type="radio"]):disabled,
|
.form-group input:not([type="checkbox"]):not([type="radio"]):disabled,
|
||||||
.form-group select:disabled,
|
.form-group select:disabled {
|
||||||
.form-stack input:not([type="checkbox"]):not([type="radio"]):disabled,
|
|
||||||
.form-stack select:disabled {
|
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
color: rgba(239, 239, 241, 0.7);
|
color: rgba(239, 239, 241, 0.7);
|
||||||
@ -1835,11 +1787,7 @@ select option {
|
|||||||
|
|
||||||
.form-group input[type="checkbox"],
|
.form-group input[type="checkbox"],
|
||||||
.form-group input[type="radio"] {
|
.form-group input[type="radio"] {
|
||||||
/* width:auto wuerde Checkbox auf 0/1px kollabieren, weil
|
width: auto;
|
||||||
appearance:none + kein Content. Wir wollen die 16x16 aus der
|
|
||||||
globalen Regel — daher explicit width:16px hier nochmal, damit
|
|
||||||
die Klassen-Specificity nicht den globalen Wert ueberschreibt. */
|
|
||||||
width: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-picker {
|
.language-picker {
|
||||||
@ -2378,32 +2326,9 @@ select option {
|
|||||||
|
|
||||||
/* Compact-width input — used for the Auto-VOD poll/age inputs where
|
/* Compact-width input — used for the Auto-VOD poll/age inputs where
|
||||||
the values are 2-3 digits and a full-width input would look odd
|
the values are 2-3 digits and a full-width input would look odd
|
||||||
alongside their inline sublabels. Stylt sich selbst mit dark-theme
|
alongside their inline sublabels. */
|
||||||
weil es direkt in einer .form-row sitzt (kein .form-group / .form-stack
|
|
||||||
Wrapper, der das styling sonst beistellt). */
|
|
||||||
.input-narrow {
|
.input-narrow {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
background-color: var(--bg-main);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-narrow:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-narrow:hover:not(:focus):not(:disabled) {
|
|
||||||
border-color: rgba(145, 70, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-narrow:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Block-level note text — same colour as .form-sublabel but reserved
|
/* Block-level note text — same colour as .form-sublabel but reserved
|
||||||
@ -3363,9 +3288,6 @@ body.theme-light .status-bar {
|
|||||||
body.theme-light .add-streamer input,
|
body.theme-light .add-streamer input,
|
||||||
body.theme-light .form-group input:not([type="checkbox"]):not([type="radio"]),
|
body.theme-light .form-group input:not([type="checkbox"]):not([type="radio"]),
|
||||||
body.theme-light .form-group select,
|
body.theme-light .form-group select,
|
||||||
body.theme-light .form-stack input:not([type="checkbox"]):not([type="radio"]),
|
|
||||||
body.theme-light .form-stack select,
|
|
||||||
body.theme-light .input-narrow,
|
|
||||||
body.theme-light .clip-input input,
|
body.theme-light .clip-input input,
|
||||||
body.theme-light .time-input-group input,
|
body.theme-light .time-input-group input,
|
||||||
body.theme-light .part-number-group input,
|
body.theme-light .part-number-group input,
|
||||||
@ -3938,9 +3860,7 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
background: rgba(0, 0, 0, 0.88);
|
background: rgba(0, 0, 0, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vod-card.preview-active .vod-duration-badge,
|
.vod-card.preview-active .vod-duration-badge {
|
||||||
.vod-card.preview-active .vod-select-checkbox,
|
|
||||||
.vod-card.preview-active .vod-downloaded-badge {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
@ -4575,9 +4495,11 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
thumbnail's bounding box. Width matches the card; aspect-ratio
|
thumbnail's bounding box. Width matches the card; aspect-ratio
|
||||||
16/9 anchors the height to align with the thumbnail. */
|
16/9 anchors the height to align with the thumbnail. */
|
||||||
.vod-storyboard-preview {
|
.vod-storyboard-preview {
|
||||||
/* Position + size werden vollstaendig per JS gesetzt (siehe
|
|
||||||
renderer-vod-hover.ts). Wir geben hier nur Visual + Stacking. */
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.22s ease-out;
|
transition: opacity 0.22s ease-out;
|
||||||
@ -4695,95 +4617,3 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Command Palette (Pillar 5 — added in 5.1.0-alpha.1) */
|
|
||||||
.command-palette {
|
|
||||||
max-width: 540px;
|
|
||||||
width: 90%;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-title {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
background: var(--bg-main);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border-soft);
|
|
||||||
border-radius: 4px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--border-soft);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-list:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-item {
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--text);
|
|
||||||
border-bottom: 1px solid var(--border-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-item:hover,
|
|
||||||
.cp-item.cp-active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-item-label {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-item-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-item:hover .cp-item-hint,
|
|
||||||
.cp-item.cp-active .cp-item-hint {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cp-hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,275 +0,0 @@
|
|||||||
# Twitch VOD Manager — v5.0.0 Goal
|
|
||||||
|
|
||||||
**Baseline:** v4.6.155 (Stand 2026-05-11)
|
|
||||||
**Target:** v5.0.0
|
|
||||||
**Status:** Draft / nicht freigegeben
|
|
||||||
**Sprache:** Deutsch (Stack-Begriffe englisch)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Executive Summary
|
|
||||||
|
|
||||||
Nach 155 Patches im 4.6er-Zyklus ist das Produkt poliert, aber im Kern unverändert seit 4.0: monolithische `main.ts` (~3700 Zeilen), JSON-File-Persistenz, Vanilla-DOM-Renderer, kein Live-Recording, keine sub-only Unterstützung, keine Tests jenseits Playwright-Smoke.
|
|
||||||
|
|
||||||
**v5.0.0 ist der Bruch:** Aufnahme statt nur Download, SQLite statt JSON, modulare Domain-Architektur, Twitch-OAuth, optionales Web-Worker-Offloading für grosse Listen. Sieben Feature-Pillars, drei harte Breaking Changes, klarer Migrationspfad von 4.6.x.
|
|
||||||
|
|
||||||
**Was 5.0.0 NICHT ist:** kein React/Vue-Rewrite, kein Plattform-Hop (bleibt Electron + Win), kein neues Bezahlmodell. Es bleibt der gleiche Single-User-Desktop-Client — nur deutlich erwachsener.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Why Major Bump?
|
|
||||||
|
|
||||||
Ein Major-Sprung ist gerechtfertigt, wenn **mindestens zwei** Kriterien erfuellt sind:
|
|
||||||
|
|
||||||
| Kriterium | 5.0.0 trifft zu? | Belege |
|
|
||||||
|---|---|---|
|
|
||||||
| Breaking Change im Config-/Datenformat | Ja | JSON → SQLite Migration, Schema v4 → v5 |
|
|
||||||
| Neues Kern-Feature (nicht Inkrement) | Ja | Live-Stream-Recording (sub-only via OAuth), Scheduled-Recording |
|
|
||||||
| Architektur-Bruch | Ja | main.ts Split in 6+ Domain-Module |
|
|
||||||
| Min-OS-/Runtime-Bump | Ja | Electron 28 → 32, Node 18 → 22, Drop Win < 10 1809 |
|
|
||||||
| User-spuerbare UI-Verschiebung | Ja | Virtual-List, Mini-Player, Theme-Engine |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Themes & Vision
|
|
||||||
|
|
||||||
Drei Leitthemen ueber allen Features:
|
|
||||||
|
|
||||||
### 3.1 Vom Downloader zum Recorder
|
|
||||||
v4 reagiert: VOD existiert auf Twitch → wir laden runter. v5 agiert proaktiv: Stream geht live → wir nehmen direkt mit. Damit fallen Sub-Only-VODs (oft 60 Tage Retention), 7-Tage-Standard-Retention und geloeschte VODs als Verlust-Szenarien weg.
|
|
||||||
|
|
||||||
### 3.2 Vom Tool zur Platform
|
|
||||||
Domain-Module mit klaren Schnittstellen statt einem 3700-Zeilen-Monolith. Optionaler Plugin-Hook fuer Post-Download-Actions (z. B. Auto-Upload-Skript, Custom-Rename, Notify-Discord). Spaeter wartbar, leichter testbar.
|
|
||||||
|
|
||||||
### 3.3 Vom Hobby zum stabilen Daily-Driver
|
|
||||||
SQLite + Crash-Recovery + Chunk-Hashing + echte Unit-Tests. Wer 500 VODs queued, soll nicht in JSON-Lock-Konflikte laufen oder beim Crash 4 Stunden Download verlieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Feature-Pillars
|
|
||||||
|
|
||||||
### Pillar 1 — Live Stream Recording (P0)
|
|
||||||
|
|
||||||
**Was:** App kann laufende Streams in Echtzeit mitschneiden, nicht nur VODs post-hoc downloaden.
|
|
||||||
|
|
||||||
**Warum:** Sub-only Streams haben oft keine VOD-Retention oder Sub-only-VODs werden nach 60 Tagen geloescht. Recording ist der einzige Weg, sie dauerhaft zu sichern.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- Streamlink-basierte Aufnahme aktiver Live-Streams (Quality-Selector wie bei VODs)
|
|
||||||
- Auto-Start: "wenn Streamer X live geht, Recording starten" (Polling-Intervall pro Streamer konfigurierbar, default 60s)
|
|
||||||
- Auto-Stop bei Stream-Ende + Final-Mux mit ffmpeg
|
|
||||||
- Resume bei Mid-Stream-Disconnect (neuer HLS-Segment-Pointer, append-Mode)
|
|
||||||
- Sub-only Streams: nutzt OAuth-Token aus Pillar 2
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- 4-Stunden-Test-Stream wird ohne Datenverlust aufgenommen
|
|
||||||
- Disconnect-Wiederherstellung in < 15s
|
|
||||||
- File-Output ist mux-fertig MP4 (kein .ts-Reste, kein orphan-Index)
|
|
||||||
- UI zeigt "LIVE REC" Badge + Recording-Dauer im Streamer-Eintrag
|
|
||||||
|
|
||||||
### Pillar 2 — Twitch-OAuth (Device Code Flow) (P0)
|
|
||||||
|
|
||||||
**Was:** Native Anmeldung mit Twitch-Account, OAuth-Token wird verschluesselt in OS-Keychain (Win Credential Manager) gespeichert.
|
|
||||||
|
|
||||||
**Warum:** Sub-only Inhalte, hoehere Rate-Limits (Helix mit User-Token > App-Token in vielen Endpoints), private Follow-Liste fuer Auto-Discovery, korrekte Mature-Content-Gating-Behandlung.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- Device Code Flow (kein Browser-Embed, kein localhost-Callback noetig — sauberer fuer Desktop)
|
|
||||||
- Multi-Account: bis zu 3 parallele Accounts (Haupt, Alt, Auto-Sub)
|
|
||||||
- Token-Refresh automatisch, Fail-Soft auf App-Token
|
|
||||||
- Settings-Tab "Accounts" mit Liste, Login/Logout, "Default fuer Recording"
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- Login dauert < 60s vom Klick bis Token in Keychain
|
|
||||||
- Sub-only Test-Channel laesst sich aufnehmen mit eingeloggtem Sub-Account
|
|
||||||
- Logout entfernt Token sauber aus Keychain (audit per Tool: kein orphan-Entry)
|
|
||||||
|
|
||||||
### Pillar 3 — SQLite-Migration (P0, Breaking)
|
|
||||||
|
|
||||||
**Was:** Alle App-Daten (Queue, Stats, Archiv-Index, Streamer-Liste, Recording-History) wandern von JSON-Files nach `app.db` (better-sqlite3 oder besser-im-Bundle-Alternative).
|
|
||||||
|
|
||||||
**Warum:** JSON wird ab ~1000 VODs spuerbar lahm (Full-File-Rewrite bei jedem Save). SQLite gibt indexed lookups, atomic transactions, kein Lock-Konflikt bei parallelen Writes, einfache Migrationen.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- Schema v5 mit Tabellen: `queue`, `archive`, `streamers`, `recordings`, `stats_daily`, `clip_index`, `config_blob`
|
|
||||||
- Migrator-Script: liest alle `C:\ProgramData\Twitch_VOD_Manager\*.json` und schreibt in `app.db`
|
|
||||||
- Backup vor Migration: `.json` → `.json.v4-backup`
|
|
||||||
- Read-Path bleibt API-kompatibel fuer Renderer (IPC-Surface unveraendert)
|
|
||||||
- WAL-Mode + busy-timeout 5000ms
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- Migrator laeuft idempotent (zweimal aufrufen = gleicher Endzustand, kein Daten-Duplikat)
|
|
||||||
- Failure-Rollback: bei Migrator-Crash bleibt App auf JSON, kein Daten-Verlust
|
|
||||||
- Query "alle VODs eines Streamers" < 50ms bei 10k Archiv-Eintraegen (gemessen)
|
|
||||||
|
|
||||||
### Pillar 4 — Architektur-Refactor: main.ts Split (P1)
|
|
||||||
|
|
||||||
**Was:** `main.ts` (3700 LoC) wird in Domain-Module zerlegt.
|
|
||||||
|
|
||||||
**Ziel-Struktur:**
|
|
||||||
```
|
|
||||||
src/main/
|
|
||||||
index.ts Entry, app lifecycle, BrowserWindow
|
|
||||||
domain/
|
|
||||||
twitch-api.ts Helix + GraphQL Calls, Rate-Limit-Handling
|
|
||||||
downloader.ts Streamlink/ffmpeg Orchestration (VOD + Live)
|
|
||||||
recorder.ts Live-Stream-Recording (NEU)
|
|
||||||
queue.ts Queue-State-Machine, persistence-call
|
|
||||||
config.ts Settings-Schema, defaults, validation
|
|
||||||
auth.ts OAuth, Token-Storage, Keychain (NEU)
|
|
||||||
cache.ts In-Memory-TTL-Caches
|
|
||||||
updater.ts electron-updater Wrapper
|
|
||||||
infra/
|
|
||||||
db.ts SQLite-Wrapper, prepared statements
|
|
||||||
logger.ts Strukturiertes Logging (JSON-Lines)
|
|
||||||
ipc.ts Channel-Registry, type-safe Handler
|
|
||||||
ipc-bridge.ts exportiert nur die API-Surface fuer preload.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Constraint:** Renderer-IPC-Surface bleibt rueckwaertskompatibel — kein Renderer-Rewrite noetig.
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- Kein Modul groesser als 800 LoC
|
|
||||||
- Zyklische Imports = 0 (`madge --circular` oder aequivalent)
|
|
||||||
- E2E-Tests gruen ohne Anpassung
|
|
||||||
|
|
||||||
### Pillar 5 — UI Power-Features (P1)
|
|
||||||
|
|
||||||
**Was:** Renderer-seitige Spruenge, die User direkt sehen.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- **Virtualized List** fuer Queue/Archive (react-window-aequivalent vanilla, oder `@tanstack/virtual-core` vanilla-Adapter) — 10k Eintraege ohne Lag
|
|
||||||
- **Mini-Player Panel**: HLS-Stream in App-eigenem `<video>` (hls.js), kollabierbar, fuer Recording-Live-Preview + VOD-Vorschau
|
|
||||||
- **Theme-Engine**: drei eingebaute Themes (Default, Dark High-Contrast, Light) + Custom-CSS-Slot mit Live-Reload
|
|
||||||
- **Template-Preview live**: Title-Template aendert sich, Beispiel-Output rendert in Echtzeit unter dem Input
|
|
||||||
- **Drag-Reorder** in Queue (HTML5-DnD, persistiert Position-Index in DB)
|
|
||||||
- **Command-Palette** (Ctrl+K): Schnellaktionen, Streamer-Suche, Settings-Jump
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- Scroll-Performance bei 5000 Eintraegen: 60fps (Chrome DevTools Performance-Trace)
|
|
||||||
- Theme-Switch ohne Reload
|
|
||||||
- Command-Palette < 100ms First-Result-Latency
|
|
||||||
|
|
||||||
### Pillar 6 — Smart-Resume & Integrity (P1)
|
|
||||||
|
|
||||||
**Was:** Downloads/Recordings ueberleben Crash, Disconnect, Reboot.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- Jeder HLS-Segment-Chunk bekommt sha1, geschrieben in DB-Tabelle `chunk_index`
|
|
||||||
- Bei Resume: vergleicht local-chunk-hash mit Manifest, ueberspringt valide Chunks, faengt am ersten Mismatch an
|
|
||||||
- Crash-Recovery beim Startup: scannt `recordings`-Tabelle nach `state=running` und bietet Resume-Dialog an
|
|
||||||
- Final-Integrity-Check: ffprobe-Aufruf auf fertige Datei, schreibt `verified=1` in DB
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- App-Kill mid-download → Neustart → Resume genau ab Abbruch-Chunk (manueller Test)
|
|
||||||
- Korrupte Chunk-Datei wird erkannt und neu geladen (per Mutation-Test: zufaelliges Byte flippen)
|
|
||||||
|
|
||||||
### Pillar 7 — Scheduled Recording & Auto-Discovery (P2)
|
|
||||||
|
|
||||||
**Was:** Automatisierungs-Layer ueber Pillar 1+2.
|
|
||||||
|
|
||||||
**Scope:**
|
|
||||||
- Pro Streamer: "Auto-Record on Live: ja/nein" + Quality-Preset
|
|
||||||
- Zeit-Plan: "nur zwischen 18-02 Uhr aufnehmen" (vermeidet Reruns/Marathons)
|
|
||||||
- **Top-Clip-Crawler**: pro Streamer alle X Tage die N besten Clips (Helix `/clips`) downloaden
|
|
||||||
- **Follow-Sync**: importiert eingeloggte Follow-Liste als Streamer-Eintraege (opt-in)
|
|
||||||
|
|
||||||
**Acceptance:**
|
|
||||||
- 3-Streamer-Auto-Record-Test ueber 24h ohne Eingriff, kein Doppelaufnahme bei Stream-Reconnect
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Breaking Changes
|
|
||||||
|
|
||||||
| # | Was bricht | Migration | User-Impact |
|
|
||||||
|---|---|---|---|
|
|
||||||
| BC-1 | Config-Speicherort | Migrator on first 5.0 start, `.v4-backup` der JSONs bleibt liegen | Einmalig 5-30s Migrations-Dialog |
|
|
||||||
| BC-2 | Min-Windows: Win10 1809 (Oktober 2018) | Installer-Check, blockiert Setup auf alten OS | Sehr wenige User betroffen |
|
|
||||||
| BC-3 | Node-API-Version: Plugins die auf Node 18 N-API gebaut sind muessen rebuild (relevant fuer better-sqlite3, evtl. fsevents) | Pre-built Binaries im Bundle | Transparent fuer User |
|
|
||||||
| BC-4 | Streamer-Eintrag-Format aendert sich (neue Felder: `oauth_account_id`, `auto_record`, `record_quality`) | Auto-Default bei Migration | Transparent |
|
|
||||||
| BC-5 | Auto-Updater Channel: alte 4.x-Clients sehen 5.0 nicht im stable-Channel, sondern in `next` (opt-in) | User muss bewusst Channel wechseln; nach 4 Wochen Beta: 5.0 → stable | Bewusste User-Aktion |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Tech-Stack Upgrades
|
|
||||||
|
|
||||||
| Komponente | 4.6 | 5.0 | Grund |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Electron | 28.x | 32.x (oder LTS aktuell) | Security-Patches, Chromium-Upgrade |
|
|
||||||
| Node | 18 | 22 | LTS, V8 perf |
|
|
||||||
| TypeScript | 5.3 | 5.6+ | satisfies / decorators stable |
|
|
||||||
| ESLint | 10 | aktueller flat-config | passt |
|
|
||||||
| Neu: better-sqlite3 | — | latest | SQLite-Persistenz |
|
|
||||||
| Neu: hls.js | — | latest | Mini-Player |
|
|
||||||
| Neu: keytar (oder Electron safeStorage) | — | latest | OAuth-Token-Storage |
|
|
||||||
| Playwright | 1.59 | aktuell | E2E |
|
|
||||||
| Neu: vitest | — | latest | Unit-Tests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Quality & DevX
|
|
||||||
|
|
||||||
- **Unit-Tests (Vitest):** mind. 60% Coverage in `src/main/domain/*` zum 5.0.0-GA. Reine Logik-Tests, kein Electron-Boot.
|
|
||||||
- **E2E (Playwright):** bestehende Suite plus drei neue Szenarien (Login, Live-Record-Start, SQLite-Migration).
|
|
||||||
- **CI:** weiterhin lokal (Solo-Projekt), aber `npm run check:all` = build + lint + vitest + e2e in einem Skript.
|
|
||||||
- **Logging:** strukturiert JSONL in `logs/app-YYYY-MM-DD.jsonl`, 14-Tage-Retention, mit Level (debug/info/warn/error) und Trace-IDs pro Download/Recording.
|
|
||||||
- **Crash-Reports:** opt-in Sentry (oder einfacher: lokaler Crash-Log-Export + Copy-to-Clipboard-Button). Default: aus, kein Telemetrie-Surprise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Release-Phasen
|
|
||||||
|
|
||||||
| Phase | Version | Was rein | Wann (relativ) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Alpha-1 | 5.0.0-alpha.1 | SQLite-Migrator + Architektur-Split | T+0 |
|
|
||||||
| Alpha-2 | 5.0.0-alpha.2 | OAuth + Live-Recording (Baseline) | T+2 Wo |
|
|
||||||
| Beta-1 | 5.0.0-beta.1 | UI Power-Features, Smart-Resume | T+4 Wo |
|
|
||||||
| Beta-2 | 5.0.0-beta.2 | Scheduled/Auto-Discovery, Bug-Fixes | T+6 Wo |
|
|
||||||
| RC | 5.0.0-rc.1 | nur noch Bugfixes, kein Feature-Add | T+8 Wo |
|
|
||||||
| GA | 5.0.0 | Public, `next`-Channel → `stable` | T+10 Wo |
|
|
||||||
|
|
||||||
Konkret Daten setze ich nicht — die haengen davon ab, wie eng du das fahren willst.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Out-of-Scope (bewusst NICHT in 5.0.0)
|
|
||||||
|
|
||||||
- React/Vue/Svelte-Rewrite des Renderers (Risiko zu gross, Vanilla bleibt)
|
|
||||||
- macOS/Linux-Builds (Electron-Builder kann, aber kein Test-Setup → erst 5.1+)
|
|
||||||
- Cloud-Sync (Queue/Settings ueber Geraete) — eigener Major-Bump waert
|
|
||||||
- Chat-Recording / Chat-Renderer (TwitchDownloaderCLI Integration) — verlockend, aber Scope-Creep → 5.1
|
|
||||||
- Mobile-Companion-App
|
|
||||||
- Plugin-API fuer Dritte (Hooks koennen wir intern bauen, aber kein public-API-Versprechen in 5.0)
|
|
||||||
- AI-Highlight-Detection (lokales LLM) — Forschung, kein Produkt-Feature 2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Definition of Done (5.0.0 GA)
|
|
||||||
|
|
||||||
Alle Punkte erfuellt:
|
|
||||||
|
|
||||||
1. [ ] Pillars 1-6 implementiert und in E2E-Tests abgedeckt (Pillar 7 darf teilweise sein, mind. Auto-Record-on-Live)
|
|
||||||
2. [ ] SQLite-Migrator getestet auf realer 4.6-Installation mit 5k+ Archive-Eintraegen, idempotent, mit Rollback-Path
|
|
||||||
3. [ ] OAuth-Token landet in Keychain, audit zeigt sauberen Logout
|
|
||||||
4. [ ] Vitest-Coverage `src/main/domain` >= 60%
|
|
||||||
5. [ ] Playwright-Suite gruen, inkl. drei neue Szenarien
|
|
||||||
6. [ ] Mini-Player + Theme-Engine + Virtual-List manuell verifiziert (Demo-Video)
|
|
||||||
7. [ ] Auto-Updater: bestehende 4.6-Installation kriegt 5.0 ueber `next`-Channel, Migration laeuft, App startet
|
|
||||||
8. [ ] README + CLAUDE.md aktualisiert (neue Architektur, neue Commands, neue Settings)
|
|
||||||
9. [ ] CHANGELOG-Eintrag mit allen Breaking Changes prominent oben
|
|
||||||
10. [ ] Frische Installation aus `Twitch-VOD-Manager-Setup-5.0.0.exe` auf clean Win11-VM: erstes Recording lauft binnen 5 Minuten User-Klicks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Naechste Schritte (wenn du dieses Goal annimmst)
|
|
||||||
|
|
||||||
1. Goal mit eigenem Realitaetscheck lesen — was streichen, was zuerst
|
|
||||||
2. Pillar 3 (SQLite) zuerst, weil es Voraussetzung fuer 1, 6, 7 ist
|
|
||||||
3. Branch `feat/v5-foundation` aufmachen, dort Architektur-Split + SQLite parallel
|
|
||||||
4. Nach Alpha-1 entscheiden, ob OAuth vor UI-Refactor oder umgekehrt
|
|
||||||
5. Eigenes `tasks/v5.0.0-checklist.md` aus Pillar-Acceptance-Criteria generieren, abhakbar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Autor:** Claude (Opus 4.7, 1M context) — 2026-05-11
|
|
||||||
**Review-Status:** unreviewed, Erstentwurf
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,416 +0,0 @@
|
|||||||
# Plan 02: SQLite-Foundation (Pillar 3)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** SQLite-Infrastruktur ins Projekt einbauen + Migrator, der bestehende JSON-Daten aus `C:\ProgramData\Twitch_VOD_Manager\*.json` nach `app.db` ueberfuehrt. JSON-Pfade bleiben Source-of-Truth — SQLite laeuft als Shadow-Schreibziel. Cutover (SQLite wird Master) erfolgt in einem spaeteren Plan.
|
|
||||||
|
|
||||||
**Architecture:** `better-sqlite3` (sync embedded). Schema-First via Inline-Konstante (`schema-v5.ts`) damit `tsc` keine SQL-Files kopieren muss. WAL-Mode, busy_timeout 5000ms, foreign_keys ON. Migrator ist idempotent (Marker in `migrations_applied` Tabelle). Bei Crash bleibt JSON-Pfad funktionsfaehig.
|
|
||||||
|
|
||||||
**Tech Stack:** better-sqlite3 + @types/better-sqlite3, vitest mit echten temp-DB-Files.
|
|
||||||
|
|
||||||
**Verifikation pro Task:** `npm run build` + `npm run test:unit` gruen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope fuer Plan 02
|
|
||||||
|
|
||||||
- Reads aus SQLite umstellen (Renderer/IPC bleibt auf JSON)
|
|
||||||
- electron-rebuild fuer better-sqlite3 (Workaround in Plan 02, voller Pipeline-Fix in spaeterem Plan)
|
|
||||||
- Schema-Migrationen v5 → v6
|
|
||||||
- Sub-only-Tabellen (kommen mit OAuth in Plan 03)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Neu:**
|
|
||||||
- `src/main/infra/schema-v5.ts` — Schema als TypeScript-String-Konstante
|
|
||||||
- `src/main/infra/db.ts` — better-sqlite3 Wrapper
|
|
||||||
- `src/main/infra/db.test.ts`
|
|
||||||
- `src/main/domain/migrator.ts` — JSON to SQLite
|
|
||||||
- `src/main/domain/migrator.test.ts`
|
|
||||||
|
|
||||||
**Modifiziert:**
|
|
||||||
- `package.json`
|
|
||||||
- `src/main.ts` — Migrator beim App-Start (fail-soft)
|
|
||||||
- `CLAUDE.md`
|
|
||||||
- `tasks/v5.0.0-roadmap.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### Task 1: better-sqlite3 installieren
|
|
||||||
|
|
||||||
- [ ] `npm install --save better-sqlite3`
|
|
||||||
- [ ] `npm install --save-dev @types/better-sqlite3`
|
|
||||||
- [ ] Verify: `node -e "const D=require('better-sqlite3'); const d=new D(':memory:'); d.prepare('SELECT 1 AS x').get(); d.close(); console.log('ok')"` → "ok"
|
|
||||||
- [ ] `npm run build` Exit 0
|
|
||||||
- [ ] Commit: `build: add better-sqlite3 + @types`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: schema-v5.ts (Schema als Inline-String)
|
|
||||||
|
|
||||||
- [ ] Datei `src/main/infra/schema-v5.ts` mit `export const SCHEMA_V5_SQL = \`...sql...\`;`
|
|
||||||
- [ ] SQL-Inhalt umfasst Tabellen: `schema_meta`, `config_kv`, `queue_items`, `downloaded_vods`, `streamers`, `archive_files`, `migrations_applied`. Plus Indices.
|
|
||||||
- [ ] Alle Tabellen `IF NOT EXISTS`. `INSERT OR IGNORE INTO schema_meta(key, value) VALUES ('schema_version', '5')`.
|
|
||||||
- [ ] Commit: `feat(db): schema v5 inline (7 tables + indices)`
|
|
||||||
|
|
||||||
**Schema-Tabellen-Spec (Spalten):**
|
|
||||||
|
|
||||||
```
|
|
||||||
schema_meta(key PK, value)
|
|
||||||
config_kv(key PK, value, updated_at)
|
|
||||||
queue_items(id PK, streamer_login, vod_id, clip_id, title, output_path,
|
|
||||||
status, progress_pct, error_message, created_at, updated_at,
|
|
||||||
completed_at, payload_json)
|
|
||||||
idx_queue_status(status), idx_queue_streamer(streamer_login),
|
|
||||||
idx_queue_created(created_at)
|
|
||||||
downloaded_vods(vod_id PK, downloaded_at default now)
|
|
||||||
streamers(login PK, auto_record, auto_vod_download, added_at default now)
|
|
||||||
idx_streamers_autorec(auto_record), idx_streamers_autodl(auto_vod_download)
|
|
||||||
archive_files(path PK, streamer_login, size_bytes, duration_seconds,
|
|
||||||
created_at, verified default 0)
|
|
||||||
idx_archive_streamer(streamer_login)
|
|
||||||
migrations_applied(name PK, applied_at default now, payload)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: db.ts Wrapper + 5 Tests
|
|
||||||
|
|
||||||
**Public DbHandle Interface:**
|
|
||||||
- `run(sql, params?)` — single-statement execute
|
|
||||||
- `get<T>(sql, params?)` — first row or undefined
|
|
||||||
- `all<T>(sql, params?)` — alle Rows
|
|
||||||
- `transaction<R>(fn)` — atomares Bracket
|
|
||||||
- `runBatch(sql)` — multi-statement (intern via `_db.exec(sql)`, fuer Schema-Bootstrap)
|
|
||||||
- `close()`
|
|
||||||
- `raw` getter
|
|
||||||
|
|
||||||
**openDatabase(filePath: string): DbHandle**
|
|
||||||
- Erstellt File (better-sqlite3 default-Verhalten)
|
|
||||||
- `pragma journal_mode = WAL`, `pragma busy_timeout = 5000`, `pragma foreign_keys = ON`
|
|
||||||
- Fuehrt Schema-Bootstrap aus: `runBatch(SCHEMA_V5_SQL)`
|
|
||||||
- Returnt das Handle-Objekt
|
|
||||||
|
|
||||||
**Tests** (`src/main/infra/db.test.ts`):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from './db';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let db: DbHandle | null = null;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-test-'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
try { db?.close(); } catch { /* ignore */ }
|
|
||||||
db = null;
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('openDatabase', () => {
|
|
||||||
test('creates a new file', () => {
|
|
||||||
const target = path.join(tmpDir, 'a.db');
|
|
||||||
db = openDatabase(target);
|
|
||||||
expect(fs.existsSync(target)).toBe(true);
|
|
||||||
expect(typeof db.run).toBe('function');
|
|
||||||
expect(typeof db.get).toBe('function');
|
|
||||||
expect(typeof db.all).toBe('function');
|
|
||||||
expect(typeof db.close).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('schema_meta row exists with schema_version=5', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'b.db'));
|
|
||||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
|
||||||
expect(row?.value).toBe('5');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('WAL mode active', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'c.db'));
|
|
||||||
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
|
|
||||||
expect(row?.journal_mode).toBe('wal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('idempotent open: existing file keeps schema_version=5', () => {
|
|
||||||
const target = path.join(tmpDir, 'd.db');
|
|
||||||
db = openDatabase(target);
|
|
||||||
db.close();
|
|
||||||
db = openDatabase(target);
|
|
||||||
const row = db.get<{ value: string }>('SELECT value FROM schema_meta WHERE key = ?', ['schema_version']);
|
|
||||||
expect(row?.value).toBe('5');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('run + get + all roundtrip', () => {
|
|
||||||
db = openDatabase(path.join(tmpDir, 'e.db'));
|
|
||||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['1234']);
|
|
||||||
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', ['5678']);
|
|
||||||
const one = db.get<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods WHERE vod_id = ?', ['1234']);
|
|
||||||
expect(one?.vod_id).toBe('1234');
|
|
||||||
const all = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
|
||||||
expect(all.map(r => r.vod_id)).toEqual(['1234', '5678']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Tests schreiben (sollen fehlschlagen — Modul fehlt)
|
|
||||||
- [ ] `npm run test:unit` zeigt 5 neue failures
|
|
||||||
- [ ] db.ts implementieren (better-sqlite3 wrappen, Interface oben einhalten)
|
|
||||||
- [ ] Tests passen — `npm run test:unit` Exit 0
|
|
||||||
- [ ] Build gruen
|
|
||||||
- [ ] Commit: `feat(db): better-sqlite3 wrapper + schema bootstrap (5 tests)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Migrator + 8 Tests
|
|
||||||
|
|
||||||
**MigratorOptions:**
|
|
||||||
- `db: DbHandle`
|
|
||||||
- `appDataDir: string`
|
|
||||||
|
|
||||||
**MigrationResult:**
|
|
||||||
- `alreadyApplied: boolean`
|
|
||||||
- `configMigrated: boolean`
|
|
||||||
- `queueMigrated: boolean`
|
|
||||||
- `downloadedVodsCount: number`
|
|
||||||
- `streamersCount: number`
|
|
||||||
- `errors: MigrationError[]` (`{ source, message }`)
|
|
||||||
|
|
||||||
**migrateJsonToSqlite(opts):**
|
|
||||||
1. Check `migrations_applied` fuer `name='v4-to-v5-jsons'` → wenn vorhanden → return `alreadyApplied: true`
|
|
||||||
2. Lies `appDataDir/config.json` falls vorhanden:
|
|
||||||
- Parse, in `db.transaction(() => { ... })`:
|
|
||||||
- Whitelisted Keys (siehe Liste unten) → `INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))` mit `value = JSON.stringify(config[key])`
|
|
||||||
- `downloaded_vod_ids` (Array of String) → `INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)`
|
|
||||||
- `auto_record_streamers` → normalizeLogin + `INSERT...ON CONFLICT DO UPDATE auto_record=1`
|
|
||||||
- `auto_vod_download_streamers` → normalizeLogin + `INSERT...ON CONFLICT DO UPDATE auto_vod_download=1`
|
|
||||||
- Bei Erfolg: `.v4-backup` Copy schreiben (`fs.copyFileSync`)
|
|
||||||
- Bei Fehler: error pushen, continue
|
|
||||||
3. Lies `appDataDir/download_queue.json` falls vorhanden:
|
|
||||||
- Parse, fuer jedes Item (mit `id`-Feld): `INSERT OR REPLACE INTO queue_items(...) VALUES (...)`
|
|
||||||
- Fields: id, streamer→normalizeLogin, vod_id, clip_id, title, output_path, status (default 'pending'), progress_pct, error_message, created_at, updated_at, completed_at, payload_json=JSON.stringify(item)
|
|
||||||
- Bei Erfolg: `.v4-backup` Copy
|
|
||||||
4. Schreibe Marker: `INSERT INTO migrations_applied(name, payload) VALUES ('v4-to-v5-jsons', JSON.stringify({summary}))`
|
|
||||||
|
|
||||||
**Whitelisted Config-Keys fuer config_kv (verbatim):**
|
|
||||||
|
|
||||||
```
|
|
||||||
language, performance_mode, metadata_cache_minutes, streamlink_quality,
|
|
||||||
streamlink_disable_ads, download_chat_replay, capture_live_chat,
|
|
||||||
discord_webhook_url, discord_notify_live_start, discord_notify_live_end,
|
|
||||||
discord_notify_vod_complete, discord_notify_vod_auto_queued,
|
|
||||||
auto_cleanup_enabled, auto_cleanup_days, auto_cleanup_target,
|
|
||||||
auto_cleanup_action, log_stream_events, auto_vod_download_poll_minutes,
|
|
||||||
auto_vod_max_age_hours, auto_resume_live_recording,
|
|
||||||
auto_merge_resumed_parts, delete_parts_after_merge,
|
|
||||||
auto_record_poll_seconds, filename_template_vod, filename_template_parts,
|
|
||||||
filename_template_clip, smart_queue_scheduler, prevent_duplicate_downloads,
|
|
||||||
persist_queue_on_restart, auto_resume_queue_on_startup,
|
|
||||||
notify_on_each_completion
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests (verbatim, src/main/domain/migrator.test.ts):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { openDatabase, type DbHandle } from '../infra/db';
|
|
||||||
import { migrateJsonToSqlite } from './migrator';
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
let appDataDir: string;
|
|
||||||
let db: DbHandle;
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrator-'));
|
|
||||||
appDataDir = path.join(tmpDir, 'appdata');
|
|
||||||
fs.mkdirSync(appDataDir, { recursive: true });
|
|
||||||
db = openDatabase(path.join(tmpDir, 'app.db'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
db.close();
|
|
||||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
function writeJson(name: string, payload: unknown): string {
|
|
||||||
const target = path.join(appDataDir, name);
|
|
||||||
fs.writeFileSync(target, JSON.stringify(payload, null, 2), 'utf-8');
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('migrateJsonToSqlite', () => {
|
|
||||||
test('no JSON files: writes migrations_applied marker', () => {
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.configMigrated).toBe(false);
|
|
||||||
expect(result.queueMigrated).toBe(false);
|
|
||||||
expect(result.downloadedVodsCount).toBe(0);
|
|
||||||
expect(result.streamersCount).toBe(0);
|
|
||||||
|
|
||||||
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']);
|
|
||||||
expect(marker?.name).toBe('v4-to-v5-jsons');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates config.json keys into config_kv', () => {
|
|
||||||
writeJson('config.json', {
|
|
||||||
language: 'de',
|
|
||||||
performance_mode: 'speed',
|
|
||||||
metadata_cache_minutes: 30,
|
|
||||||
downloaded_vod_ids: ['1', '2', '3'],
|
|
||||||
auto_record_streamers: ['foo', 'bar'],
|
|
||||||
});
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.configMigrated).toBe(true);
|
|
||||||
|
|
||||||
const lang = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
|
|
||||||
expect(JSON.parse(lang!.value)).toBe('de');
|
|
||||||
|
|
||||||
const perf = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['performance_mode']);
|
|
||||||
expect(JSON.parse(perf!.value)).toBe('speed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates downloaded_vod_ids', () => {
|
|
||||||
writeJson('config.json', { downloaded_vod_ids: ['100', '200', '300'] });
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.downloadedVodsCount).toBe(3);
|
|
||||||
const rows = db.all<{ vod_id: string }>('SELECT vod_id FROM downloaded_vods ORDER BY vod_id');
|
|
||||||
expect(rows.map(r => r.vod_id)).toEqual(['100', '200', '300']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates streamers from both lists', () => {
|
|
||||||
writeJson('config.json', {
|
|
||||||
auto_record_streamers: ['Alice', '@bob'],
|
|
||||||
auto_vod_download_streamers: ['bob', 'carol'],
|
|
||||||
});
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.streamersCount).toBeGreaterThanOrEqual(3);
|
|
||||||
|
|
||||||
const alice = db.get<{ login: string; auto_record: number }>('SELECT login, auto_record FROM streamers WHERE login = ?', ['alice']);
|
|
||||||
expect(alice?.auto_record).toBe(1);
|
|
||||||
|
|
||||||
const bob = db.get<{ login: string; auto_record: number; auto_vod_download: number }>('SELECT login, auto_record, auto_vod_download FROM streamers WHERE login = ?', ['bob']);
|
|
||||||
expect(bob?.auto_record).toBe(1);
|
|
||||||
expect(bob?.auto_vod_download).toBe(1);
|
|
||||||
|
|
||||||
const carol = db.get<{ login: string; auto_vod_download: number }>('SELECT login, auto_vod_download FROM streamers WHERE login = ?', ['carol']);
|
|
||||||
expect(carol?.auto_vod_download).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('migrates download_queue.json items', () => {
|
|
||||||
writeJson('download_queue.json', [
|
|
||||||
{ id: 'q1', status: 'pending', streamer: 'foo', vod_id: 'v1', created_at: 1000, updated_at: 1000 },
|
|
||||||
{ id: 'q2', status: 'completed', streamer: 'bar', vod_id: 'v2', created_at: 2000, updated_at: 3000, completed_at: 3000 },
|
|
||||||
]);
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.queueMigrated).toBe(true);
|
|
||||||
|
|
||||||
const all = db.all<{ id: string; status: string }>('SELECT id, status FROM queue_items ORDER BY id');
|
|
||||||
expect(all).toHaveLength(2);
|
|
||||||
expect(all[0].status).toBe('pending');
|
|
||||||
expect(all[1].status).toBe('completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('idempotent second run', () => {
|
|
||||||
writeJson('config.json', { downloaded_vod_ids: ['1', '2'] });
|
|
||||||
migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
const result2 = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result2.alreadyApplied).toBe(true);
|
|
||||||
const count = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM downloaded_vods');
|
|
||||||
expect(count?.c).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('writes .v4-backup of source JSONs', () => {
|
|
||||||
const configPath = writeJson('config.json', { language: 'en' });
|
|
||||||
migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(fs.existsSync(configPath + '.v4-backup')).toBe(true);
|
|
||||||
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('malformed JSON is logged + skipped', () => {
|
|
||||||
fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8');
|
|
||||||
const result = migrateJsonToSqlite({ db, appDataDir });
|
|
||||||
expect(result.configMigrated).toBe(false);
|
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
|
||||||
expect(result.errors[0].source).toBe('config.json');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Tests schreiben (failing — Modul fehlt)
|
|
||||||
- [ ] Migrator implementieren (gemaess Interface + Step-Liste oben)
|
|
||||||
- [ ] Tests passen — `npm run test:unit` Exit 0
|
|
||||||
- [ ] Build gruen
|
|
||||||
- [ ] Commit: `feat(db): JSON to SQLite migrator (idempotent, fail-soft, 8 tests)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Migrator-Aufruf beim App-Start
|
|
||||||
|
|
||||||
**main.ts:** im `app.whenReady().then(() => { ... })` Block, vor dem ersten `createWindow()`-Aufruf, einen try/catch-Block mit:
|
|
||||||
- `require('./main/infra/db')` und `require('./main/domain/migrator')` (lazy, damit Native-Fehler kein App-Start verhindern)
|
|
||||||
- `openDatabase(path.join(APPDATA_DIR, 'app.db'))`
|
|
||||||
- `migrateJsonToSqlite({ db, appDataDir: APPDATA_DIR })`
|
|
||||||
- Result ins debug.log loggen (falls `debugLog` Funktion vorhanden)
|
|
||||||
- `db.close()` im `finally`
|
|
||||||
- Catch: fail-soft, nur ins debug.log oder console.error
|
|
||||||
|
|
||||||
- [ ] Code adden
|
|
||||||
- [ ] Build gruen
|
|
||||||
- [ ] `npm run test:e2e` Exit 0 (Playwright startet App, Migrator laeuft auf leerer AppData)
|
|
||||||
- [ ] Commit: `feat(db): wire migrator into app startup (fail-soft, lazy require)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Full Verification + Version Bump 5.0.0-alpha.1
|
|
||||||
|
|
||||||
- [ ] `npm run test:e2e:release` Exit 0 (build + unit (mit jetzt >=104 Tests) + update-logic + 3 Playwright-Stages)
|
|
||||||
- [ ] `npm version 5.0.0-alpha.1 --no-git-tag-version`
|
|
||||||
- [ ] `npm run build` Exit 0
|
|
||||||
- [ ] Commit: `release: 5.0.0-alpha.1 - SQLite migrator (shadow write, JSON stays master)`
|
|
||||||
- [ ] Tag: `git tag v5.0.0-alpha.1`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: CLAUDE.md + Roadmap Update
|
|
||||||
|
|
||||||
**CLAUDE.md Key Patterns / Persistence:**
|
|
||||||
> JSON files in `C:\ProgramData\Twitch_VOD_Manager\` bleiben Source-of-Truth. Seit v5.0.0-alpha.1 spiegelt der Migrator (`src/main/domain/migrator.ts`) Konfiguration, Queue, downloaded_vod_ids und Streamer-Listen einmalig in `app.db` (SQLite, better-sqlite3). Schema: `src/main/infra/schema-v5.ts`. Cutover (SQLite wird Master) erfolgt in einem spaeteren Plan.
|
|
||||||
|
|
||||||
**Roadmap:** Plan 02 Status → `DONE (v5.0.0-alpha.1)`, Plan 03 wird `NEXT`.
|
|
||||||
|
|
||||||
- [ ] CLAUDE.md aktualisieren
|
|
||||||
- [ ] Roadmap aktualisieren
|
|
||||||
- [ ] Commit: `docs: SQLite migrator pattern + roadmap status update`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- [ ] Tests echt (kein Skip / Stub)
|
|
||||||
- [ ] Idempotenz getestet
|
|
||||||
- [ ] Malformed-JSON getestet
|
|
||||||
- [ ] `.v4-backup` Copy getestet
|
|
||||||
- [ ] Fail-Soft Pfad in main.ts: App startet auch ohne native-build-success
|
|
||||||
- [ ] Spec-Coverage: Pillar 3 SQLite-Migrator + Schema v5 abgedeckt. Cutover bleibt fuer spaeter
|
|
||||||
|
|
||||||
## Done-Definition
|
|
||||||
|
|
||||||
1. `npm run test:unit` Exit 0 mit >= 13 neuen Tests (5 db + 8 migrator)
|
|
||||||
2. `npm run test:e2e:release` Exit 0
|
|
||||||
3. SQLite `app.db` wird beim ersten Start angelegt
|
|
||||||
4. Migrator idempotent (zweimaliger Run)
|
|
||||||
5. `.v4-backup` Files existieren
|
|
||||||
6. Version 5.0.0-alpha.1 getaggt
|
|
||||||
7. CLAUDE.md + Roadmap aktualisiert
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Handoff
|
|
||||||
|
|
||||||
Inline-Execution via `superpowers:executing-plans`. Naechster Plan (`tasks/v5.0.0-plan-03-oauth.md`) nach Done-Definition.
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
# Plan 03: OAuth Foundation (Pillar 2 — Storage-Layer)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans.
|
|
||||||
|
|
||||||
**Goal:** OAuth-Token-Speicher in SQLite + verschluesseltes Persistieren via Electron `safeStorage`. Keine eigentliche OAuth-Flow-Implementierung in Plan 03 — die kommt in einem Folgeplan (Plan 03b), nachdem die Flow-Entscheidung (Authorization Code mit PKCE via System-Browser + localhost-Redirect, oder Implicit via embedded BrowserWindow) bestaetigt ist.
|
|
||||||
|
|
||||||
**Architecture:** Schema-Extension via additives `CREATE TABLE IF NOT EXISTS`. Crypto-Layer abstrahiert hinter `SecureStorage` Interface — Electron-safeStorage in Production, In-Memory in Tests. Token-CRUD ueber `token-store.ts` modul. Twitch ist der erste/einzige Provider in Plan 03 — Interface ist provider-agnostisch fuer spaeter.
|
|
||||||
|
|
||||||
**Tech Stack:** Electron `safeStorage` (Win Credential Manager im Hintergrund), better-sqlite3, vitest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aenderung vs Goal.md
|
|
||||||
|
|
||||||
Goal sagt "Device Code Flow". Twitch unterstuetzt das nicht — nur Authorization Code + PKCE, Implicit, oder Client Credentials. Plan 03 macht den Storage-Layer flow-agnostisch fertig. Konkrete Flow-Wahl + Implementation = Plan 03b (separat).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope fuer Plan 03
|
|
||||||
|
|
||||||
- Eigentliche OAuth-Flow-Implementation (Browser-Window oder System-Browser + Loopback)
|
|
||||||
- Twitch Helix-Endpunkte mit Token (zB `/users/me`) — kommt mit Flow
|
|
||||||
- IPC-Handler `login/logout/whoami` — kommt mit Flow
|
|
||||||
- Renderer-UI fuer Multi-Account — kommt mit UI-Plan
|
|
||||||
- Sub-only-Stream-Aufnahme — kommt mit Live-Rec-Plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Neu:**
|
|
||||||
- `src/main/infra/secure-storage.ts` — `SecureStorage` interface + Electron-Impl + MemoryImpl
|
|
||||||
- `src/main/infra/secure-storage.test.ts`
|
|
||||||
- `src/main/domain/token-store.ts` — CRUD auf oauth_accounts
|
|
||||||
- `src/main/domain/token-store.test.ts`
|
|
||||||
|
|
||||||
**Modifiziert:**
|
|
||||||
- `src/main/infra/schema-v5.ts` — neue Tabelle `oauth_accounts`
|
|
||||||
- `CLAUDE.md`
|
|
||||||
- `tasks/v5.0.0-roadmap.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### Task 1: Schema-Extension oauth_accounts
|
|
||||||
|
|
||||||
In `schema-v5.ts` an das Ende der Tabellen-Deklarationen (vor `migrations_applied`) anhaengen:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
twitch_user_id TEXT,
|
|
||||||
login TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
encrypted_access_token TEXT NOT NULL,
|
|
||||||
encrypted_refresh_token TEXT,
|
|
||||||
expires_at INTEGER,
|
|
||||||
scopes_json TEXT,
|
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
UNIQUE(provider, twitch_user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_oauth_provider ON oauth_accounts(provider);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_oauth_default ON oauth_accounts(is_default);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Schema-Konstante erweitern
|
|
||||||
- [ ] db.test.ts: 1 Test adden — `oauth_accounts` Tabelle existiert nach openDatabase
|
|
||||||
- [ ] `npm run test:unit` gruen
|
|
||||||
- [ ] Commit: `feat(db): add oauth_accounts table to schema v5`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: secure-storage Modul + 4 Tests
|
|
||||||
|
|
||||||
**Public Interface:**
|
|
||||||
```typescript
|
|
||||||
export interface SecureStorage {
|
|
||||||
isEncryptionAvailable(): boolean;
|
|
||||||
encrypt(plaintext: string): string; // base64-encoded ciphertext
|
|
||||||
decrypt(ciphertext: string): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MemorySecureStorage implements SecureStorage { ... } // for tests
|
|
||||||
export function createElectronSecureStorage(): SecureStorage; // requires app.whenReady fired
|
|
||||||
```
|
|
||||||
|
|
||||||
**MemoryImpl:** verschluesselt nicht, gibt plaintext unveraendert zurueck. Liefert `isEncryptionAvailable() === false`. Markiert klar im Logger, dass kein Crypto greift — fuer Tests OK.
|
|
||||||
|
|
||||||
**ElectronImpl:** wrappt `electron.safeStorage`. `isEncryptionAvailable()` ruft `safeStorage.isEncryptionAvailable()`. `encrypt()` ruft `safeStorage.encryptString(plaintext)` → Buffer → base64-string. `decrypt()` inverse.
|
|
||||||
|
|
||||||
**Tests** (`secure-storage.test.ts`):
|
|
||||||
- MemoryImpl: `isEncryptionAvailable()` returns false
|
|
||||||
- MemoryImpl: `decrypt(encrypt('hello')) === 'hello'`
|
|
||||||
- MemoryImpl: roundtrip mit multi-byte (`'aeoeue-test'`)
|
|
||||||
- MemoryImpl: empty string roundtrip
|
|
||||||
- ElectronImpl: skip in vitest-env (Electron nicht verfuegbar), aber Existence-Check via `typeof createElectronSecureStorage === 'function'`
|
|
||||||
|
|
||||||
- [ ] Tests + Modul schreiben
|
|
||||||
- [ ] `npm run test:unit` gruen (+5 tests)
|
|
||||||
- [ ] Commit: `feat(auth): SecureStorage interface + Memory + Electron impls (4 tests)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: token-store Modul + 6 Tests
|
|
||||||
|
|
||||||
**Public Surface:**
|
|
||||||
```typescript
|
|
||||||
export interface TokenRecord {
|
|
||||||
id: number;
|
|
||||||
provider: string;
|
|
||||||
twitchUserId: string | null;
|
|
||||||
login: string | null;
|
|
||||||
displayName: string | null;
|
|
||||||
expiresAt: number | null;
|
|
||||||
scopes: string[];
|
|
||||||
isDefault: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenWriteInput {
|
|
||||||
provider: string;
|
|
||||||
twitchUserId?: string;
|
|
||||||
login?: string;
|
|
||||||
displayName?: string;
|
|
||||||
accessToken: string; // plaintext, wird verschluesselt geschrieben
|
|
||||||
refreshToken?: string;
|
|
||||||
expiresAt?: number;
|
|
||||||
scopes?: string[];
|
|
||||||
isDefault?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenStore {
|
|
||||||
upsert(input: TokenWriteInput): TokenRecord;
|
|
||||||
list(provider?: string): TokenRecord[];
|
|
||||||
getDefault(provider: string): TokenRecord | null;
|
|
||||||
setDefault(id: number): void;
|
|
||||||
getAccessToken(id: number): string; // entschluesselt
|
|
||||||
getRefreshToken(id: number): string | null;
|
|
||||||
delete(id: number): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests (mit MemorySecureStorage):**
|
|
||||||
1. `upsert` neuer Account → record returned mit id > 0
|
|
||||||
2. `upsert` same provider + twitch_user_id → updated, kein neuer record
|
|
||||||
3. `list()` liefert alle accounts
|
|
||||||
4. `list('twitch')` filtert nach provider
|
|
||||||
5. `getDefault('twitch')` liefert isDefault=1, oder null
|
|
||||||
6. `setDefault(id)` setzt is_default=1 fuer id, 0 fuer alle anderen mit gleichem provider
|
|
||||||
7. `getAccessToken` entschluesselt korrekt
|
|
||||||
8. `delete` entfernt record
|
|
||||||
|
|
||||||
(Mind. 6 Tests — gerne mehr.)
|
|
||||||
|
|
||||||
- [ ] Tests + Modul schreiben
|
|
||||||
- [ ] `npm run test:unit` gruen (+6 tests)
|
|
||||||
- [ ] Commit: `feat(auth): token-store CRUD on oauth_accounts (encrypted, 6 tests)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Full verification + Version Bump 5.0.0-alpha.2
|
|
||||||
|
|
||||||
- [ ] `npm run test:e2e:release` Exit 0 (unit jetzt >= 117)
|
|
||||||
- [ ] `npm version 5.0.0-alpha.2 --no-git-tag-version`
|
|
||||||
- [ ] `npm run build` Exit 0
|
|
||||||
- [ ] Commit: `release: 5.0.0-alpha.2 - OAuth foundation (storage layer)`
|
|
||||||
- [ ] Tag: `git tag v5.0.0-alpha.2`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Docs
|
|
||||||
|
|
||||||
CLAUDE.md unter Key Patterns adden:
|
|
||||||
> **Secrets**: OAuth-Token (Twitch + spaeter) landen verschluesselt via Electron `safeStorage` (Win Credential Manager) in `oauth_accounts` Tabelle der SQLite. Zugriff ueber `src/main/domain/token-store.ts`. Memory-Impl fuer Tests.
|
|
||||||
|
|
||||||
Roadmap: Plan 03 → DONE, Plan 03b "OAuth Flow Implementation" als neuer NEXT-Eintrag dazu.
|
|
||||||
|
|
||||||
- [ ] Updates + Commit: `docs: OAuth foundation pattern + roadmap`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Done-Definition Plan 03
|
|
||||||
|
|
||||||
1. `oauth_accounts` Tabelle in Schema
|
|
||||||
2. SecureStorage Interface + MemoryImpl + ElectronImpl
|
|
||||||
3. TokenStore CRUD getestet
|
|
||||||
4. `npm run test:e2e:release` Exit 0
|
|
||||||
5. >= 11 neue unit-tests (1 schema + 4 secure-storage + 6 token-store)
|
|
||||||
6. Version 5.0.0-alpha.2 getaggt
|
|
||||||
7. CLAUDE.md + Roadmap aktualisiert
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Handoff
|
|
||||||
|
|
||||||
Inline-Execution. Plan 03b (OAuth Flow Implementation) wird danach geschrieben.
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
# Plan 04: Smart-Resume Foundation (Pillar 6 — Storage-Layer)
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans.
|
|
||||||
|
|
||||||
**Goal:** Chunk-Index-Schema in SQLite + sha1-Hash-Helper-Modul. Vorbereitung fuer den eigentlichen Smart-Resume-Mechanismus, der in einem Folgeplan in den bestehenden Recorder integriert wird. Plan 04 ist rein additiv — kein bestehender Code wird modifiziert.
|
|
||||||
|
|
||||||
**Architecture:** Neue Tabelle `chunk_index` (additiv im Schema). Hash-Helpers als pure infra-Modul (sha1 von Buffer + Streaming-File-Hash). Store-Modul (CRUD) folgt dem Pattern von `token-store`.
|
|
||||||
|
|
||||||
**Tech Stack:** Node `crypto` (sha1), better-sqlite3, vitest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Integration in den bestehenden Recorder/Downloader (kommt in Plan 04b)
|
|
||||||
- Resume-Logik beim App-Start (Plan 04b)
|
|
||||||
- HLS-Manifest-Parser (vielleicht Plan 04b)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Neu:**
|
|
||||||
- `src/main/infra/chunk-hash.ts` — sha1-Helper (Buffer + File)
|
|
||||||
- `src/main/infra/chunk-hash.test.ts`
|
|
||||||
- `src/main/domain/chunk-index-store.ts` — CRUD auf chunk_index
|
|
||||||
- `src/main/domain/chunk-index-store.test.ts`
|
|
||||||
|
|
||||||
**Modifiziert:**
|
|
||||||
- `src/main/infra/schema-v5.ts` — neue Tabelle `chunk_index`
|
|
||||||
- `src/main/infra/db.test.ts` — 1 Test fuer neue Tabelle
|
|
||||||
- `CLAUDE.md`
|
|
||||||
- `tasks/v5.0.0-roadmap.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### Task 1: Schema-Extension chunk_index
|
|
||||||
|
|
||||||
In schema-v5.ts vor `migrations_applied`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS chunk_index (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
chunk_seq INTEGER NOT NULL,
|
|
||||||
sha1_hex TEXT NOT NULL,
|
|
||||||
bytes INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
UNIQUE(item_id, chunk_seq)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chunk_item ON chunk_index(item_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_chunk_sha1 ON chunk_index(sha1_hex);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Schema-Konstante erweitern
|
|
||||||
- [ ] db.test.ts: 1 neuer Test fuer chunk_index
|
|
||||||
- [ ] Commit: `feat(db): add chunk_index table (sha1 of recorded HLS segments)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: chunk-hash Modul
|
|
||||||
|
|
||||||
**API:**
|
|
||||||
```typescript
|
|
||||||
export function hashBuffer(b: Buffer): string; // sha1 hex
|
|
||||||
export function hashFile(filePath: string): Promise<string>; // streaming sha1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests:**
|
|
||||||
- known input → known sha1 (fixture: `'hello'` → `aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d`)
|
|
||||||
- empty buffer → `da39a3ee5e6b4b0d3255bfef95601890afd80709`
|
|
||||||
- File hash via tmp file matches buffer hash for same content
|
|
||||||
- Large file (1MB+) hashes successfully
|
|
||||||
|
|
||||||
- [ ] Tests + Modul
|
|
||||||
- [ ] Commit: `feat(resume): chunk-hash sha1 helpers (4 tests)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: chunk-index-store Modul
|
|
||||||
|
|
||||||
**API:**
|
|
||||||
```typescript
|
|
||||||
export interface ChunkRecord {
|
|
||||||
id: number;
|
|
||||||
itemId: string;
|
|
||||||
chunkSeq: number;
|
|
||||||
sha1Hex: string;
|
|
||||||
bytes: number;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChunkIndexStore {
|
|
||||||
record(itemId: string, chunkSeq: number, sha1Hex: string, bytes: number): ChunkRecord;
|
|
||||||
listForItem(itemId: string): ChunkRecord[];
|
|
||||||
countForItem(itemId: string): number;
|
|
||||||
lookupBySha1(sha1Hex: string): ChunkRecord[];
|
|
||||||
deleteForItem(itemId: string): number; // returns deleted-row count
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createChunkIndexStore(db: DbHandle): ChunkIndexStore;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tests (mit MemorySecureStorage NICHT noetig — kein encryption hier):**
|
|
||||||
1. record + listForItem
|
|
||||||
2. UNIQUE(item_id, chunk_seq) — zweimal gleicher (itemId, chunkSeq) → INSERT OR REPLACE (latest wins)
|
|
||||||
3. countForItem
|
|
||||||
4. lookupBySha1 (dedupe-Erkennung)
|
|
||||||
5. deleteForItem
|
|
||||||
6. Ordering — listForItem nach chunk_seq ASC
|
|
||||||
7. Bytes-Roundtrip
|
|
||||||
|
|
||||||
- [ ] Tests + Modul
|
|
||||||
- [ ] Commit: `feat(resume): chunk-index-store CRUD (7 tests)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Full Verification + Bump 5.0.0-alpha.3
|
|
||||||
|
|
||||||
- [ ] `npm run test:e2e:release` Exit 0 (unit jetzt >= 138)
|
|
||||||
- [ ] `npm version 5.0.0-alpha.3 --no-git-tag-version`
|
|
||||||
- [ ] Commit + Tag v5.0.0-alpha.3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Docs
|
|
||||||
|
|
||||||
CLAUDE.md "Smart-Resume" Notiz:
|
|
||||||
> Recorded HLS-Segments koennen mit sha1-Hash in `chunk_index` Tabelle protokolliert werden (`src/main/domain/chunk-index-store.ts`). Vorbereitung fuer Crash-Recovery + Integrity-Check; eigentliche Integration in den Recorder kommt in Folgeplan.
|
|
||||||
|
|
||||||
Roadmap: Plan 04 → DONE, Plan 04b "Resume Integration" als naechster Eintrag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Done-Definition
|
|
||||||
|
|
||||||
1. `chunk_index` Tabelle existiert nach openDatabase
|
|
||||||
2. hashBuffer + hashFile getestet (known-input + roundtrip)
|
|
||||||
3. ChunkIndexStore CRUD getestet
|
|
||||||
4. test:e2e:release Exit 0
|
|
||||||
5. >= 12 neue unit tests
|
|
||||||
6. v5.0.0-alpha.3 getaggt
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
# v5.0.0 Roadmap (Reality-Aligned)
|
|
||||||
|
|
||||||
**Baseline:** v4.6.155
|
|
||||||
**Spec:** `tasks/v5.0.0-goal.md`
|
|
||||||
**Last verified:** 2026-05-11
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reality Check vs. Goal
|
|
||||||
|
|
||||||
Beim Vergleich der echten Codebase (`src/main.ts` 7485 LoC mit 30+ Sektionen) gegen die Goal-Annahmen zeigt sich: einige "neu zu bauenden" Features existieren teilweise schon.
|
|
||||||
|
|
||||||
| Pillar | Goal sagt | Reality | Restarbeit % |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P1 — Live Stream Recording | "neu" | Auto-Record-Poller, Live-Recording-Events-Log, Live-Chat-Capture existieren | ~30% (Polish + Sub-only-Pfad nach OAuth) |
|
|
||||||
| P2 — Twitch OAuth | "neu" | App nutzt nur public Web-Client-ID, keine User-Auth | 100% |
|
|
||||||
| P3 — SQLite-Migration | "Breaking" | Config + Queue + Stats sind JSON-Files | 100% |
|
|
||||||
| P4 — main.ts Split | "neu" | 7485 LoC Monolith, 30 Sektionen, schon klar getrennt | 100% (mechanische Arbeit) |
|
|
||||||
| P5 — UI Power-Features | "neu" | Vanilla DOM, keine Virtualisierung, kein Mini-Player, ein Theme | 90% |
|
|
||||||
| P6 — Smart-Resume | "neu" | `auto_resume_live_recording` Flag existiert, `auto_merge_resumed_parts`, Crash-Recovery rudimentaer | ~60% (Chunk-Hash + Integrity-Check fehlen) |
|
|
||||||
| P7 — Scheduled/Auto-Discovery | "neu" | Auto-VOD-Download-Poller + Live-Status-Batch-Poller schon implementiert | ~30% (Top-Clip-Crawler, Follow-Sync fehlen) |
|
|
||||||
|
|
||||||
**Konsequenz:** Reihenfolge der Plane orientiert sich am Restarbeits-Volumen und Dependencies.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Order (reordered post Plan 01)
|
|
||||||
|
|
||||||
```
|
|
||||||
Plan 01: Foundation — Vitest + Pure-Utility-Extraction [DONE — v5.0.0-alpha.0]
|
|
||||||
└─> Plan 02: SQLite-Foundation (P3) [DONE — v5.0.0-alpha.1]
|
|
||||||
└─> Plan 03: OAuth Foundation Storage (P2) [DONE — v5.0.0-alpha.2]
|
|
||||||
└─> Plan 04: Smart-Resume Foundation (P6) [DONE — v5.0.0-alpha.3]
|
|
||||||
└─> DB-Singleton Lift + Plan 03b: OAuth Flow Scaffold [DONE — v5.0.0]
|
|
||||||
|
|
||||||
Post-5.0 (5.1.x):
|
|
||||||
Plan 04b Resume Integration (recorder hook) — touches main.ts state
|
|
||||||
Plan 05 Live-Rec Polish + Sub-only — needs user-supplied Twitch Client ID
|
|
||||||
Plan 06 UI Power (virtual list, mini-player, command palette) — renderer work
|
|
||||||
Plan 07-09 Architektur-Split Rest (Pillar 4) — state-strategy zuerst
|
|
||||||
└─> Plan 04: Live-Recording Polish + Sub-only (P1)
|
|
||||||
├─> Plan 05: Auto-Discovery Erweiterung (P7)
|
|
||||||
└─> Plan 06: UI Power-Features (P5)
|
|
||||||
└─> Plan 07: Smart-Resume Finalisierung (P6)
|
|
||||||
└─> Plan 08: Architektur-Split Pt. 1 (state-coupled modules)
|
|
||||||
└─> Plan 09: Architektur-Split Pt. 2 (final split)
|
|
||||||
└─> Plan 10: v5.0.0-rc.1 → GA
|
|
||||||
```
|
|
||||||
|
|
||||||
**Begruendung der Umordnung:** Plan 01 hat das Pure-Extraktion-Pattern validiert (Pillar 4 Teil-1).
|
|
||||||
Weiter splitten der state-gekoppelten Module braucht zuerst eine State-Container-Strategie — das ist
|
|
||||||
besser informiert nachdem Features (SQLite, OAuth, Live-Rec) ins System gehen und die echten
|
|
||||||
Querverbindungen sichtbar sind. Bis dahin lebt das Pattern aus Plan 01 weiter (neue Module landen
|
|
||||||
in `src/main/{infra,domain}/`), nur die alten in main.ts steckenden Stateful-Funktionen bleiben dort.
|
|
||||||
|
|
||||||
Plan 02 = Pillar 3 (SQLite).
|
|
||||||
Plan 03 = Pillar 2 (OAuth).
|
|
||||||
Plan 04 = Pillar 1.
|
|
||||||
Plan 05 = Pillar 7.
|
|
||||||
Plan 06 = Pillar 5.
|
|
||||||
Plan 07 = Pillar 6.
|
|
||||||
Plan 08-09 = Pillar 4 Rest.
|
|
||||||
Plan 10 = Release.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plan Status
|
|
||||||
|
|
||||||
| # | Plan | Datei | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01 | Foundation: Vitest + Pure-Utility-Extraction | `tasks/v5.0.0-plan-01-foundation.md` | **DONE** (v5.0.0-alpha.0) |
|
|
||||||
| 02 | SQLite-Foundation (Pillar 3) | `tasks/v5.0.0-plan-02-sqlite.md` | **DONE** (v5.0.0-alpha.1) |
|
|
||||||
| 03 | OAuth Foundation Storage (Pillar 2) | `tasks/v5.0.0-plan-03-oauth-foundation.md` | **DONE** (v5.0.0-alpha.2) |
|
|
||||||
| 03b | OAuth Flow Scaffold (Pillar 2) | `tasks/v5.0.0-plan-03b-oauth-flow.md` | **DONE** (v5.0.0) — PKCE + Loopback + Twitch flow modules, scaffold complete |
|
|
||||||
| 04 | Smart-Resume Foundation (Pillar 6) | `tasks/v5.0.0-plan-04-resume-foundation.md` | **DONE** (v5.0.0-alpha.3) |
|
|
||||||
| 04b | Smart-Resume Integration (Pillar 6) | (5.1.x) | post-5.0 — recorder integration |
|
|
||||||
| 05 | Live-Recording Polish (Pillar 1) | (5.1.x) | post-5.0 — needs OAuth Client ID + IPC + UI |
|
|
||||||
| 05 | Auto-Discovery Erweiterung (Pillar 7) | `tasks/v5.0.0-plan-05-auto-disc.md` | nach Plan 04 |
|
|
||||||
| 06 | UI Power (Pillar 5) | `tasks/v5.0.0-plan-06-ui-power.md` | nach Plan 04 |
|
|
||||||
| 07 | Smart-Resume Final (Pillar 6) | `tasks/v5.0.0-plan-07-smart-resume.md` | nach Plan 06 |
|
|
||||||
| 08 | Architektur-Split Pt. 1 (Pillar 4) | `tasks/v5.0.0-plan-08-split-pt1.md` | nach Plan 07 |
|
|
||||||
| 09 | Architektur-Split Pt. 2 (Pillar 4) | `tasks/v5.0.0-plan-09-split-pt2.md` | nach Plan 08 |
|
|
||||||
| 10 | Release 5.0.0 GA | `tasks/v5.0.0-plan-10-release.md` | nach Plan 09 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Versionsstrategie (post Reorder)
|
|
||||||
|
|
||||||
| Plan abgeschlossen | Version |
|
|
||||||
|---|---|
|
|
||||||
| Plan 01 | **5.0.0-alpha.0** Foundation, vitest + pure modules (DONE) |
|
|
||||||
| Plan 02 | 5.0.0-alpha.1 SQLite Migrator (Breaking, opt-in next-channel) |
|
|
||||||
| Plan 03 | 5.0.0-alpha.2 OAuth |
|
|
||||||
| Plan 04 | 5.0.0-beta.0 Live-Recording Polish + Sub-only |
|
|
||||||
| Plan 05 | 5.0.0-beta.1 Auto-Discovery |
|
|
||||||
| Plan 06 | 5.0.0-beta.2 UI Power |
|
|
||||||
| Plan 07 | 5.0.0-beta.3 Smart-Resume |
|
|
||||||
| Plan 08-09 | 5.0.0-rc.0 / rc.1 Architektur-Split |
|
|
||||||
| Plan 10 | 5.0.0 stable |
|
|
||||||
|
|
||||||
Pro Plan: ein Release-Tag, Auto-Updater Channel `next`. 4.6-User bleiben auf `stable`.
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'node',
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
globals: false,
|
|
||||||
reporters: ['default'],
|
|
||||||
clearMocks: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user