Compare commits
No commits in common. "5439786652669788d8e6a5b712643df9e422c36f" and "5a5d6f9c475b19919a120c0a0fddc97d5e5c7e31" have entirely different histories.
5439786652
...
5a5d6f9c47
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)
|
||||
@ -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. 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`.
|
||||
- **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`.
|
||||
|
||||
### Key Patterns
|
||||
|
||||
@ -38,8 +38,7 @@ 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 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.
|
||||
- **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.
|
||||
- **Localization**: EN (`renderer-locale-en.ts`) and DE (`renderer-locale-de.ts`) string tables.
|
||||
|
||||
### External Tools
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "5.1.0-alpha.1",
|
||||
"version": "5.0.0-alpha.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "5.1.0-alpha.1",
|
||||
"version": "5.0.0-alpha.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "5.1.0-alpha.1",
|
||||
"version": "5.0.0-alpha.3",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
|
||||
@ -811,23 +811,6 @@
|
||||
</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>
|
||||
@ -840,7 +823,6 @@
|
||||
<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>
|
||||
|
||||
56
src/main.ts
56
src/main.ts
@ -8,12 +8,6 @@ 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 {
|
||||
@ -92,7 +86,14 @@ type UpdateCheckSource = 'startup' | 'interval' | 'manual';
|
||||
type UpdateDownloadSource = 'auto' | 'manual';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -966,6 +967,36 @@ 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));
|
||||
@ -1683,6 +1714,17 @@ 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 {
|
||||
|
||||
@ -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,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,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,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,202 +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 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;
|
||||
})();
|
||||
@ -536,15 +536,6 @@ 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();
|
||||
|
||||
@ -4617,95 +4617,3 @@ 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;
|
||||
}
|
||||
|
||||
@ -30,14 +30,9 @@ 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]
|
||||
└─> 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 04b: Smart-Resume Integration (P6) [NEXT]
|
||||
└─> Plan 04: Live-Recording Polish + Sub-only (P1)
|
||||
├─> Plan 05: Auto-Discovery Erweiterung (P7)
|
||||
└─> Plan 06: UI Power-Features (P5)
|
||||
@ -71,10 +66,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 Scaffold (Pillar 2) | `tasks/v5.0.0-plan-03b-oauth-flow.md` | **DONE** (v5.0.0) — PKCE + Loopback + Twitch flow modules, scaffold complete |
|
||||
| 03b | OAuth Flow Implementation (Pillar 2) | `tasks/v5.0.0-plan-03b-oauth-flow.md` | nach Plan 04 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user