Compare commits

...

10 Commits

Author SHA1 Message Date
xRangerDE
5439786652 docs: comprehensive CHANGELOG 4.6.155 -> 5.1.0-alpha.1
43 commits seit 4.6.155, 20 neue Module, 219 unit tests, 5 alpha-Tags + 5.0.0 GA.
Bricht nach Pillar gegliedert auf, listet Breaking Changes (additive),
Build/Migration Checklist + Roadmap-Rest fuer 5.1.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:03:06 +02:00
xRangerDE
d7d0fafe5c release: 5.1.0-alpha.1 - top-clips-crawler + command palette
Pillar 7 (Auto-Discovery) gets the Helix top-clips-crawler module (9 tests).
Pillar 5 (UI Power) gets its first visible component: Command Palette via
Ctrl+K with 6 tab-jump commands.

219 unit tests + e2e:release gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:05:20 +02:00
xRangerDE
2065f794a6 feat(ui): Command Palette (Ctrl+K) — Pillar 5 first visible component
Modal markup + CSS (.command-palette .cp-*) + renderer-command-palette.ts.
6 statische Tab-Wechsel-Befehle (VODs/Queue/Streamers/Stats/Archive/Settings)
mit prefix-Match. ArrowUp/Down navigiert, Enter ausfuehrt, Esc/Click-on-Overlay
schliesst. Registriert sich in closeTopmostOpenModal damit globaler Esc-Handler
es korrekt findet.

clearList via removeChild-Loop statt innerHTML='' (Hook-Pattern Bypass — gleiches
Verhalten, sicherer).

npm run test:e2e gruen — App startet sauber mit dem neuen Modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:04:14 +02:00
xRangerDE
dc2b609132 feat(discovery): Helix top-clips-crawler module + rangeLastDays helper (9 tests)
Pillar 7 (Auto-Discovery) addition. Pure module — fetchImpl injizierbar fuer
Tests. Helix /clips endpoint, supports broadcaster_id + first (clamped 1-100) +
started_at/ended_at. Returns sorted-desc by view_count. snake_case Helix-
Felder werden auf camelCase im Public-Interface gemappt.

219 unit tests. IPC wiring + Auto-Discovery-Scheduler kommt post-5.1.0-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:00:38 +02:00
xRangerDE
f775e7a9e2 docs: CLAUDE.md update module inventory (19 modules, 17 test files, 210 tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:58:11 +02:00
xRangerDE
595269d9f5 release: 5.1.0-alpha.0 - integrity-check + archive-files-store + format-helpers
Continuation post-5.0.0. Adds three more domain/infra modules + 46 tests
(210 total). Touched pillars:

- Pillar 1 (Live-Rec): integrity-check ffprobe parser + verdict
- Pillar 6 (Smart-Resume / Archive): archive-files-store CRUD + summary
- Pillar 4 (Architecture-Split): format-helpers extraction (4 more pure
  functions out of main.ts: sanitizeFilenamePart, formatTwitchDurationFromSeconds,
  formatDateWithPattern, getMergeGroupPhaseText)

main.ts continues to shrink (now ~7160 LoC from 7485 start). Renderer-side
work (Pillar 5 UI Power) + recorder integration (Plan 04b) + OAuth IPC
wiring (needs Twitch Client ID from user) still ahead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:57:35 +02:00
xRangerDE
35189f6776 refactor: extract format helpers (sanitize, twitch-duration, date-pattern, merge-phase) + 24 tests
src/main/infra/format-helpers.ts. main.ts adapter for getMergeGroupPhaseText
injects config.language. 210 unit tests gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:57:22 +02:00
xRangerDE
bd1db9b873 feat(archive): archive-files-store CRUD + summaryByStreamer (10 tests)
upsert / get / list (filter by streamer, ordered by createdAt DESC NULLS LAST) /
setVerified / delete / summaryByStreamer (aggregated count + bytes per streamer) /
totalBytes. normalizeLogin used on streamer_login at write + filter time so
@Alice/Alice/alice all collapse to alice.

186 unit tests gruen. Storage layer for Pillar 1 verification + Pillar 6
archive index — recorder/storage-stats integration is post-5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:53:54 +02:00
xRangerDE
987fb73a0e feat(integrity): ffprobe JSON parser + verdict assessor (12 tests)
Pillar 1 storage-layer piece. Spawn-free design — caller liefert das
ffprobe-Output als String, dieses Modul parsed + bewertet (no-video-stream,
duration-too-short, expected-duration mismatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:48 +02:00
xRangerDE
5b1c68a122 release: 5.0.0 — vitest + SQLite + OAuth storage/flow + Smart-Resume foundation
Major bump. v5.0.0-GA umfasst:

Foundation
- vitest test infrastructure
- 11 neue Module in src/main/{infra,domain}/
- main.ts: -198 LoC pure helpers extracted
- 164 unit tests + full e2e:release suite gruen

Persistence (Pillar 3 — done)
- better-sqlite3 mit WAL + 5s busy_timeout
- schema v5 (9 tables: schema_meta, config_kv, queue_items, downloaded_vods,
  streamers, archive_files, migrations_applied, oauth_accounts, chunk_index)
- Idempotenter JSON to SQLite Migrator (shadow-write, .v4-backup)
- Long-lived DB-Handle Singleton mit shutdown-close

Auth (Pillar 2 — scaffold done)
- SecureStorage (Electron safeStorage + Memory-Impl)
- token-store CRUD auf oauth_accounts (encrypted)
- PKCE pair + state generator
- Loopback HTTP server (127.0.0.1, ephemeral port)
- Twitch OAuth 2.1 Authorization Code Flow + PKCE module
- IPC wiring + Client ID config + Renderer button kommen in 5.1.x

Smart-Resume (Pillar 6 — foundation done)
- chunk_index table
- sha1 buffer + streaming file hash helpers
- chunk-index-store CRUD
- Integration in den Live-Recorder kommt in 5.1.x

Post-5.0 Roadmap
- Plan 04b: Resume in Recorder
- Plan 05: Live-Rec Polish + Sub-only (braucht Twitch Client ID)
- Plan 06: UI Power (virtual list, mini-player, command palette)
- Plan 07-09: Architektur-Split (Pillar 4 Rest)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:48:02 +02:00
18 changed files with 1505 additions and 59 deletions

217
CHANGELOG.md Normal file
View File

@ -0,0 +1,217 @@
# 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)

View File

@ -27,7 +27,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **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. Reine Helpers bereits ausgelagert nach `src/main/infra/` (fs-atomic, duration) und `src/main/domain/` (update-version-utils, i18n-backend, config-normalize). Roadmap fuer den vollstaendigen Split: `tasks/v5.0.0-roadmap.md`.
- **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
@ -38,7 +38,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **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 in Plan 04b.
- **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

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "5.0.0-alpha.3",
"version": "5.1.0-alpha.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "5.0.0-alpha.3",
"version": "5.1.0-alpha.1",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "5.0.0-alpha.3",
"version": "5.1.0-alpha.1",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -811,6 +811,23 @@
</main>
</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 Suche"
/>
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Command results"></ul>
<p class="cp-hint">Up/Down zum Navigieren, Enter zum Ausfuhren, Esc zum Schliessen</p>
</div>
</div>
<script src="../dist/renderer-locale-de.js"></script>
<script src="../dist/renderer-locale-en.js"></script>
<script src="../dist/renderer-texts.js"></script>
@ -823,6 +840,7 @@
<script src="../dist/renderer-archive.js"></script>
<script src="../dist/renderer-profile.js"></script>
<script src="../dist/renderer-vod-hover.js"></script>
<script src="../dist/renderer-command-palette.js"></script>
<script src="../dist/renderer.js"></script>
</body>
</html>

View File

@ -8,6 +8,12 @@ import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/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 {
@ -86,14 +92,7 @@ type UpdateCheckSource = 'startup' | 'interval' | 'manual';
type UpdateDownloadSource = 'auto' | 'manual';
function getMergeGroupPhaseText(phase: string): string {
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;
}
return getMergeGroupPhaseTextCore(phase, config?.language ?? 'de');
}
// ==========================================
@ -967,36 +966,6 @@ function releaseClaimedFilenamesForItem(itemId: string): void {
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 {
const safe = Math.max(0, Math.floor(totalSeconds));
@ -1714,17 +1683,6 @@ async function ensureTwitchAuth(forceRefresh = false): Promise<boolean> {
return await requestTwitchLogin();
}
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).
// 4xx (other than 408/429) are application errors and not retried.
function isTransientAxiosError(err: unknown): boolean {

View File

@ -0,0 +1,106 @@
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);
});
});

View File

@ -0,0 +1,138 @@
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;
},
};
}

View File

@ -0,0 +1,115 @@
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();
});
});

View File

@ -0,0 +1,132 @@
// 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);
}

View File

@ -0,0 +1,137 @@
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');
});
});

View File

@ -0,0 +1,135 @@
// 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(),
};
}

View File

@ -0,0 +1,103 @@
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');
});
});

View File

@ -0,0 +1,78 @@
// 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;
}
}

View File

@ -0,0 +1,202 @@
// 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 showTab = (window as unknown as { showTab?: (tab: string) => void }).showTab;
if (typeof showTab !== 'function') {
return [];
}
const tabs: Array<{ id: string; labels: string[]; hint: string }> = [
{ id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Go' },
{ id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Go' },
{ id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Go' },
{ id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Go' },
{ id: 'archive', labels: ['Archive', 'archiv'], hint: 'Go' },
{ id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Go' },
];
return tabs.map(t => ({
id: 'tab:' + t.id,
label: t.labels[0],
hint: t.hint,
keywords: t.labels.join(' ').toLowerCase(),
action: () => showTab(t.id),
}));
}
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;
})();

View File

@ -536,6 +536,15 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
function closeTopmostOpenModal(): boolean {
// 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');
if (eventsViewerModal?.classList.contains('show')) {
closeEventsViewer();

View File

@ -4617,3 +4617,95 @@ input[type="number"]::-webkit-outer-spin-button {
font-size: 11px;
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;
}

View File

@ -30,9 +30,14 @@ Beim Vergleich der echten Codebase (`src/main.ts` 7485 LoC mit 30+ Sektionen) ge
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 03b: OAuth Flow Implementation (P2)
└─> Plan 04: Smart-Resume Foundation (P6) [DONE — v5.0.0-alpha.3]
└─> Plan 04b: Smart-Resume Integration (P6) [NEXT]
└─> 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)
@ -66,10 +71,10 @@ Plan 10 = Release.
| 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 Implementation (Pillar 2) | `tasks/v5.0.0-plan-03b-oauth-flow.md` | nach Plan 04 |
| 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) | `tasks/v5.0.0-plan-04b-resume-integration.md` | **NEXT** |
| 05 | Live-Recording Polish (Pillar 1) | `tasks/v5.0.0-plan-05-live-rec.md` | nach Plan 03b + 04b |
| 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 |