Compare commits
No commits in common. "main" and "v4.2.4" have entirely different histories.
@ -1,138 +0,0 @@
|
||||
# Improvement Log
|
||||
|
||||
Dated entries from improvement cycles. Newest at top.
|
||||
|
||||
## 2026-05-03 — Cycle 4: GQL retry + VOD sort + shutdown consolidation
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. Public Twitch GQL fallback retries on transient failures (defensive error handling)
|
||||
|
||||
- **File**: `src/main.ts` — new `isTransientAxiosError` + retry loop in `fetchPublicTwitchGql`.
|
||||
- **Problem**: `fetchPublicTwitchGql` swallowed every network error with `catch (e) { console.error(...); return null; }`. The public-API fallback path is what users without a Twitch client_id/secret hit on every VOD list load — a single TCP RST or a transient `503` from `gql.twitch.tv` produced an empty list and the user had to click refresh.
|
||||
- **Fix**: Up to 3 attempts with exponential backoff (`400ms × 2^(attempt-1)` + jitter, capped by attempt count). Retries cover transient HTTP (`408`, `429`, `5xx`) and pure network failures (no response). GraphQL errors in `errors[]` are still returned without retry — those are application-level rejections of the query itself. Recovery is logged via `appendDebugLog('public-gql-recovered', ...)` so we can later see in logs whether the retries actually pay off.
|
||||
|
||||
### 2. VOD list sort dropdown with persistence (client feature: VM/state + UI + persistence)
|
||||
|
||||
- **Files**: `src/renderer-streamers.ts`, `src/renderer.ts`, `src/renderer-texts.ts`, `src/index.html`, `src/renderer-locale-de.ts`, `src/renderer-locale-en.ts`.
|
||||
- **Problem**: VODs always rendered in the order Twitch returned them (`sort:TIME` desc). With long archives users had no way to find the longest stream, the most-watched, or the oldest.
|
||||
- **Fix**: `vodSortSelect` dropdown next to the filter input. Five sort modes: newest first, oldest first, most viewed, longest first, shortest first. State (`vodSortKey`) persisted to `localStorage` under `twitch-vod-manager:vod-sort` and validated against an enum on load — an unknown stored value falls back to `date_desc` so a future rename can't strand the user. `renderVodGridFromCurrentState` now applies `sortVods` before `filterVodsByQuery` so the filter sees the sort order and the match-counter is consistent. Sort labels and the "Sort:" prefix label are localized (DE + EN), and `refreshVodSortSelectLabels` re-runs on language switch so the option labels stay in the active language. Browser-default keyboard nav on the select (arrow keys, type-ahead) covers keyboard access.
|
||||
|
||||
### 3. `shutdownCleanup()` consolidates `window-all-closed` + `before-quit` (cleanup of meaningful size)
|
||||
|
||||
- **File**: `src/main.ts`.
|
||||
- **Problem**: Both lifecycle handlers ran nearly identical cleanup blocks but had drifted: `window-all-closed` killed children and was platform-aware (`app.quit()` on non-darwin), `before-quit` only stopped timers and saved state. There was no single place to add a new "must run on exit" step — every future addition had to be pasted into both handlers and inevitably one would diverge.
|
||||
- **Fix**: Single `shutdownCleanup(reason)` helper, gated by an idempotent `shutdownCleanupDone` flag so a `before-quit` immediately following a `window-all-closed` is a no-op. The helper kills `activeDownloads`, `activeClipProcesses`, and `currentEditorProcess` (with try/catch so an already-exited proc doesn't throw), persists config + queue, then stops timers. Debug-log flush is reordered to run AFTER `saveConfig` / `flushQueueSave` so any error in those persistence calls actually reaches the log file before the flush timer is gone. Both `app.on(...)` handlers shrank to one line each.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||
- `npm run test:e2e:update-logic` — passed.
|
||||
- `npm run test:merge-split` — passed.
|
||||
- `npm run test:e2e` — passed (`issues: []`).
|
||||
- `npm run test:e2e:guide` — passed (`failures: []`).
|
||||
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`).
|
||||
|
||||
## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. `download-clip` IPC: integrity, cancellation, sanitization (server defensive)
|
||||
|
||||
- **File**: `src/main.ts` — `download-clip` IPC handler, new `activeClipProcesses` map.
|
||||
- **Problem**: The handler reported `success: true` on streamlink exit code 0 even when the resulting file was empty / a few hundred bytes (Twitch occasionally returns a manifest with no segments). The path passed `clipInfo.broadcaster_name` straight to `path.join` — Twitch returns the broadcaster's *display* name, which can carry unicode, spaces, or punctuation that produced surprising directory layouts on Windows. The spawned streamlink process was tracked nowhere, so `window-all-closed` left it orphaned.
|
||||
- **Fix**: `safeBroadcaster` runs through `sanitizeFilenamePart`. `safeTitle` falls back to `clip` when the title sanitises to empty. The output filename now goes through `ensureUniqueFilename(path, clipId)` so retrying a clip with the same title doesn't overwrite the previous download. After streamlink exits, the file is rejected if smaller than 16 KiB or if `validateDownloadedFileIntegrity` fails (no video stream / unreadable). The proc is tracked in a new `activeClipProcesses` map and killed by `window-all-closed`.
|
||||
|
||||
### 2. VOD list filter / search (client feature: VM/state + UI + persistence + keyboard)
|
||||
|
||||
- **Files**: `src/renderer-streamers.ts`, `src/renderer.ts`, `src/renderer-texts.ts`, `src/index.html`, `src/renderer-locale-de.ts`, `src/renderer-locale-en.ts`.
|
||||
- **Problem**: A streamer can have hundreds of VODs (the test fixture alone has 37 cards). There was no way to find a specific VOD by title — only scroll. With a long archive this is genuinely painful.
|
||||
- **Fix**: Filter row above the VOD grid (`vodFilterInput`, clear button, match counter). State (`vodFilterQuery`) is persisted to `localStorage` via `loadPersistedVodFilter` / `persistVodFilter`, so the search bar survives an app restart. The render path was split: `renderVODs` now stores `lastLoadedVods` + `lastLoadedStreamer` and delegates to `renderVodGridFromCurrentState`, which applies `filterVodsByQuery` on every input event without re-fetching. Empty-state DOM is built via `setVodGridEmptyState` using `createElement` + `textContent` (no `innerHTML` for locale strings — defense-in-depth even though the strings are trusted). Keyboard: `Ctrl+F` / `Cmd+F` focuses the filter (only when the VODs tab is active and Electron's no-op default is suppressed); `Esc` clears the filter when the input has focus and content; `Esc` still closes modals first if any are open.
|
||||
|
||||
### 3. Decouple `currentProcess` from queue downloads (server cleanup + race fix)
|
||||
|
||||
- **File**: `src/main.ts` — global rename and assignment removal.
|
||||
- **Problem**: A single `currentProcess: ChildProcess | null` was shared by `cutVideo`, `mergeVideos`, `splitMergedFile`, AND `downloadVODPart`. With parallel downloads the global was constantly overwritten between siblings, but the cross-talk that mattered was different: if a queue download was running and the user kicked off a video cut, the cutter ffmpeg ran into the same global. Pressing the queue's *cancel-download* button then iterated `activeDownloads` (correct) AND called `currentProcess.kill()` (incorrect — that was the cutter ffmpeg by then), killing the unrelated cut.
|
||||
- **Fix**: `currentProcess` renamed to `currentEditorProcess` and confined to the editor pipeline (cutter / merger / splitter). `downloadVODPart` no longer assigns to it — `activeDownloads` is the sole source of truth for queue children. The fallback `if (currentProcess) currentProcess.kill()` was removed from `remove-from-queue`, `pause-download`, and `cancel-download`. `window-all-closed` still kills it (so a cutter ffmpeg gets cleaned up on app exit) and now also kills `activeClipProcesses` introduced by Pick 1.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||
- `npm run test:e2e:update-logic` — passed.
|
||||
- `npm run test:e2e` — passed (`issues: []`).
|
||||
- `npm run test:e2e:guide` — passed (`failures: []`).
|
||||
- `npm run test:merge-split` — passed.
|
||||
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue add, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
## 2026-05-03 — Cycle 2: release pipeline + defensive parsing
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. `scripts/release_gitea.mjs` skips rebuild when artifacts exist (release pipeline)
|
||||
|
||||
- **File**: `scripts/release_gitea.mjs`.
|
||||
- **Problem**: The script unconditionally ran `npm run dist:win` (full test suite + electron-builder) even when the version's artifacts were already on disk under `release/`. When `npm run test:e2e` was broken (cycle 1 follow-up), the release path was unusable — the previous cycle had to bypass the script with direct API uploads via PowerShell. Every future agent would hit the same wall.
|
||||
- **Fix**: New `--skip-build` flag. The script now also auto-detects whether all 3 required artifacts (`Setup-<v>.exe`, `Setup-<v>.exe.blockmap`, `latest.yml`) exist for the requested version and skips `dist:win` accordingly. The auto-skip is the safe default — explicit `--skip-build` documents intent. Help text updated to describe the new flag and the auto-skip behaviour.
|
||||
|
||||
### 2. `playwright` in `devDependencies` + simplified test scripts (release pipeline)
|
||||
|
||||
- **Files**: `package.json` (+ `package-lock.json`).
|
||||
- **Problem**: `npm exec --yes --package=playwright -- node scripts/smoke-test*.js` failed with `MODULE_NOT_FOUND` in environments where `npm exec` couldn't resolve playwright on the fly (clean caches, locked CI runners). Cycle 1 worked around it with `npm install --no-save playwright`. Result: the documented test path was unreliable.
|
||||
- **Fix**: `playwright ^1.59.1` added to `devDependencies`. `test:e2e`, `test:e2e:guide`, `test:e2e:full` now invoke `node scripts/smoke-test*.js` directly — `require('playwright')` resolves locally. No browser binary install needed because the smoke tests drive Electron via `_electron`, not a browser.
|
||||
|
||||
### 3. Defensive parsing in `loadConfig` and `loadQueue` (server-side correctness)
|
||||
|
||||
- **File**: `src/main.ts` — new `isPlainObject` / `isValidQueueStatus` / `sanitizeCustomClip` / `sanitizeMergeGroup` / `sanitizeQueueItem` helpers; rewritten `loadConfig` and `loadQueue`.
|
||||
- **Problem**: `loadConfig` blindly spread `JSON.parse(data)` over the defaults. If the config file ever held a non-object (corrupt, manually edited to an array, partial write before Cycle 1's fsync landed), the spread either dropped values silently (primitives) or polluted the config object (arrays became numeric keys). `loadQueue` only validated `id`, `url`, `status` are strings — it accepted `customClip` / `mergeGroup` of any shape, never validated `progress` was a finite number, and notably never normalized stale `status: 'downloading'` items. After a hard kill mid-download, those items came back marked as still downloading with no actual download running, and `start-download` only resurrected `paused` items, leaving them stuck.
|
||||
- **Fix**: `loadConfig` checks `isPlainObject(parsed)` before spread; non-objects are logged and ignored, defaults used. `loadQueue` runs every entry through `sanitizeQueueItem` which validates the `status` enum, normalizes `progress` to `[0, 100]`, validates and normalizes `customClip` / `mergeGroup` shapes, and demotes stale `status: 'downloading'` to `pending` with `progress = 0` so the user can actually resume the queue. Invalid items are dropped with a count logged. As a bonus, the previously-unused `CustomClip` and `MergeGroupItem` type imports now have call sites.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||
- `npm run test:e2e:update-logic` — passed.
|
||||
- `npm run test:e2e` — passed via the new direct script path (no `npm exec` workaround), `issues: []`.
|
||||
- `npm run test:e2e:guide` — passed.
|
||||
- `npm run test:merge-split` — passed.
|
||||
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
## 2026-05-03 — Cycle 1: stability & UX polish
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. Atomic file writes survive power loss / crash mid-write (correctness)
|
||||
|
||||
- **Files**: `src/main.ts` — new `writeFileAtomicSync` helper, `saveConfig`, `writeQueueToDisk`.
|
||||
- **Problem**: `saveConfig` and `writeQueueToDisk` used `writeFileSync` + `renameSync`. Node's `writeFileSync` does NOT call `fsync` — the OS may report the rename complete while the file content still sits in the write cache. A power loss / kernel panic between `writeFileSync` and `renameSync` could leave the renamed file empty or truncated. On next launch, `JSON.parse` throws and the app silently falls back to defaults (config) or `[]` (queue). Users would see "settings reset" / "queue lost" with no diagnostic in the debug log beyond a `console.error`.
|
||||
- **Fix**: `openSync(tmp, 'w')` → `writeSync(fd, buffer, 0, len, 0)` → `fsyncSync(fd)` → `closeSync(fd)` → `renameSync`. The `fsyncSync` is wrapped in an inner try (some filesystems reject it, e.g. network shares); failure there is non-fatal but the close + rename order is always preserved. The Windows copy/unlink fallback for "rename failed because target locked" is kept.
|
||||
|
||||
### 2. Per-item filename claims fix parallel-download race (race condition + dead-code cleanup)
|
||||
|
||||
- **Files**: `src/main.ts` — `ensureUniqueFilename`, new `releaseClaimedFilenamesForItem`, every download call site, `splitMergedFile` signature.
|
||||
- **Problem**: `claimedFilenames` was a global `Set<string>` and `processOneQueueItem` did `claimedFilenames.clear()` in its `finally`. With parallel downloads enabled (max 2), when item A finished, the `clear()` wiped item B's reservations too. In the narrow window between B claiming a filename via `ensureUniqueFilename` and streamlink actually writing the first bytes to disk, a third item entering the freed slot could compute the SAME filename (claim set empty, file not yet on disk) → both downloads would race writing the same path. The dead `releaseClaimedFilename(filePath)` function was defined at line 722 but never called from anywhere.
|
||||
- **Fix**: New `Map<itemId, Set<filename>>` tracks which item claimed which filenames. `ensureUniqueFilename(filePath, itemId)` registers per-item; `releaseClaimedFilenamesForItem(itemId)` removes only that item's claims. `splitMergedFile` gained an `itemId` parameter so split-phase claims register correctly. The dead `releaseClaimedFilename` is gone, replaced by the per-item variant.
|
||||
|
||||
### 3. Renderer UX polish — robust progress lookup, persisted active tab, keyboard shortcuts (client-side feature)
|
||||
|
||||
- **Files**: `src/renderer-queue.ts`, `src/renderer.ts`.
|
||||
- **Problem(s)** (small wins bundled as one coherent UX improvement):
|
||||
- `updateQueueItemProgress` indexed `byId('queueList').children[idx]` by array position — fragile if the queue array and DOM ever diverged for a frame (queue mutated after render-fingerprint shortcut, or during the throttled queue-sync window).
|
||||
- The active tab always reset to `vods` on app launch — annoying for users who live in `settings`, `cutter`, or `merge`.
|
||||
- No way to dismiss any of the three modals (`clipModal`, `templateGuideModal`, `updateModal`) without clicking the close button.
|
||||
- No keyboard navigation between tabs (only `Del` and `S` were wired).
|
||||
- The page title used to show the streamer name even when the user was on Settings or Cutter, because `showTab` always preferred `currentStreamer` over the tab title.
|
||||
- **Fix**:
|
||||
- Look up queue items by `[data-id="..."]` selector instead of array index. Resilient to mutation between renders. Determinate / indeterminate progress class logic tightened (`isDeterminate = progress > 0 && progress <= 100`).
|
||||
- Active tab persisted to `localStorage` on every `showTab`; restored on init via `loadPersistedActiveTab`, whitelisted to known tab IDs (`vods | clips | cutter | merge | settings`) so a future rename can't strand users on a missing tab. Title logic fixed: streamer name only appears in the page title when the VODs tab is active.
|
||||
- `Escape` closes the topmost open modal regardless of focus (priority order: clip dialog → template guide → update modal). Works while typing in a modal input.
|
||||
- `Ctrl+1..5` (or `Cmd+1..5` on macOS) jumps directly to a tab. Existing `Del` (delete selected) and `S` (start/pause) shortcuts continue to work and remain blocked while typing in inputs.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors, 0 new warnings).
|
||||
- `node scripts/smoke-test-update-version-logic.js` — passed.
|
||||
- `node scripts/smoke-test-merge-split-logic.js` — passed.
|
||||
- `node scripts/smoke-test.js` — passed (37 VODs listed, queue add OK, preflight green, `issues: []`).
|
||||
- `node scripts/smoke-test-template-guide.js` — passed (17 variable rows, live preview reactive, `failures: []`).
|
||||
- `node scripts/smoke-test-full.js` — passed (`failures: []`, `runtimeIssues: []`; flows verified: language switch, queue add, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
ESLint reports 36 pre-existing warnings and 1 pre-existing error (control-character regex in `sanitizeFilenamePart`); none new from this cycle.
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,432 +0,0 @@
|
||||
# VOD Merge+Split Feature — Design Spec
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Status:** Approved
|
||||
**Revision:** 3 (second spec-review fixes)
|
||||
|
||||
## Summary
|
||||
|
||||
Allow users to select 2+ pending VODs in the download queue, combine them into a single "Merge Group" queue item, and have the system automatically: download all VODs, merge them chronologically via FFmpeg, split the merged result into time-based parts (using the existing `part_minutes` setting), and clean up all temporary files.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. User selects 2+ pending queue items via checkboxes
|
||||
2. Clicking "Merge & Split" creates a single merge-group queue item
|
||||
3. VODs are auto-sorted chronologically by full ISO timestamp (date + time)
|
||||
4. The entire download → merge → split → cleanup runs as one automated job
|
||||
5. Part naming uses the existing `filename_template_parts` setting
|
||||
6. Date for naming is taken from the first (earliest) VOD
|
||||
7. Temporary files (individual downloads, merged file) are deleted after successful split
|
||||
8. On failure, the entire group fails; retry resumes from where it left off
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model
|
||||
|
||||
### New Interfaces
|
||||
|
||||
```typescript
|
||||
interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string; // Full ISO timestamp from Twitch API (includes time)
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
interface MergeGroup {
|
||||
items: MergeGroupItem[]; // VODs, sorted chronologically by date
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number; // Index of VOD currently being downloaded
|
||||
downloadedFiles: Record<number, string>; // Sparse map: VOD index → file path (not array)
|
||||
mergedFile?: string; // Path of concatenated file
|
||||
splitFiles?: string[]; // Paths of final split parts
|
||||
totalDurationSec?: number; // Cached total duration for progress calculation
|
||||
}
|
||||
```
|
||||
|
||||
**Note on `downloadedFiles`:** Uses `Record<number, string>` (sparse map keyed by VOD index) instead of `string[]` to avoid ambiguity during retry. On retry, `downloadedFiles[i]` is checked — if the key exists AND the file exists on disk, that VOD is skipped. A dense array would cause index misalignment when earlier downloads succeed but later ones fail.
|
||||
|
||||
### QueueItem Extension
|
||||
|
||||
```typescript
|
||||
interface QueueItem {
|
||||
// ... all existing fields unchanged ...
|
||||
mergeGroup?: MergeGroup; // Present → this is a merge group
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The `mergeGroup` field must be added to ALL THREE copies of the `QueueItem` interface:
|
||||
- `src/main.ts` (lines ~156-173)
|
||||
- `src/preload.ts` (lines ~12-26)
|
||||
- `src/renderer-globals.d.ts` (lines ~40-58)
|
||||
|
||||
**Type sharing strategy:** The `MergeGroup` and `MergeGroupItem` interfaces must be defined in ALL locations where `QueueItem` is defined:
|
||||
- **`src/main.ts`**: Full interface definitions (canonical source)
|
||||
- **`src/renderer-globals.d.ts`**: Full interface definitions (for renderer TypeScript)
|
||||
- **`src/preload.ts`**: Full interface definitions (for preload TypeScript — required because `preload.ts` has its own copy of `QueueItem` and TypeScript strict mode requires `MergeGroup` to be in scope)
|
||||
|
||||
This follows the existing pattern: `QueueItem` and `CustomClip` are already duplicated across all three files.
|
||||
|
||||
### Conventions
|
||||
|
||||
- When `mergeGroup` is set, the top-level `url`, `title`, `date`, `streamer`, `duration_str` are populated from the **first** (chronologically earliest) item in the group, for backwards compatibility with existing queue code.
|
||||
- `title` is generated: `"Merge: {title1} + {title2}"` (3+ items: `"Merge: {title1} + {n-1} weitere"`)
|
||||
- `duration_str` is the sum of all individual durations.
|
||||
- `url` is set to the first VOD's URL (used as identifier only, not for downloading — each item in `mergeGroup.items` has its own URL).
|
||||
|
||||
---
|
||||
|
||||
## 2. Download Flow — `processDownloadMergeGroup()`
|
||||
|
||||
A new function handling the 4-phase pipeline. Called from `processQueue()` when `item.mergeGroup` is present.
|
||||
|
||||
**Critical: Download mode override.** Merge group VODs are ALWAYS downloaded in full-file mode, regardless of the user's `config.download_mode` setting. This ensures each VOD produces exactly one file. The user's parts setting is only applied during Phase 3 (splitting the merged result). Without this override, `download_mode === 'parts'` would produce multiple files per VOD, breaking the merge pipeline.
|
||||
|
||||
**Implementation approach:** `processDownloadMergeGroup()` does NOT call `downloadVOD()` (which reads `config.download_mode` from the global config and would split into parts). Instead, it calls `downloadVODPart()` directly with `startTime=null, endTime=null` (meaning "download the entire VOD as one file"). The merge-group function replicates the necessary setup from `downloadVOD()` itself:
|
||||
- Tool verification (`ensureStreamlinkInstalled()`, `ensureFfmpegInstalled()`)
|
||||
- Output folder creation (`ensureDownloadFolder()`)
|
||||
- Disk space pre-check (`ensureDiskSpace()` with 3x estimate)
|
||||
- Filename generation (using first VOD's date, streamer, and a temp naming scheme like `merge_tmp_{vodIndex}_{timestamp}.mp4`)
|
||||
|
||||
This avoids mutating the global `config.download_mode` (which would be a race condition in async code) and avoids refactoring `downloadVOD()` to accept an override parameter.
|
||||
|
||||
### Phase 1: Downloading (`mergePhase: 'downloading'`)
|
||||
|
||||
```
|
||||
For each VOD in mergeGroup.items (chronological order):
|
||||
0. Reset currentDownloadCancelled = false (CRITICAL: prevents stale cancel state
|
||||
from a prior VOD or previous retry from aborting the current download)
|
||||
1. Set currentItemIndex = i
|
||||
2. Skip if downloadedFiles[i] exists in map AND file exists on disk (retry recovery)
|
||||
3. Call downloadVODPart() directly with startTime=null, endTime=null
|
||||
(bypasses downloadVOD() which checks config.download_mode)
|
||||
5. Store resulting file path: downloadedFiles[i] = path
|
||||
6. Report weighted progress: each VOD's share proportional to its duration
|
||||
7. On error → entire group status = 'error', phase stays 'downloading'
|
||||
8. saveQueue() after each completed VOD (crash recovery)
|
||||
```
|
||||
|
||||
### Phase 2: Merging (`mergePhase: 'merging'`)
|
||||
|
||||
```
|
||||
1. Use existing mergeVideos() function (FFmpeg concat demuxer)
|
||||
BUT pass totalDurationSec so progress can be calculated correctly
|
||||
2. Input: files sorted explicitly by index:
|
||||
Object.keys(downloadedFiles).sort((a, b) => Number(a) - Number(b)).map(k => downloadedFiles[Number(k)])
|
||||
(Do NOT rely on implicit Object.values() ordering — VOD order is correctness-critical)
|
||||
3. Output: temporary file "merged_{timestamp}.mp4" in download directory
|
||||
4. Two-stage strategy: stream copy first, re-encode fallback
|
||||
5. Progress: mergeVideos() must use actual total duration for correct percentage:
|
||||
percent = (out_time_us / (totalDurationSec * 1_000_000)) * 100
|
||||
(The existing formula `currentUs / 10_000_000` is a bug — it caps at 99% after
|
||||
16.5 minutes regardless of actual file length. Must be fixed.)
|
||||
6. On error → status = 'error', phase stays 'merging'
|
||||
7. Store path in mergeGroup.mergedFile, saveQueue()
|
||||
```
|
||||
|
||||
**Note on existing mergeVideos() progress bug and signature change:** The current implementation at main.ts:2440 uses `Math.min(99, currentUs / 10000000)` which hits 99% after ~16 minutes. The fix:
|
||||
- Add an **optional** `totalDurationSec?: number` parameter to `mergeVideos()` (4th parameter after `onProgress`)
|
||||
- When provided: `percent = Math.min(99, (currentUs / (totalDurationSec * 1_000_000)) * 100)`
|
||||
- When NOT provided (standalone Merge Videos tab): use `ffprobe` to determine total duration of all input files before starting the merge. This is a lightweight call (~100ms) that runs once before the merge begins.
|
||||
- The existing IPC handler `merge-videos` does NOT need signature changes — it calls `mergeVideos()` internally and simply omits the `totalDurationSec` parameter, triggering the ffprobe fallback.
|
||||
- `processDownloadMergeGroup()` passes `totalDurationSec` directly (already computed from VOD durations), skipping the ffprobe call.
|
||||
|
||||
**Note on Windows path escaping:** The existing `mergeVideos()` function uses Unix-style single-quote escaping (`'\\''`) in the FFmpeg concat file. This is a pre-existing issue that should be verified on Windows during testing. FFmpeg's concat demuxer on Windows typically handles forward-slash paths correctly, so the implementation should normalize backslashes to forward slashes in the concat file.
|
||||
|
||||
### Phase 3: Splitting (`mergePhase: 'splitting'`)
|
||||
|
||||
```
|
||||
1. Calculate total duration from mergeGroup.items (or use cached totalDurationSec)
|
||||
2. Calculate numParts = ceil(totalDuration / (config.part_minutes * 60))
|
||||
3. For each part:
|
||||
ffmpeg -ss <startSec> -i mergedFile -t <partDuration> -c copy -y part_X.mp4
|
||||
(Note: -ss BEFORE -i for fast seeking, matching existing cutVideo() pattern)
|
||||
4. Naming via existing filename_template_parts, date = first VOD's date
|
||||
5. Progress: simple part counter (copy mode with fast-seek is near-instant)
|
||||
6. Store paths in mergeGroup.splitFiles
|
||||
7. On error → status = 'error', phase stays 'splitting'
|
||||
```
|
||||
|
||||
**Split approach:** `-c copy` (stream copy, no re-encoding) with `-ss` before `-i` (fast seek)
|
||||
- Near-instant execution (fast seek skips directly to the target position)
|
||||
- Quality-lossless
|
||||
- Minor keyframe-boundary imprecision at split points (irrelevant for 60+ min parts)
|
||||
- Matches the existing `cutVideo()` pattern at main.ts:2303-2306
|
||||
|
||||
### Phase 4: Cleanup (`mergePhase: 'cleanup'`)
|
||||
|
||||
```
|
||||
1. Delete all individual downloads: all values from downloadedFiles
|
||||
2. Delete merged file: mergedFile
|
||||
3. Only split parts remain on disk
|
||||
4. Set mergePhase = 'done', status = 'completed', progress = 100
|
||||
5. saveQueue()
|
||||
```
|
||||
|
||||
### Progress Weighting
|
||||
|
||||
| Phase | Weight | Display |
|
||||
|-------------|--------|--------------------------------------------|
|
||||
| Downloading | 70% | "VOD 1/2 wird heruntergeladen — 45%" |
|
||||
| Merging | 20% | "Zusammenfugen... 60%" |
|
||||
| Splitting | 10% | "Part 2/5 wird erstellt..." |
|
||||
| Cleanup | 0% | "Aufraumen..." (instant) |
|
||||
|
||||
Overall progress = phase_base + (phase_progress × phase_weight)
|
||||
|
||||
Example for 2 VODs: During download of VOD 2 at 50%:
|
||||
- VOD 1 complete = 35% (50% of 70%)
|
||||
- VOD 2 at 50% = 17.5% (50% of remaining 35%)
|
||||
- Total: 52.5%
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Changes
|
||||
|
||||
### 3a) Queue Item Checkboxes
|
||||
|
||||
- Each queue item gets a checkbox to the left of the status indicator
|
||||
- Checkbox only visible/enabled for items with `status === 'pending'`
|
||||
- Hidden for: downloading, paused, completed, error items
|
||||
- Hidden for items that are already merge groups
|
||||
|
||||
### 3b) "Merge & Split" Button
|
||||
|
||||
- Appears in the queue action bar when 2+ checkboxes are checked
|
||||
- Label: "Merge & Split (N)" where N = number of selected items
|
||||
- Clicking it:
|
||||
1. Collects selected items
|
||||
2. Sorts them chronologically by `date` (ISO timestamp comparison)
|
||||
3. Creates a new merge-group QueueItem
|
||||
4. Removes the individual items from the queue
|
||||
5. Adds the merge-group item in their place
|
||||
6. Clears all checkboxes
|
||||
7. Calls `window.api.createMergeGroup(selectedIds)` → main process
|
||||
|
||||
### 3c) Merge Group Rendering
|
||||
|
||||
A merge group in the queue looks like a regular item but visually distinct:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [merge-icon] Merge: VOD Title 1 + VOD Title 2 │
|
||||
│ 4h15m (2 VODs) | 01.03.2026 Running │
|
||||
│ VOD 1/2 wird heruntergeladen — 45.0% │
|
||||
│ ████████████░░░░░░░░░░░░░░░░░ 45% │
|
||||
│ [x]│
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Merge icon via CSS/SVG (chain-link or similar), no emoji
|
||||
- Meta line shows total duration and VOD count
|
||||
- Progress text shows current phase with detail
|
||||
- Remove button cancels and cleans up all temp files
|
||||
|
||||
### 3d) Localization Strings
|
||||
|
||||
**English (`renderer-locale-en.ts`):**
|
||||
```typescript
|
||||
mergeGroup: {
|
||||
btn: 'Merge & Split',
|
||||
phaseDownloading: 'Downloading VOD {current}/{total}',
|
||||
phaseMerging: 'Merging...',
|
||||
phaseSplitting: 'Splitting Part {current}/{total}...',
|
||||
phaseCleanup: 'Cleaning up...',
|
||||
titleTwo: 'Merge: {title1} + {title2}',
|
||||
titleMany: 'Merge: {title1} + {count} more',
|
||||
needMinTwo: 'Select at least 2 VODs',
|
||||
metaLabel: '{duration} ({count} VODs)',
|
||||
}
|
||||
```
|
||||
|
||||
**German (`renderer-locale-de.ts`):**
|
||||
```typescript
|
||||
mergeGroup: {
|
||||
btn: 'Zusammenfugen & Splitten',
|
||||
phaseDownloading: 'VOD {current}/{total} wird heruntergeladen',
|
||||
phaseMerging: 'Zusammenfugen...',
|
||||
phaseSplitting: 'Part {current}/{total} wird erstellt...',
|
||||
phaseCleanup: 'Aufraumen...',
|
||||
titleTwo: 'Merge: {title1} + {title2}',
|
||||
titleMany: 'Merge: {title1} + {count} weitere',
|
||||
needMinTwo: 'Mindestens 2 VODs auswahlen',
|
||||
metaLabel: '{duration} ({count} VODs)',
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. IPC Interface
|
||||
|
||||
### New IPC Channel: `create-merge-group`
|
||||
|
||||
```typescript
|
||||
// Preload
|
||||
createMergeGroup: (itemIds: string[]): Promise<QueueItem[]> =>
|
||||
ipcRenderer.invoke('create-merge-group', itemIds),
|
||||
|
||||
// Main process handler
|
||||
ipcMain.handle('create-merge-group', async (_, itemIds: string[]) => {
|
||||
// 1. Find items in downloadQueue by IDs
|
||||
// 2. Validate all are 'pending'
|
||||
// 3. Sort chronologically by date (ISO timestamp — handles same-day different times)
|
||||
// 4. Create MergeGroup object with downloadedFiles = {}
|
||||
// 5. Create new QueueItem with mergeGroup field
|
||||
// - url = first item's url
|
||||
// - title = generated merge title
|
||||
// - date = first item's date
|
||||
// - duration_str = sum of durations
|
||||
// 6. Remove individual items from queue
|
||||
// 7. Insert merge-group item at position of first removed item
|
||||
// 8. saveQueue() and emit 'queue-updated'
|
||||
// 9. Return updated queue
|
||||
});
|
||||
```
|
||||
|
||||
No other new IPC channels needed — progress reuses existing `download-progress` event.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| VOD download fails | Entire group → `error`. Retry skips already-downloaded files. |
|
||||
| FFmpeg merge fails | Group → `error`, phase stays `merging`. Retry re-attempts merge. |
|
||||
| FFmpeg split fails | Group → `error`, phase stays `splitting`. Retry checks if merged file exists. |
|
||||
| Disk space insufficient | Pre-check before download. Estimate: **3x** sum of VOD sizes needed (downloads + merged + split parts all on disk simultaneously before cleanup). |
|
||||
| User deletes group during download | Cancel running download, delete all temp files. |
|
||||
| App crash / restart | Queue persisted with `mergeGroup` data. Retry resumes from last phase. |
|
||||
| VODs from different streamers | Allowed. Naming uses first VOD's streamer. |
|
||||
| Different qualities/resolutions | FFmpeg re-encode fallback handles codec mismatches. |
|
||||
|
||||
### Disk Space Calculation
|
||||
|
||||
Peak disk usage occurs just before cleanup, when all three file sets coexist:
|
||||
- Individual downloads: ~1x total VOD size
|
||||
- Merged file: ~1x total VOD size
|
||||
- Split parts: ~1x total VOD size
|
||||
- **Total: ~3x sum of VOD sizes**
|
||||
|
||||
The pre-check uses `3x * estimated_total_size` with a minimum of 256 MB.
|
||||
|
||||
### Retry Logic
|
||||
|
||||
```
|
||||
On retry of a merge-group item:
|
||||
status is reset to 'pending', progress to 0
|
||||
mergeGroup.mergePhase and mergeGroup.downloadedFiles are PRESERVED
|
||||
|
||||
processDownloadMergeGroup() checks mergePhase on entry:
|
||||
|
||||
if mergePhase === 'downloading':
|
||||
For each i in items:
|
||||
if downloadedFiles[i] exists AND file on disk → skip
|
||||
else → download and store
|
||||
Then proceed to merging phase
|
||||
|
||||
if mergePhase === 'merging':
|
||||
Check if all downloadedFiles values exist on disk
|
||||
If any missing → reset mergePhase to 'downloading', restart
|
||||
If all present → re-attempt merge
|
||||
Then proceed to splitting phase
|
||||
|
||||
if mergePhase === 'splitting':
|
||||
Check if mergedFile exists on disk
|
||||
If missing → check downloadedFiles → reset to appropriate phase
|
||||
If present → re-attempt split
|
||||
Then proceed to cleanup
|
||||
|
||||
Progress is recalculated from phase on first tick of processDownloadMergeGroup():
|
||||
'downloading' → base = 0% (count already-downloaded files for offset)
|
||||
'merging' → base = 70%
|
||||
'splitting' → base = 90%
|
||||
|
||||
Note: retryFailedDownloads resets item.progress to 0. There will be a brief
|
||||
0% flash in the UI between retry-click and processDownloadMergeGroup() starting.
|
||||
This is a minor UX imperfection (< 1 second) — acceptable since the progress
|
||||
corrects itself immediately when processing begins. Fixing it would require
|
||||
special-casing merge groups in retryFailedDownloads, adding complexity for
|
||||
negligible benefit.
|
||||
```
|
||||
|
||||
### Cancel/Remove During Active Merge Group
|
||||
|
||||
```
|
||||
When user removes a merge-group item during processing:
|
||||
1. Set currentDownloadCancelled = true (kills active streamlink/ffmpeg process)
|
||||
2. Wait for process to exit
|
||||
3. Delete all temp files:
|
||||
- All files in downloadedFiles (if they exist)
|
||||
- mergedFile (if it exists)
|
||||
- Any partial output files
|
||||
4. Remove item from queue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Duration summation across multiple VODs
|
||||
- Chronological sorting by ISO timestamp (same day, different times: 16:00 vs 18:00)
|
||||
- Merge group title generation (2 items, 3+ items)
|
||||
- Progress weighting calculation (70/20/10)
|
||||
- Retry logic: phase detection, file existence checks
|
||||
- FFmpeg split argument construction (verify `-ss` before `-i`)
|
||||
- `currentDownloadCancelled` reset between VOD downloads
|
||||
|
||||
### Integration Tests
|
||||
- Create merge group via IPC → verify queue structure
|
||||
- Persist merge group → reload → verify mergeGroup data intact (including `downloadedFiles` as Record)
|
||||
- FFmpeg concat file generation (correct paths, escaping, forward slashes on Windows)
|
||||
- `download_mode` override: verify merge group downloads use 'full' mode regardless of config
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
- Checkbox visibility: only on pending items
|
||||
- Button appears/disappears based on selection count
|
||||
- Merge group renders with correct title, meta, and phase text
|
||||
- Localization: all new strings present in EN and DE
|
||||
|
||||
### Manual Test
|
||||
- 2 short VODs → Merge & Split with 5 min parts → verify parts created, temp files deleted, progress correct
|
||||
|
||||
---
|
||||
|
||||
## 7. Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `src/main.ts` | Add `processDownloadMergeGroup()`, `splitMergedFile()`, IPC handler `create-merge-group`, extend `processQueue()` branching, fix `mergeVideos()` progress formula (pass totalDurationSec), force `download_mode='full'` for merge groups, reset `currentDownloadCancelled` per sub-download |
|
||||
| `src/preload.ts` | Add `createMergeGroup` to API surface, add `mergeGroup?: MergeGroup` to QueueItem interface |
|
||||
| `src/renderer-queue.ts` | Add checkbox rendering, selection state, "Merge & Split" button, merge-group display logic, phase-aware progress text |
|
||||
| `src/renderer-shared.ts` | Add `selectedQueueIds: Set<string>` global state |
|
||||
| `src/renderer-locale-en.ts` | Add `mergeGroup` string block |
|
||||
| `src/renderer-locale-de.ts` | Add `mergeGroup` string block |
|
||||
| `src/renderer-globals.d.ts` | Add `MergeGroup`, `MergeGroupItem` interfaces, extend `QueueItem` with `mergeGroup?` |
|
||||
| `src/index.html` | Add merge-group button to queue action bar, CSS for checkboxes and merge-group items |
|
||||
|
||||
---
|
||||
|
||||
## 8. Review Changelog
|
||||
|
||||
### Revision 2 (first spec review — 9 issues fixed):
|
||||
|
||||
1. **`currentDownloadCancelled` reset** — Added explicit reset to `false` before each VOD download in Phase 1
|
||||
2. **Windows path escaping** — Added note to normalize backslashes to forward slashes in concat file
|
||||
3. **`mergeVideos()` progress bug** — Fixed: pass `totalDurationSec` for correct percentage calculation
|
||||
4. **Retry progress reset** — Defined phase-based progress recalculation on retry resume
|
||||
5. **`-ss` after `-i` slow seeking** — Fixed: `-ss` before `-i` matching existing `cutVideo()` pattern
|
||||
6. **`downloadedFiles` indexing** — Changed from `string[]` to `Record<number, string>` (sparse map by VOD index)
|
||||
7. **Missing `mergeGroup` in preload.ts** — Added to files-to-modify table
|
||||
8. **Disk space estimate** — Changed from 2.2x to 3x to account for all three file sets coexisting
|
||||
9. **`download_mode` conflict** — Force `download_mode = 'full'` for merge group downloads
|
||||
|
||||
### Revision 3 (second spec review — 5 issues fixed):
|
||||
|
||||
10. **`download_mode` override race condition** — Changed approach: call `downloadVODPart()` directly instead of `downloadVOD()`, avoiding global config mutation. Merge-group function replicates necessary setup (tool checks, folder, disk space, filename).
|
||||
11. **`mergeVideos()` signature change breaks standalone IPC** — Made `totalDurationSec` an optional parameter with `ffprobe` fallback for the standalone Merge Videos tab. Existing IPC surface unchanged.
|
||||
12. **Retry progress 0% flash** — Acknowledged as minor UX gap (< 1 second). Documenting rather than adding complexity to fix.
|
||||
13. **`MergeGroup` types not in preload.ts scope** — Added explicit type-sharing strategy: duplicate interfaces in all three files (follows existing `QueueItem`/`CustomClip` pattern).
|
||||
14. **`Object.values()` implicit key ordering** — Changed to explicit sort: `Object.keys().sort().map()`.
|
||||
@ -1,25 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import security from 'eslint-plugin-security';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
security.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
// Tune down noisy rules for existing codebase
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': 'off',
|
||||
'security/detect-object-injection': 'off', // Too many false positives with Record types
|
||||
'security/detect-non-literal-fs-filename': 'off', // All paths come from controlled sources
|
||||
'no-async-promise-executor': 'warn',
|
||||
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tmp_*/**']
|
||||
}
|
||||
];
|
||||
1193
package-lock.json
generated
1193
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.6.155",
|
||||
"version": "4.2.4",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
@ -9,31 +9,25 @@
|
||||
"build": "tsc",
|
||||
"start": "npm run build && electron .",
|
||||
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
|
||||
"test:e2e": "node scripts/smoke-test.js",
|
||||
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "node scripts/smoke-test-full.js",
|
||||
"test:e2e": "npm exec --yes --package=playwright -- node scripts/smoke-test.js",
|
||||
"test:e2e:guide": "npm exec --yes --package=playwright -- node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "npm exec --yes --package=playwright -- node scripts/smoke-test-full.js",
|
||||
"test:e2e:release": "npm run build && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
||||
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run test:e2e:release && electron-builder --win",
|
||||
"release:gitea": "node scripts/release_gitea.mjs",
|
||||
"test:merge-split": "node scripts/smoke-test-merge-split-logic.js"
|
||||
"release:gitea": "node scripts/release_gitea.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"electron-updater": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^20.10.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"playwright": "^1.59.1",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript-eslint": "^8.57.1"
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "de.24-music.twitch-vod-manager",
|
||||
|
||||
@ -28,13 +28,10 @@ function parseArgs(argv) {
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
return { help: true };
|
||||
}
|
||||
const FLAGS = new Set(["--dry-run", "--skip-build"]);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const skipBuild = args.includes("--skip-build");
|
||||
const positional = args.filter((arg) => !FLAGS.has(arg));
|
||||
const version = positional[0] || "";
|
||||
const notes = positional.slice(1).join(" ").trim();
|
||||
return { help: false, dryRun, skipBuild, version, notes };
|
||||
const version = args.find((arg) => arg !== "--dry-run") || "";
|
||||
const notes = args.filter((arg) => arg !== "--dry-run").slice(1).join(" ").trim();
|
||||
return { help: false, dryRun, version, notes };
|
||||
}
|
||||
|
||||
function ensureVersion(version) {
|
||||
@ -125,22 +122,10 @@ async function uploadAssets(baseApi, releaseId, authHeader, releaseDir, files) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasAllArtifactsForVersion(version) {
|
||||
const releaseDir = path.join(process.cwd(), "release");
|
||||
const files = [
|
||||
`Twitch-VOD-Manager-Setup-${version}.exe`,
|
||||
`Twitch-VOD-Manager-Setup-${version}.exe.blockmap`,
|
||||
"latest.yml"
|
||||
];
|
||||
return files.every((f) => fs.existsSync(path.join(releaseDir, f)));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--skip-build] [--dry-run]\n");
|
||||
process.stdout.write(" --skip-build skip dist:win when release/ already has the 3 required artifacts\n");
|
||||
process.stdout.write(" (auto-skipped when artifacts already exist for this version)\n");
|
||||
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--dry-run]\n");
|
||||
process.stdout.write("Env: GITEA_BASE_URL, GITEA_REPO_OWNER, GITEA_REPO_NAME, GITEA_TOKEN\n");
|
||||
return;
|
||||
}
|
||||
@ -156,20 +141,7 @@ async function main() {
|
||||
run("git", ["push", "origin", tag]);
|
||||
}
|
||||
|
||||
// Skip the rebuild when the user passed --skip-build OR when all artifacts
|
||||
// for this version are already on disk. The original unconditional dist:win
|
||||
// re-ran the full test suite + electron-builder even when the .exe already
|
||||
// existed, which made the script unusable when test:e2e was broken.
|
||||
const artifactsExist = hasAllArtifactsForVersion(version);
|
||||
const shouldBuild = !args.skipBuild && !artifactsExist;
|
||||
if (shouldBuild) {
|
||||
run(NPM_EXECUTABLE, ["run", "dist:win"]);
|
||||
} else if (artifactsExist) {
|
||||
process.stdout.write(`Skipping dist:win — artifacts for ${tag} already exist in release/\n`);
|
||||
} else {
|
||||
process.stdout.write(`Skipping dist:win (--skip-build)\n`);
|
||||
}
|
||||
|
||||
run(NPM_EXECUTABLE, ["run", "dist:win"]);
|
||||
const assets = ensureAssets(version);
|
||||
if (args.dryRun) {
|
||||
process.stdout.write(`Dry run complete for ${tag}\n`);
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
function run() {
|
||||
const failures = [];
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) failures.push(message);
|
||||
};
|
||||
|
||||
// ---- Test 1: parseDuration summation ----
|
||||
function parseDuration(duration) {
|
||||
let seconds = 0;
|
||||
const hours = duration.match(/(\d+)h/);
|
||||
const minutes = duration.match(/(\d+)m/);
|
||||
const secs = duration.match(/(\d+)s/);
|
||||
if (hours) seconds += parseInt(hours[1]) * 3600;
|
||||
if (minutes) seconds += parseInt(minutes[1]) * 60;
|
||||
if (secs) seconds += parseInt(secs[1]);
|
||||
return seconds;
|
||||
}
|
||||
|
||||
const vods = [
|
||||
{ duration_str: '2h30m0s' },
|
||||
{ duration_str: '1h45m30s' }
|
||||
];
|
||||
const totalDuration = vods.reduce((sum, v) => sum + parseDuration(v.duration_str), 0);
|
||||
assert(totalDuration === 15330, `Duration sum: expected 15330, got ${totalDuration}`);
|
||||
|
||||
// ---- Test 2: Chronological sort by ISO timestamp ----
|
||||
const items = [
|
||||
{ date: '2026-03-01T18:00:00Z', title: 'Evening' },
|
||||
{ date: '2026-03-01T16:00:00Z', title: 'Afternoon' },
|
||||
{ date: '2026-03-02T10:00:00Z', title: 'Next Day' }
|
||||
];
|
||||
const sorted = [...items].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
assert(sorted[0].title === 'Afternoon', `Sort[0]: expected Afternoon, got ${sorted[0].title}`);
|
||||
assert(sorted[1].title === 'Evening', `Sort[1]: expected Evening, got ${sorted[1].title}`);
|
||||
assert(sorted[2].title === 'Next Day', `Sort[2]: expected Next Day, got ${sorted[2].title}`);
|
||||
|
||||
// ---- Test 3: Same day, different times ----
|
||||
const sameDay = [
|
||||
{ date: '2026-03-01T18:30:00Z', title: 'Later' },
|
||||
{ date: '2026-03-01T16:15:00Z', title: 'Earlier' }
|
||||
];
|
||||
const sortedSameDay = [...sameDay].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
assert(sortedSameDay[0].title === 'Earlier', `SameDay[0]: expected Earlier, got ${sortedSameDay[0].title}`);
|
||||
assert(sortedSameDay[1].title === 'Later', `SameDay[1]: expected Later, got ${sortedSameDay[1].title}`);
|
||||
|
||||
// ---- Test 4: Merge group title generation ----
|
||||
function makeMergeTitle(items, isEnglish) {
|
||||
if (items.length === 2) return `Merge: ${items[0].title} + ${items[1].title}`;
|
||||
return `Merge: ${items[0].title} + ${items.length - 1} ${isEnglish ? 'more' : 'weitere'}`;
|
||||
}
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }], true) === 'Merge: A + B',
|
||||
'Title 2 items failed'
|
||||
);
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], false) === 'Merge: A + 2 weitere',
|
||||
'Title 3 items DE failed'
|
||||
);
|
||||
assert(
|
||||
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], true) === 'Merge: A + 2 more',
|
||||
'Title 3 items EN failed'
|
||||
);
|
||||
|
||||
// ---- Test 5: Progress weighting (70/20/10) ----
|
||||
const totalSec = 10800; // 180min
|
||||
const vod1Dur = 3600; // 60min
|
||||
const vod2Dur = 7200; // 120min
|
||||
const vod1Weight = vod1Dur / totalSec;
|
||||
const vod2Weight = vod2Dur / totalSec;
|
||||
const priorWeight = vod1Weight;
|
||||
const vodProgress = 50;
|
||||
const overallProgress = (priorWeight + vod2Weight * (vodProgress / 100)) * 70;
|
||||
assert(
|
||||
Math.abs(overallProgress - 46.67) < 0.1,
|
||||
`Progress weighting: expected ~46.67, got ${overallProgress}`
|
||||
);
|
||||
|
||||
// ---- Test 6: Split part count ----
|
||||
const partMinutes = 60;
|
||||
const mergedDuration = 15330; // 4h15m30s
|
||||
const numParts = Math.ceil(mergedDuration / (partMinutes * 60));
|
||||
assert(numParts === 5, `Split parts: expected 5, got ${numParts}`);
|
||||
|
||||
// ---- Test 7: Object.keys explicit sort for downloadedFiles ----
|
||||
const downloadedFiles = { 2: '/path/c.mp4', 0: '/path/a.mp4', 1: '/path/b.mp4' };
|
||||
const sortedPaths = Object.keys(downloadedFiles)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(k => downloadedFiles[Number(k)]);
|
||||
assert(sortedPaths[0] === '/path/a.mp4', `Sort files[0]: expected a.mp4, got ${sortedPaths[0]}`);
|
||||
assert(sortedPaths[1] === '/path/b.mp4', `Sort files[1]: expected b.mp4, got ${sortedPaths[1]}`);
|
||||
assert(sortedPaths[2] === '/path/c.mp4', `Sort files[2]: expected c.mp4, got ${sortedPaths[2]}`);
|
||||
|
||||
// ---- Test 8: FFmpeg split args order (-ss before -i) ----
|
||||
function buildSplitArgs(startSec, inputFile, durationSec) {
|
||||
const formatDur = (s) => {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
return ['-ss', formatDur(startSec), '-i', inputFile, '-t', formatDur(durationSec), '-c', 'copy', '-y', 'out.mp4'];
|
||||
}
|
||||
const args = buildSplitArgs(3600, 'input.mp4', 3600);
|
||||
const ssIndex = args.indexOf('-ss');
|
||||
const iIndex = args.indexOf('-i');
|
||||
assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`);
|
||||
|
||||
// ---- Test 9: ensureUniqueFilename pattern ----
|
||||
function ensureUnique(base, ext, existingFiles) {
|
||||
let candidate = base + ext;
|
||||
if (!existingFiles.includes(candidate)) return candidate;
|
||||
let counter = 1;
|
||||
while (existingFiles.includes(candidate)) {
|
||||
candidate = `${base}_${counter}${ext}`;
|
||||
counter++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
assert(ensureUnique('video', '.mp4', []) === 'video.mp4', 'Unique: no conflict');
|
||||
assert(ensureUnique('video', '.mp4', ['video.mp4']) === 'video_1.mp4', 'Unique: one conflict');
|
||||
assert(ensureUnique('video', '.mp4', ['video.mp4', 'video_1.mp4']) === 'video_2.mp4', 'Unique: two conflicts');
|
||||
|
||||
// ---- Results ----
|
||||
if (failures.length > 0) {
|
||||
console.error(`FAIL: ${failures.length} test(s) failed:`);
|
||||
failures.forEach(f => console.error(` - ${f}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('All merge-split logic tests passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
610
src/index.html
610
src/index.html
@ -10,23 +10,23 @@
|
||||
<body class="theme-twitch">
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateText">Neue Version verfügbar!</span>
|
||||
<div id="updateProgress" class="update-banner-progress-wrap is-hidden">
|
||||
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
|
||||
<div id="updateProgressBar" class="update-banner-progress-bar"></div>
|
||||
<div id="updateProgress" style="display: none; flex: 1; margin: 0 15px;">
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 4px; height: 8px; overflow: hidden;">
|
||||
<div id="updateProgressBar" style="background: white; height: 100%; width: 0%; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
|
||||
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal-overlay" id="updateModal" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal update-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
|
||||
<button class="modal-close" onclick="dismissUpdateModal()">x</button>
|
||||
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
|
||||
<h2 id="updateModalTitle">Update verfugbar</h2>
|
||||
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
|
||||
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
|
||||
<div class="update-modal-meta" id="updateModalMeta" style="display:none;"></div>
|
||||
|
||||
<div class="update-changelog-card is-hidden" id="updateChangelogCard">
|
||||
<div class="update-changelog-card" id="updateChangelogCard" style="display:none;">
|
||||
<div class="update-changelog-header">
|
||||
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
|
||||
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
|
||||
@ -39,117 +39,111 @@
|
||||
|
||||
<div class="modal-actions update-modal-actions">
|
||||
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
|
||||
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version ueberspringen</button>
|
||||
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clip Dialog Modal -->
|
||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
|
||||
<div class="modal clip-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
|
||||
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
|
||||
<div class="modal-overlay" id="clipModal">
|
||||
<div class="modal" style="background: #2b2b2b; max-width: 500px;">
|
||||
<button class="modal-close" onclick="closeClipDialog()">x</button>
|
||||
<h2 style="color: #E5A00D; text-align: center; margin-bottom: 20px;" id="clipDialogTitle">Clip zuschneiden</h2>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogStartLabel" for="clipStartSlider">Start:</label>
|
||||
<input type="range" id="clipStartSlider" min="0" max="100" value="0" oninput="updateFromSlider('start')">
|
||||
<div class="clip-modal-time-row">
|
||||
<label class="clip-modal-meta" id="clipDialogStartTimeLabel" for="clipStartTime">Startzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipStartTime" value="00:00:00" class="clip-modal-time-input" onchange="updateFromInput('start')">
|
||||
<!-- Start Zeit mit Slider -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;">Start:</label>
|
||||
<input type="range" id="clipStartSlider" min="0" max="100" value="0"
|
||||
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
|
||||
oninput="updateFromSlider('start')">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<label style="color: #888;">Startzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipStartTime" value="00:00:00"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
|
||||
onchange="updateFromInput('start')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogEndLabel" for="clipEndSlider">Ende:</label>
|
||||
<input type="range" id="clipEndSlider" min="0" max="100" value="60" oninput="updateFromSlider('end')">
|
||||
<div class="clip-modal-time-row">
|
||||
<label class="clip-modal-meta" id="clipDialogEndTimeLabel" for="clipEndTime">Endzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipEndTime" value="00:01:00" class="clip-modal-time-input" onchange="updateFromInput('end')">
|
||||
<!-- End Zeit mit Slider -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;">Ende:</label>
|
||||
<input type="range" id="clipEndSlider" min="0" max="100" value="60"
|
||||
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
|
||||
oninput="updateFromSlider('end')">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<label style="color: #888;">Endzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipEndTime" value="00:01:00"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
|
||||
onchange="updateFromInput('end')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-duration">
|
||||
<span id="clipDialogDurationLabel" class="clip-modal-meta">Dauer: </span>
|
||||
<span id="clipDurationDisplay" class="clip-modal-duration-value">00:01:00</span>
|
||||
<!-- Dauer Anzeige -->
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<span style="color: #888;">Dauer: </span>
|
||||
<span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label>
|
||||
<input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()">
|
||||
<div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div>
|
||||
<!-- Teil Nummer -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label>
|
||||
<input type="text" id="clipStartPart" placeholder="z.B. 42"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white;"
|
||||
oninput="updateFilenameExamples()">
|
||||
<div style="color: #888; font-size: 12px; margin-top: 5px;">Leer lassen = Teil 1</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogFormatLabel">Dateinamen-Format:</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()">
|
||||
<span id="formatSimple" class="clip-radio-label">01.02.2026_1.mp4 (Standard)</span>
|
||||
<!-- Dateinamen Format -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()">
|
||||
<span id="formatTimestamp" class="clip-radio-label">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="parts" onchange="updateFilenameExamples()">
|
||||
<span id="formatParts" class="clip-radio-label">01.02.2026_Part01.mp4 (Parts-Format)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()">
|
||||
<span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
|
||||
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatTemplate" style="color: #aaa;">{date}_{part}.mp4 (benutzerdefiniert)</span>
|
||||
</label>
|
||||
|
||||
<div id="clipFilenameTemplateWrap" class="clip-template-wrap">
|
||||
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
|
||||
<div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div>
|
||||
<button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button>
|
||||
<div id="clipFilenameTemplateWrap" style="display:none; margin-top: 10px;">
|
||||
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4"
|
||||
placeholder="{date}_{part}.mp4"
|
||||
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
|
||||
oninput="updateFilenameExamples()">
|
||||
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="clipTemplateLint" style="color: #8bc34a; font-size: 12px; margin-top: 4px;">Template-Check: OK</div>
|
||||
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clip-modal-actions">
|
||||
<button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
|
||||
<!-- Button -->
|
||||
<div style="text-align: center;">
|
||||
<button class="btn-primary" style="background: #00c853; padding: 12px 30px; border: none; border-radius: 4px; color: white; font-weight: 600; cursor: pointer;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Viewer Modal -->
|
||||
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-events">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
|
||||
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
|
||||
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div>
|
||||
<div id="eventsViewerList" class="viewer-modal-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Replay Viewer Modal -->
|
||||
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-chat">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
|
||||
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
|
||||
<div class="viewer-modal-filter-row">
|
||||
<input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()">
|
||||
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Guide Modal -->
|
||||
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
|
||||
<div class="modal-overlay" id="templateGuideModal">
|
||||
<div class="modal template-guide-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
|
||||
<button class="modal-close" onclick="closeTemplateGuide()">x</button>
|
||||
<h2 id="templateGuideTitle">Template Guide</h2>
|
||||
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
|
||||
|
||||
<div class="template-guide-actions">
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
|
||||
</div>
|
||||
|
||||
<label id="templateGuideTemplateLabel" for="templateGuideInput" class="template-guide-label">Template</label>
|
||||
<label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
|
||||
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
|
||||
|
||||
<div class="template-guide-preview-box">
|
||||
@ -160,12 +154,12 @@
|
||||
|
||||
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
|
||||
<div class="template-guide-table-wrap">
|
||||
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
|
||||
<table class="template-guide-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="templateGuideVarCol" scope="col">Variable</th>
|
||||
<th id="templateGuideDescCol" scope="col">Beschreibung</th>
|
||||
<th id="templateGuideExampleCol" scope="col">Beispiel</th>
|
||||
<th id="templateGuideVarCol">Variable</th>
|
||||
<th id="templateGuideDescCol">Beschreibung</th>
|
||||
<th id="templateGuideExampleCol">Beispiel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="templateGuideBody"></tbody>
|
||||
@ -173,7 +167,7 @@
|
||||
</div>
|
||||
|
||||
<div class="template-guide-footer">
|
||||
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
|
||||
<button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -181,49 +175,34 @@
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
<svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
<span id="logoText">Twitch VOD Manager</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<span id="navVodsText">Twitch VODs</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<div class="nav-item" data-tab="clips" onclick="showTab('clips')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<span id="navClipsText">Twitch Clips</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||
<span id="navCutterText">Video schneiden</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||
<div class="nav-item" data-tab="merge" onclick="showTab('merge')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||
<span id="navMergeText">Videos zusammenfugen</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
|
||||
<span id="navStatsText">Statistik</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<span id="navArchiveText">Archiv</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<span id="navSettingsText">Einstellungen</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="section-title" id="streamerSectionTitle">
|
||||
<span class="section-title-label">
|
||||
<span id="streamerSectionTitleText">Streamer</span>
|
||||
<span id="streamerSectionCounter" class="streamer-section-counter"></span>
|
||||
</span>
|
||||
<button id="btnStreamerBulkRemove" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button>
|
||||
</div>
|
||||
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
|
||||
<div class="section-title">Streamer</div>
|
||||
<div class="streamers" id="streamerList"></div>
|
||||
|
||||
<div class="queue-section">
|
||||
@ -233,13 +212,11 @@
|
||||
</div>
|
||||
<div class="queue-list" id="queueList"></div>
|
||||
<div class="queue-actions">
|
||||
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge & Split</button>
|
||||
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
|
||||
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
|
||||
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar" id="statsBar"></div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
@ -248,10 +225,10 @@
|
||||
<div class="header-actions">
|
||||
<div class="header-search">
|
||||
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
|
||||
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
|
||||
<button onclick="addStreamer()">+</button>
|
||||
</div>
|
||||
<button type="button" class="btn-icon" onclick="refreshVODs()">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<button class="btn-icon" onclick="refreshVODs()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<span id="refreshText">Aktualisieren</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -260,37 +237,11 @@
|
||||
<div class="content">
|
||||
<!-- VODs Tab -->
|
||||
<div class="tab-content active" id="vodsTab">
|
||||
<div id="streamerProfileHeader" class="streamer-profile-header is-hidden"></div>
|
||||
<div class="vod-filter-row">
|
||||
<input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()">
|
||||
<button type="button" id="vodFilterClearBtn" class="btn-close is-hidden" onclick="clearVodFilter()" title="Clear filter">x</button>
|
||||
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
|
||||
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
|
||||
<option value="date_desc">Newest first</option>
|
||||
<option value="date_asc">Oldest first</option>
|
||||
<option value="views_desc">Most viewed</option>
|
||||
<option value="duration_desc">Longest first</option>
|
||||
<option value="duration_asc">Shortest first</option>
|
||||
</select>
|
||||
<span id="vodFilterCount" class="form-sublabel vod-filter-count"></span>
|
||||
<label id="vodHideDownloadedLabel" class="inline-toggle" title="">
|
||||
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
|
||||
<span id="vodHideDownloadedText">Hide downloaded</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="vodBulkBar" class="vod-bulk-bar is-hidden">
|
||||
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
|
||||
<span class="vod-bulk-spacer"></span>
|
||||
<button id="vodBulkAddBtn" class="btn-pill primary" type="button" onclick="bulkAddSelectedVodsToQueue()">+ Queue</button>
|
||||
<button id="vodBulkMarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(true)">Mark as downloaded</button>
|
||||
<button id="vodBulkUnmarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(false)">Unmark</button>
|
||||
<button id="vodBulkClearBtn" class="btn-pill" type="button" onclick="clearVodSelection()">Clear</button>
|
||||
</div>
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
<div class="empty-state">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||
<h3 id="vodGridEmptyTitle">Keine VODs</h3>
|
||||
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
||||
<h3>Keine VODs</h3>
|
||||
<p>Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -300,13 +251,13 @@
|
||||
<div class="clip-input">
|
||||
<h2 id="clipsHeading">Twitch Clip-Download</h2>
|
||||
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
|
||||
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
|
||||
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||
<div class="clip-status" id="clipStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card centered">
|
||||
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
|
||||
<h3 id="clipsInfoTitle">Info</h3>
|
||||
<p id="clipsInfoText" class="info-text">
|
||||
<p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
|
||||
Unterstutzte Formate:
|
||||
- https://clips.twitch.tv/ClipName
|
||||
- https://www.twitch.tv/streamer/clip/ClipName
|
||||
@ -323,37 +274,37 @@
|
||||
<h3 id="cutterSelectTitle">Video auswahlen</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
|
||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
<button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-preview" id="cutterPreview">
|
||||
<div class="placeholder">
|
||||
<svg aria-hidden="true" width="64" height="64" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p>Video auswahlen um Vorschau zu sehen</p>
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-info" id="cutterInfo">
|
||||
<div class="cutter-info" id="cutterInfo" style="display:none">
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
|
||||
<span class="cutter-info-label">Dauer</span>
|
||||
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Aufloesung</span>
|
||||
<span class="cutter-info-label">Auflosung</span>
|
||||
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
|
||||
<span class="cutter-info-label">FPS</span>
|
||||
<span class="cutter-info-value" id="infoFps">--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
|
||||
<span class="cutter-info-label">Auswahl</span>
|
||||
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container" id="timelineContainer">
|
||||
<div class="timeline-container" id="timelineContainer" style="display:none">
|
||||
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
|
||||
<div class="timeline-selection" id="timelineSelection"></div>
|
||||
<div class="timeline-current" id="timelineCurrent"></div>
|
||||
@ -361,25 +312,25 @@
|
||||
|
||||
<div class="time-inputs">
|
||||
<div class="time-input-group">
|
||||
<label id="cutterStartLabel" for="startTime">Start:</label>
|
||||
<label>Start:</label>
|
||||
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
<div class="time-input-group">
|
||||
<label id="cutterEndLabel" for="endTime">Ende:</label>
|
||||
<label>Ende:</label>
|
||||
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="cutProgress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Cut progress" id="cutProgressGauge">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="cutProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="cutProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-actions">
|
||||
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
<button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -389,115 +340,49 @@
|
||||
<div class="merge-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="mergeTitle">Videos zusammenfugen</h3>
|
||||
<p id="mergeDesc" class="card-intro">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
|
||||
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
|
||||
Die Reihenfolge kann per Drag & Drop geandert werden.
|
||||
</p>
|
||||
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||
<button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="mergeFileList">
|
||||
<div class="empty-state merge-empty-state">
|
||||
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p id="mergeEmptyText">Keine Videos ausgewahlt</p>
|
||||
<div class="empty-state" style="padding: 40px 20px;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="mergeProgress">
|
||||
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Merge progress" id="mergeProgressGauge">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill" id="mergeProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="mergeProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="merge-actions">
|
||||
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||
<button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Tab -->
|
||||
<div class="tab-content" id="statsTab">
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="statsTitle">Archiv-Statistik</h3>
|
||||
<div class="section-header-actions">
|
||||
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span>
|
||||
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsSummaryTitle">Uebersicht</h3>
|
||||
<div id="statsSummaryGrid" class="stats-summary-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsTopStreamersTitle">Top Streamer (nach Groesse)</h3>
|
||||
<div id="statsTopStreamers"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsActivityTitle">Aktivitaet (letzte 30 Tage)</h3>
|
||||
<div id="statsActivity"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsSizeBucketsTitle">Aufnahme-Groessen-Verteilung</h3>
|
||||
<div id="statsSizeBuckets"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Search Tab -->
|
||||
<div class="tab-content" id="archiveTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="archiveTitle">Archiv durchsuchen</h3>
|
||||
<p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
|
||||
<div class="form-row search-bar">
|
||||
<input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche...">
|
||||
<select id="archiveSearchType" class="select-compact">
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="live">Live-Aufnahmen</option>
|
||||
<option value="vod">VOD-Downloads</option>
|
||||
</select>
|
||||
<select id="archiveSearchStreamer" class="select-compact size-md">
|
||||
<option value="">Alle Streamer</option>
|
||||
</select>
|
||||
<select id="archiveSearchSort" class="select-compact">
|
||||
<option value="date_desc">Neueste zuerst</option>
|
||||
<option value="date_asc">Aelteste zuerst</option>
|
||||
<option value="size_desc">Groesste zuerst</option>
|
||||
<option value="size_asc">Kleinste zuerst</option>
|
||||
<option value="name_asc">Name (A-Z)</option>
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
|
||||
</div>
|
||||
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div id="archiveSearchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" id="settingsTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="designTitle">Design</h3>
|
||||
<div class="form-group">
|
||||
<label id="themeLabel" for="themeSelect">Theme</label>
|
||||
<label id="themeLabel">Theme</label>
|
||||
<select id="themeSelect" onchange="changeTheme(this.value)">
|
||||
<option value="twitch">Twitch</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
<option value="apple">Apple</option>
|
||||
<option value="light" id="themeLightOption">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="languageLabel">Sprache</label>
|
||||
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
|
||||
<div class="language-picker" id="languagePicker">
|
||||
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
|
||||
<span class="flag-icon flag-de" aria-hidden="true"></span>
|
||||
<span id="languageDeText">Deutsch</span>
|
||||
@ -516,63 +401,40 @@
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="apiTitle">Twitch API</h3>
|
||||
<p id="apiHelpText" class="card-intro">
|
||||
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
|
||||
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()">dev.twitch.tv/console/apps</a>
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label id="clientIdLabel" for="clientId">Client ID</label>
|
||||
<label id="clientIdLabel">Client ID</label>
|
||||
<input type="text" id="clientId" placeholder="Twitch Client ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="clientSecretLabel" for="clientSecret">Client Secret</label>
|
||||
<label id="clientSecretLabel">Client Secret</label>
|
||||
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
|
||||
<button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
|
||||
<div class="form-group">
|
||||
<label id="storageLabel" for="downloadPath">Speicherort</label>
|
||||
<label id="storageLabel">Speicherort</label>
|
||||
<div class="form-row">
|
||||
<input type="text" id="downloadPath" readonly>
|
||||
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
|
||||
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="modeLabel" for="downloadMode">Download-Modus</label>
|
||||
<label id="modeLabel">Download-Modus</label>
|
||||
<select id="downloadMode">
|
||||
<option value="full" id="modeFullText">Ganzes VOD</option>
|
||||
<option value="parts" id="modePartsText">In Teile splitten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
|
||||
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
|
||||
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
|
||||
<select id="parallelDownloads">
|
||||
<option value="1" id="parallelDownloads1">1 (Standard)</option>
|
||||
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label>
|
||||
<select id="streamlinkQuality">
|
||||
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
|
||||
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
|
||||
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
|
||||
<option value="720p60" id="streamlinkQuality720p60">720p60</option>
|
||||
<option value="720p" id="streamlinkQuality720p">720p</option>
|
||||
<option value="480p" id="streamlinkQuality480p">480p</option>
|
||||
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
|
||||
<label id="performanceModeLabel">Performance-Profil</label>
|
||||
<select id="performanceMode">
|
||||
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
|
||||
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
|
||||
@ -580,62 +442,26 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<label style="display:flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" id="smartSchedulerToggle" checked>
|
||||
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||
<input type="checkbox" id="duplicatePreventionToggle" checked>
|
||||
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||
<input type="checkbox" id="persistQueueToggle" checked>
|
||||
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoResumeQueueToggle">
|
||||
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="notifyEachCompletionToggle">
|
||||
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="streamlinkDisableAdsToggle" checked>
|
||||
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="downloadChatReplayToggle">
|
||||
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="captureLiveChatToggle">
|
||||
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="logStreamEventsToggle" checked>
|
||||
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
|
||||
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoMergeResumedPartsToggle">
|
||||
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
|
||||
</label>
|
||||
<label class="toggle-row indented">
|
||||
<input type="checkbox" id="deletePartsAfterMergeToggle">
|
||||
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
|
||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
|
||||
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
|
||||
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
|
||||
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
|
||||
</div>
|
||||
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
|
||||
@ -643,45 +469,44 @@
|
||||
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
|
||||
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
|
||||
</div>
|
||||
<div class="filename-template-grid">
|
||||
<label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
|
||||
<input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
|
||||
<div style="display: grid; gap: 8px; margin-top: 8px;">
|
||||
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
|
||||
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
|
||||
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
|
||||
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
|
||||
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
|
||||
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
|
||||
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
|
||||
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
</div>
|
||||
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
|
||||
<div id="filenameTemplateHint" style="color: #888; font-size: 12px; margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="filenameTemplateLint" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="updateTitle">Updates</h3>
|
||||
<p id="versionInfo" class="card-intro">Version: v4.1.13</p>
|
||||
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p>
|
||||
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="preflightTitle">System-Check</h3>
|
||||
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
|
||||
<h3 id="preflightTitle" style="margin: 0;">System-Check</h3>
|
||||
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom: 10px;">
|
||||
<button type="button" class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
|
||||
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
|
||||
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
</div>
|
||||
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="debugLogTitle">Live Debug-Log</h3>
|
||||
<div class="form-row aligned">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
|
||||
<label class="inline-toggle">
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
|
||||
<span id="autoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
@ -689,108 +514,12 @@
|
||||
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="storageCardTitle">Storage</h3>
|
||||
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
|
||||
</div>
|
||||
<p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
|
||||
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></div>
|
||||
<div id="storageList"></div>
|
||||
|
||||
<hr>
|
||||
<h4 id="cleanupTitle">Auto-Cleanup</h4>
|
||||
<p id="cleanupIntro" class="card-intro">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
|
||||
<label class="toggle-row" style="margin-bottom: 8px;">
|
||||
<input type="checkbox" id="autoCleanupEnabledToggle">
|
||||
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
|
||||
</label>
|
||||
<div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;">
|
||||
<label class="form-stack size-sm">
|
||||
<span id="autoCleanupDaysLabel" class="form-sublabel">Tage-Schwelle</span>
|
||||
<input type="number" id="autoCleanupDays" min="1" max="3650" value="30">
|
||||
</label>
|
||||
<label class="form-stack size-md">
|
||||
<span id="autoCleanupTargetLabel" class="form-sublabel">Bereich</span>
|
||||
<select id="autoCleanupTarget">
|
||||
<option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option>
|
||||
<option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-stack size-md">
|
||||
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
|
||||
<select id="autoCleanupAction">
|
||||
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
|
||||
<option value="delete" id="autoCleanupActionDelete">Loeschen</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
|
||||
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
|
||||
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
|
||||
</div>
|
||||
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="discordCardTitle">Discord-Webhook</h3>
|
||||
<p id="discordCardIntro" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
|
||||
<div class="form-group">
|
||||
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
|
||||
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyLiveStartToggle">
|
||||
<span id="discordNotifyLiveStartLabel">Bei Live-Aufnahme-Start benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyLiveEndToggle">
|
||||
<span id="discordNotifyLiveEndLabel">Bei Live-Aufnahme-Ende benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyVodCompleteToggle">
|
||||
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyVodAutoQueuedToggle">
|
||||
<span id="discordNotifyVodAutoQueuedLabel">Bei automatisch eingereihten VODs benachrichtigen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
|
||||
<p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
|
||||
<div class="form-row aligned">
|
||||
<label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label>
|
||||
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" class="input-narrow">
|
||||
<label id="autoVodMaxAgeHoursLabel" class="form-sublabel" for="autoVodMaxAgeHours" style="margin-left:12px;">Max. Alter (Stunden)</label>
|
||||
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" class="input-narrow">
|
||||
</div>
|
||||
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
|
||||
<button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
|
||||
<span id="autoVodStatusLine" class="form-sublabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="backupCardTitle">Sicherung & Wartung</h3>
|
||||
<p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
|
||||
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
|
||||
<div class="form-row aligned">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label class="inline-toggle">
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
|
||||
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
@ -802,11 +531,10 @@
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" id="statusDot" aria-hidden="true"></div>
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Nicht verbunden</span>
|
||||
</div>
|
||||
<span id="statusBarQueueSummary" class="status-bar-queue-summary"></span>
|
||||
<span id="versionText" class="status-bar-version"></span>
|
||||
<span id="versionText">v4.1.13</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@ -819,10 +547,6 @@
|
||||
<script src="../dist/renderer-streamers.js"></script>
|
||||
<script src="../dist/renderer-queue.js"></script>
|
||||
<script src="../dist/renderer-updates.js"></script>
|
||||
<script src="../dist/renderer-stats.js"></script>
|
||||
<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.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
5112
src/main.ts
5112
src/main.ts
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,43 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types';
|
||||
|
||||
// Types
|
||||
interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
customClip?: CustomClip;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
@ -64,13 +100,10 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
|
||||
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
|
||||
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
|
||||
retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id),
|
||||
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
|
||||
|
||||
// Download
|
||||
startDownload: () => ipcRenderer.invoke('start-download'),
|
||||
@ -85,27 +118,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
|
||||
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
|
||||
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
|
||||
openFile: (path: string) => ipcRenderer.invoke('open-file', path),
|
||||
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
|
||||
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
|
||||
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
||||
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
|
||||
getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'),
|
||||
onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => {
|
||||
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
|
||||
},
|
||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
||||
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
|
||||
triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'),
|
||||
onAutoVodScanCompleted: (callback: (info: { queuedCount: number }) => void) => {
|
||||
ipcRenderer.on('auto-vod-scan-completed', (_, info) => callback(info));
|
||||
},
|
||||
|
||||
// Video Cutter
|
||||
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
@ -128,14 +140,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
||||
ipcRenderer.invoke('reset-downloaded-vod-ids'),
|
||||
markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('mark-vod-downloaded', vodId, mark),
|
||||
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-config'),
|
||||
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('import-config'),
|
||||
|
||||
// Events
|
||||
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
let archiveStreamerSelectPopulated = false;
|
||||
let archiveSearchInFlight = false;
|
||||
let archiveSearchDebounceTimer: number | null = null;
|
||||
|
||||
function populateArchiveStreamerSelect(): void {
|
||||
if (archiveStreamerSelectPopulated) return;
|
||||
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
|
||||
const streamers = (config.streamers as string[] | undefined) || [];
|
||||
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
|
||||
const opts = sorted.map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
|
||||
applyHtml(select, `<option value="">${escapeHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
|
||||
archiveStreamerSelectPopulated = true;
|
||||
}
|
||||
|
||||
function onArchiveSearchInput(): void {
|
||||
if (archiveSearchDebounceTimer !== null) {
|
||||
window.clearTimeout(archiveSearchDebounceTimer);
|
||||
}
|
||||
// 250ms debounce — feels snappy without spamming the IO walker on
|
||||
// every keystroke. The walk is fast but pointless to repeat mid-type.
|
||||
archiveSearchDebounceTimer = window.setTimeout(() => {
|
||||
archiveSearchDebounceTimer = null;
|
||||
void performArchiveSearch();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function performArchiveSearch(): Promise<void> {
|
||||
if (archiveSearchInFlight) return;
|
||||
populateArchiveStreamerSelect();
|
||||
|
||||
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||
const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
||||
const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||
const summaryEl = document.getElementById('archiveSearchSummary');
|
||||
const resultsEl = document.getElementById('archiveSearchResults');
|
||||
const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null;
|
||||
if (!resultsEl) return;
|
||||
|
||||
archiveSearchInFlight = true;
|
||||
if (btn) btn.disabled = true;
|
||||
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...';
|
||||
|
||||
try {
|
||||
const filter = {
|
||||
query: queryEl?.value || '',
|
||||
type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'),
|
||||
streamer: streamerEl?.value || '',
|
||||
sinceMs: null,
|
||||
untilMs: null,
|
||||
sort: ((sortEl?.value as 'date_desc') || 'date_desc'),
|
||||
limit: 200
|
||||
};
|
||||
const result = await window.api.searchArchive(filter);
|
||||
renderArchiveSearchResults(result);
|
||||
} catch (e) {
|
||||
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
|
||||
applyHtml(resultsEl, '');
|
||||
} finally {
|
||||
archiveSearchInFlight = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchiveSearchResults(result: ArchiveSearchResult): void {
|
||||
const summaryEl = document.getElementById('archiveSearchSummary');
|
||||
const resultsEl = document.getElementById('archiveSearchResults');
|
||||
if (!resultsEl) return;
|
||||
|
||||
if (!result.rootExists) {
|
||||
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
|
||||
applyHtml(resultsEl, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summaryEl) {
|
||||
const tmpl = result.truncated
|
||||
? UI_TEXT.static.archiveSummaryTruncated
|
||||
: UI_TEXT.static.archiveSummary;
|
||||
summaryEl.textContent = (tmpl || '')
|
||||
.replace('{matchCount}', String(result.matchCount))
|
||||
.replace('{scanned}', String(result.totalScanned))
|
||||
.replace('{shown}', String(result.hits.length));
|
||||
}
|
||||
|
||||
if (result.hits.length === 0) {
|
||||
applyHtml(resultsEl, `<div class="archive-no-matches">${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.hits.map((hit) => {
|
||||
const date = new Date(hit.mtimeMs).toLocaleString();
|
||||
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
|
||||
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const chatBtn = hit.chatPath
|
||||
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeHtml(hit.fileName)}', 'chat')">${escapeHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
|
||||
: '';
|
||||
const eventsBtn = hit.eventsPath
|
||||
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeHtml(hit.fileName)}', 'events')">${escapeHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="archive-result-row">
|
||||
<div class="archive-result-body">
|
||||
<div class="archive-result-meta">
|
||||
${typeBadge}
|
||||
<strong class="archive-result-streamer">${escapeHtml(hit.streamer)}</strong>
|
||||
<span class="archive-result-date">${escapeHtml(date)}</span>
|
||||
</div>
|
||||
<div class="archive-result-filename" title="${escapeHtml(hit.fullPath)}">${escapeHtml(hit.fileName)}</div>
|
||||
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div>
|
||||
</div>
|
||||
<div class="archive-result-actions">
|
||||
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
|
||||
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
|
||||
${chatBtn}
|
||||
${eventsBtn}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
applyHtml(resultsEl, rows);
|
||||
}
|
||||
|
||||
function openFilePath(filePath: string): void {
|
||||
void window.api.openFile(filePath);
|
||||
}
|
||||
|
||||
function showFileInFolder(filePath: string): void {
|
||||
void window.api.showInFolder(filePath);
|
||||
}
|
||||
|
||||
function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void {
|
||||
if (kind === 'events') {
|
||||
const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer;
|
||||
if (typeof fn === 'function') fn(filePath, title);
|
||||
} else {
|
||||
const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer;
|
||||
if (typeof fn === 'function') fn(filePath, title);
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as {
|
||||
performArchiveSearch: typeof performArchiveSearch;
|
||||
onArchiveSearchInput: typeof onArchiveSearchInput;
|
||||
openFilePath: typeof openFilePath;
|
||||
showFileInFolder: typeof showFileInFolder;
|
||||
openEventsOrChat: typeof openEventsOrChat;
|
||||
}).performArchiveSearch = performArchiveSearch;
|
||||
(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput;
|
||||
(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath;
|
||||
(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder;
|
||||
(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat;
|
||||
|
||||
function initArchiveSearchInput(): void {
|
||||
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (queryEl && !queryEl.dataset.bound) {
|
||||
queryEl.addEventListener('input', onArchiveSearchInput);
|
||||
queryEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') void performArchiveSearch();
|
||||
});
|
||||
queryEl.dataset.bound = '1';
|
||||
}
|
||||
const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort'];
|
||||
for (const id of filters) {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
if (el && !el.dataset.bound) {
|
||||
el.addEventListener('change', () => { void performArchiveSearch(); });
|
||||
el.dataset.bound = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput;
|
||||
196
src/renderer-globals.d.ts
vendored
196
src/renderer-globals.d.ts
vendored
@ -15,32 +15,6 @@ interface AppConfig {
|
||||
prevent_duplicate_downloads?: boolean;
|
||||
persist_queue_on_restart?: boolean;
|
||||
metadata_cache_minutes?: number;
|
||||
parallel_downloads?: number;
|
||||
auto_resume_queue_on_startup?: boolean;
|
||||
downloaded_vod_ids?: string[];
|
||||
streamlink_quality?: string;
|
||||
notify_on_each_completion?: boolean;
|
||||
streamlink_disable_ads?: boolean;
|
||||
auto_record_streamers?: string[];
|
||||
auto_record_poll_seconds?: number;
|
||||
download_chat_replay?: boolean;
|
||||
capture_live_chat?: boolean;
|
||||
discord_webhook_url?: string;
|
||||
discord_notify_live_start?: boolean;
|
||||
discord_notify_live_end?: boolean;
|
||||
discord_notify_vod_complete?: boolean;
|
||||
discord_notify_vod_auto_queued?: boolean;
|
||||
auto_cleanup_enabled?: boolean;
|
||||
auto_cleanup_days?: number;
|
||||
auto_cleanup_target?: 'live_only' | 'all';
|
||||
auto_cleanup_action?: 'delete' | 'archive';
|
||||
log_stream_events?: boolean;
|
||||
auto_vod_download_streamers?: string[];
|
||||
auto_vod_download_poll_minutes?: number;
|
||||
auto_vod_max_age_hours?: number;
|
||||
auto_resume_live_recording?: boolean;
|
||||
auto_merge_resumed_parts?: boolean;
|
||||
delete_parts_after_merge?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@ -59,28 +33,10 @@ interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -99,10 +55,6 @@ interface QueueItem {
|
||||
progressStatus?: string;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
outputFiles?: string[];
|
||||
isLive?: boolean;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
@ -116,7 +68,6 @@ interface DownloadProgress {
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
@ -203,116 +154,6 @@ interface PreflightResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface StreamerStorageEntry {
|
||||
name: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
liveBytes: number;
|
||||
chatBytes: number;
|
||||
folderPath: string;
|
||||
}
|
||||
interface CleanupReport {
|
||||
enabled: boolean;
|
||||
dryRun: boolean;
|
||||
cutoffDays: number;
|
||||
target: 'live_only' | 'all';
|
||||
action: 'delete' | 'archive';
|
||||
scannedAt: string;
|
||||
candidates: number;
|
||||
processed: number;
|
||||
failed: number;
|
||||
bytesFreed: number;
|
||||
failures: Array<{ path: string; error: string }>;
|
||||
}
|
||||
interface StorageStatsResult {
|
||||
downloadPath: string;
|
||||
rootExists: boolean;
|
||||
freeBytes: number | null;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
streamers: StreamerStorageEntry[];
|
||||
extras: StreamerStorageEntry[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
interface StreamerProfile {
|
||||
login: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bannerUrl: string;
|
||||
description: string;
|
||||
broadcasterType: '' | 'partner' | 'affiliate';
|
||||
followerCount: number | null;
|
||||
vodCount: number;
|
||||
lastStreamAt: string | null;
|
||||
isLive: boolean;
|
||||
currentTitle: string | null;
|
||||
currentGame: string | null;
|
||||
currentStreamPreviewUrl: string;
|
||||
currentStreamViewers: number | null;
|
||||
twitchUrl: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface VodStoryboard {
|
||||
vodId: string;
|
||||
spriteDataUrl: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
framesInSprite: number;
|
||||
}
|
||||
|
||||
interface ArchiveSearchHit {
|
||||
fullPath: string;
|
||||
fileName: string;
|
||||
streamer: string;
|
||||
type: 'live' | 'vod' | 'chat' | 'events' | 'other';
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
chatPath: string | null;
|
||||
eventsPath: string | null;
|
||||
}
|
||||
interface ArchiveSearchResult {
|
||||
totalScanned: number;
|
||||
matchCount: number;
|
||||
truncated: boolean;
|
||||
hits: ArchiveSearchHit[];
|
||||
scannedAt: string;
|
||||
rootExists: boolean;
|
||||
}
|
||||
|
||||
interface ArchiveStatsTopStreamer {
|
||||
streamer: string;
|
||||
bytes: number;
|
||||
fileCount: number;
|
||||
liveBytes: number;
|
||||
vodBytes: number;
|
||||
chatBytes: number;
|
||||
}
|
||||
interface ArchiveStatsDay { date: string; count: number; bytes: number }
|
||||
interface ArchiveStatsBucket { label: string; count: number; bytes: number }
|
||||
interface ArchiveStats {
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
liveCount: number;
|
||||
liveBytes: number;
|
||||
vodCount: number;
|
||||
vodBytes: number;
|
||||
chatCount: number;
|
||||
chatBytes: number;
|
||||
eventsCount: number;
|
||||
streamerCount: number;
|
||||
avgRecordingSizeBytes: number;
|
||||
topStreamers: ArchiveStatsTopStreamer[];
|
||||
dailyActivity: ArchiveStatsDay[];
|
||||
sizeBuckets: ArchiveStatsBucket[];
|
||||
scannedAt: string;
|
||||
downloadPath: string;
|
||||
rootExists: boolean;
|
||||
}
|
||||
|
||||
interface ApiBridge {
|
||||
getConfig(): Promise<AppConfig>;
|
||||
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
|
||||
@ -321,13 +162,10 @@ interface ApiBridge {
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
getQueue(): Promise<QueueItem[]>;
|
||||
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
||||
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
clearCompleted(): Promise<QueueItem[]>;
|
||||
retryFailedDownloads(): Promise<QueueItem[]>;
|
||||
retryQueueItem(id: string): Promise<QueueItem[]>;
|
||||
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
||||
startDownload(): Promise<boolean>;
|
||||
pauseDownload(): Promise<boolean>;
|
||||
cancelDownload(): Promise<boolean>;
|
||||
@ -338,34 +176,6 @@ interface ApiBridge {
|
||||
selectMultipleVideos(): Promise<string[] | null>;
|
||||
saveVideoDialog(defaultName: string): Promise<string | null>;
|
||||
openFolder(path: string): Promise<void>;
|
||||
openFile(path: string): Promise<boolean>;
|
||||
showInFolder(path: string): Promise<boolean>;
|
||||
openDebugLogFile(): Promise<boolean>;
|
||||
checkFolderWritable(path: string): Promise<boolean>;
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
getArchiveStats(): Promise<ArchiveStats>;
|
||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
||||
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
|
||||
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
|
||||
searchArchive(filter: {
|
||||
query?: string;
|
||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||
streamer?: string;
|
||||
sinceMs?: number | null;
|
||||
untilMs?: number | null;
|
||||
sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
||||
limit?: number;
|
||||
}): Promise<ArchiveSearchResult>;
|
||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||
getAutomationStatus(): Promise<{
|
||||
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
||||
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
||||
}>;
|
||||
triggerAutoVodScan(): Promise<{ queuedCount: number }>;
|
||||
triggerAutoRecordScan(): Promise<{ triggered: number }>;
|
||||
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
@ -379,10 +189,6 @@ interface ApiBridge {
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
||||
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
|
||||
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
||||
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
||||
|
||||
@ -26,7 +26,6 @@ const UI_TEXT_DE = {
|
||||
mergeAdd: '+ Videos hinzufugen',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Hell',
|
||||
languageLabel: 'Sprache',
|
||||
languageDe: 'Deutsch',
|
||||
languageEn: 'Englisch',
|
||||
@ -41,148 +40,13 @@ const UI_TEXT_DE = {
|
||||
modeFull: 'Ganzes VOD',
|
||||
modeParts: 'In Teile splitten',
|
||||
partMinutesLabel: 'Teil-Lange (Minuten)',
|
||||
parallelDownloadsLabel: 'Parallele Downloads',
|
||||
parallelDownloads1: '1 (Standard)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance-Profil',
|
||||
performanceModeStability: 'Max Stabilitat',
|
||||
performanceModeBalanced: 'Ausgewogen',
|
||||
performanceModeSpeed: 'Max Geschwindigkeit',
|
||||
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
||||
smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.',
|
||||
streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
|
||||
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Log-Datei oeffnen',
|
||||
storageCardTitle: 'Speicher',
|
||||
storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.',
|
||||
storageRefresh: 'Aktualisieren',
|
||||
storageEmpty: 'Download-Ordner ist leer oder nicht lesbar.',
|
||||
storageScanning: 'Scanne...',
|
||||
storageSummary: 'Gesamt: {files} Dateien, {size} — Freier Speicher: {free}',
|
||||
storageColumnFolder: 'Ordner',
|
||||
storageColumnFiles: 'Dateien',
|
||||
storageColumnTotal: 'Gesamt',
|
||||
storageColumnLive: 'Live',
|
||||
storageColumnChat: 'Chat',
|
||||
storageColumnActionsAria: 'Aktionen',
|
||||
storageOpen: 'Oeffnen',
|
||||
storageOtherFolders: 'Andere Ordner im Download-Pfad',
|
||||
cleanupTitle: 'Auto-Cleanup',
|
||||
cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.',
|
||||
cleanupEnabledLabel: 'Auto-Cleanup aktivieren',
|
||||
cleanupDaysLabel: 'Tage-Schwelle',
|
||||
cleanupTargetLabel: 'Bereich',
|
||||
cleanupTargetLive: 'Nur Live-Aufnahmen',
|
||||
cleanupTargetAll: 'Alle Aufnahmen',
|
||||
cleanupActionLabel: 'Aktion',
|
||||
cleanupActionArchive: 'In Archiv verschieben',
|
||||
cleanupActionDelete: 'Loeschen',
|
||||
cleanupDryRun: 'Vorschau',
|
||||
cleanupRunNow: 'Jetzt ausfuehren',
|
||||
cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.',
|
||||
cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}',
|
||||
cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.',
|
||||
cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.',
|
||||
discordCardTitle: 'Discord-Webhook',
|
||||
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
|
||||
discordWebhookUrlLabel: 'Webhook-URL',
|
||||
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
||||
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
||||
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
|
||||
autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)',
|
||||
autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)',
|
||||
deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen',
|
||||
autoVodCardTitle: 'Auto-VOD-Download',
|
||||
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
|
||||
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
|
||||
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
|
||||
autoVodScanNow: 'Jetzt scannen',
|
||||
autoRecordScanNow: 'Live-Status pruefen',
|
||||
statsTitle: 'Archiv-Statistik',
|
||||
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
|
||||
statsRefresh: 'Aktualisieren',
|
||||
statsScanning: 'Scanne...',
|
||||
statsScannedAt: 'Letzter Scan',
|
||||
statsSummaryTitle: 'Uebersicht',
|
||||
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
|
||||
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
|
||||
statsSizeBucketsTitle: 'Aufnahme-Groessen-Verteilung',
|
||||
statsTotalRecordings: 'Aufnahmen gesamt',
|
||||
statsLiveRecordings: 'Live-Aufnahmen',
|
||||
statsVodRecordings: 'VOD-Downloads',
|
||||
statsStreamers: 'Streamer',
|
||||
statsAvgSize: 'Durchschn. Groesse',
|
||||
statsChatFiles: 'Chat-Dateien',
|
||||
statsFiles: 'Dateien',
|
||||
statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.',
|
||||
statsActivitySummary: '{count} Aufnahmen - {size} in den letzten 30 Tagen',
|
||||
statsEmpty: 'Keine Daten.',
|
||||
statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
navStats: 'Statistik',
|
||||
navArchive: 'Archiv',
|
||||
archiveTitle: 'Archiv durchsuchen',
|
||||
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.',
|
||||
archiveAllTypes: 'Alle Typen',
|
||||
archiveTypeLive: 'Live-Aufnahmen',
|
||||
archiveTypeVod: 'VOD-Downloads',
|
||||
archiveAllStreamers: 'Alle Streamer',
|
||||
archiveSortDateDesc: 'Neueste zuerst',
|
||||
archiveSortDateAsc: 'Aelteste zuerst',
|
||||
archiveSortSizeDesc: 'Groesste zuerst',
|
||||
archiveSortSizeAsc: 'Kleinste zuerst',
|
||||
archiveSortNameAsc: 'Name (A-Z)',
|
||||
archiveSearchBtn: 'Suchen',
|
||||
archiveSearching: 'Scanne...',
|
||||
archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)',
|
||||
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
|
||||
archiveNoMatches: 'Keine Treffer.',
|
||||
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
archiveSearchPlaceholder: 'Suche...',
|
||||
archiveSearchAria: 'Archiv durchsuchen',
|
||||
archiveOpen: 'Oeffnen',
|
||||
archiveShowInFolder: 'Ordner',
|
||||
archiveViewChat: 'Chat',
|
||||
archiveViewEvents: 'Events',
|
||||
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
||||
backupCardTitle: 'Sicherung & Wartung',
|
||||
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
||||
exportConfig: 'Konfiguration exportieren',
|
||||
importConfig: 'Konfiguration importieren',
|
||||
resetDownloadedIds: 'Downloaded-VODs zuruecksetzen',
|
||||
configExported: 'Konfiguration exportiert.',
|
||||
configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
|
||||
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
|
||||
configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
|
||||
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.',
|
||||
resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.',
|
||||
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
|
||||
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
||||
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
|
||||
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
||||
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
|
||||
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
|
||||
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen',
|
||||
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
|
||||
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
|
||||
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.',
|
||||
captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).',
|
||||
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
|
||||
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
|
||||
streamlinkQualityLabel: 'Stream-Qualitaet',
|
||||
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
|
||||
streamlinkQualityBest: 'Best (Standard)',
|
||||
streamlinkQualitySource: 'Source (Original)',
|
||||
streamlinkQualityAudio: 'Nur Audio',
|
||||
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filtern...',
|
||||
streamerListFilterAria: 'Streamer-Liste filtern',
|
||||
streamerAddAriaLabel: 'Streamer hinzufuegen',
|
||||
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
|
||||
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
|
||||
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
|
||||
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
||||
filenameTemplatesTitle: 'Dateinamen-Templates',
|
||||
vodTemplateLabel: 'VOD-Template',
|
||||
@ -260,15 +124,10 @@ const UI_TEXT_DE = {
|
||||
clips: 'Clips',
|
||||
cutter: 'Video schneiden',
|
||||
merge: 'Videos zusammenfugen',
|
||||
stats: 'Statistik',
|
||||
archive: 'Archiv',
|
||||
settings: 'Einstellungen'
|
||||
},
|
||||
queue: {
|
||||
empty: 'Keine Downloads in der Warteschlange',
|
||||
detailStreamer: 'Streamer:',
|
||||
detailDuration: 'Dauer:',
|
||||
detailDate: 'Datum:',
|
||||
start: 'Start',
|
||||
stop: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
@ -288,87 +147,9 @@ const UI_TEXT_DE = {
|
||||
eta: 'Restzeit',
|
||||
part: 'Teil',
|
||||
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.',
|
||||
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.',
|
||||
openFile: 'Datei oeffnen',
|
||||
showInFolder: 'Im Ordner zeigen',
|
||||
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
|
||||
outputFilesLabel: '{count} Ausgabedateien',
|
||||
retryItem: 'Diesen Eintrag erneut versuchen',
|
||||
viewChat: 'Chat ansehen',
|
||||
viewChatLoading: 'Lade Chat...',
|
||||
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
|
||||
chatViewerFilterPlaceholder: 'Chat filtern...',
|
||||
chatViewerFilterAria: 'Chatnachrichten filtern',
|
||||
viewChatCount: '{count} Nachrichten',
|
||||
viewChatTruncatedSuffix: ' (gekuerzt)',
|
||||
viewEvents: 'Events ansehen',
|
||||
viewEventsCount: '{count} Events',
|
||||
viewEventsEmpty: 'Keine Events aufgezeichnet.',
|
||||
eventStartedAs: 'Gestartet als',
|
||||
eventEndedAfter: 'Beendet nach',
|
||||
eventTitleFromTo: 'Titel: {from} -> {to}',
|
||||
eventGameFromTo: 'Game: {from} -> {to}',
|
||||
statusBarSummary: '{downloading} aktiv, {pending} wartet',
|
||||
ctxMoveTop: 'Nach oben verschieben',
|
||||
ctxMoveBottom: 'Nach unten verschieben',
|
||||
ctxCopyUrl: 'URL kopieren',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxRemove: 'Aus Queue entfernen',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet',
|
||||
recordingHealth: {
|
||||
ok: 'Gesund - Bytes fliessen',
|
||||
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
|
||||
unknown: 'Warte auf ersten Segment'
|
||||
},
|
||||
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
|
||||
},
|
||||
profile: {
|
||||
liveBadge: 'LIVE',
|
||||
partner: 'Partner',
|
||||
affiliate: 'Affiliate',
|
||||
followers: 'Follower',
|
||||
vods: 'VODs',
|
||||
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals',
|
||||
lastStream: 'Letzter Stream',
|
||||
openTwitch: 'Auf Twitch oeffnen',
|
||||
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
||||
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
|
||||
liveThumbAlt: 'Live-Vorschau',
|
||||
recordNow: 'Jetzt aufnehmen',
|
||||
refresh: 'Aktualisieren',
|
||||
agoMinutes: 'vor {n} Min',
|
||||
agoHours: 'vor {n} h',
|
||||
agoDays: 'vor {n} Tagen',
|
||||
agoMonths: 'vor {n} Monaten',
|
||||
agoYears: 'vor {n} Jahren'
|
||||
},
|
||||
streamers: {
|
||||
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
||||
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
||||
liveRecordingOffline: '{streamer} ist gerade offline.',
|
||||
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.',
|
||||
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
|
||||
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
|
||||
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
|
||||
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
|
||||
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
|
||||
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
|
||||
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.',
|
||||
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
|
||||
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
|
||||
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
|
||||
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
|
||||
liveNowTooltip: 'Aktuell live auf Twitch',
|
||||
modalCloseAria: 'Dialog schliessen',
|
||||
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.',
|
||||
removeAria: 'Entfernen',
|
||||
cutProgressAria: 'Schnitt-Fortschritt',
|
||||
mergeProgressAria: 'Merge-Fortschritt',
|
||||
updateProgressAria: 'Update-Download-Fortschritt'
|
||||
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.'
|
||||
},
|
||||
vods: {
|
||||
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen',
|
||||
noneTitle: 'Keine VODs',
|
||||
noneText: 'Wahle einen Streamer aus der Liste.',
|
||||
loading: 'Lade VODs...',
|
||||
@ -377,53 +158,11 @@ const UI_TEXT_DE = {
|
||||
noResultsText: 'Dieser Streamer hat keine VODs.',
|
||||
untitled: 'Unbenanntes VOD',
|
||||
views: 'Aufrufe',
|
||||
addQueue: '+ Warteschlange',
|
||||
trimButton: 'VOD zuschneiden',
|
||||
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
|
||||
filterAria: 'VOD-Titel filtern',
|
||||
filterClearTitle: 'Filter loeschen (Esc)',
|
||||
filterNoMatchTitle: 'Keine Treffer',
|
||||
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
||||
filterMatchCount: '{shown} von {total} VODs',
|
||||
sortLabel: 'Sortierung:',
|
||||
sortDateDesc: 'Neueste zuerst',
|
||||
sortDateAsc: 'Aelteste zuerst',
|
||||
sortViewsDesc: 'Meiste Aufrufe',
|
||||
sortDurationDesc: 'Laengste zuerst',
|
||||
sortDurationAsc: 'Kuerzeste zuerst',
|
||||
bulkSelectedCount: '{count} ausgewaehlt',
|
||||
bulkAddToQueue: '+ Warteschlange',
|
||||
bulkAdding: 'Fuege hinzu...',
|
||||
bulkClear: 'Loeschen',
|
||||
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
|
||||
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
||||
bulkMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
bulkUnmark: 'Markierung entfernen',
|
||||
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
|
||||
bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.',
|
||||
alreadyDownloaded: 'Bereits heruntergeladen',
|
||||
hideDownloaded: 'Bereits geladene ausblenden',
|
||||
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
|
||||
openOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxCopyUrl: 'VOD-URL kopieren',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
ctxMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
ctxUnmarkDownloaded: 'Markierung entfernen'
|
||||
addQueue: '+ Warteschlange'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'VOD zuschneiden',
|
||||
dialogStart: 'Start:',
|
||||
dialogStartTime: 'Startzeit (HH:MM:SS):',
|
||||
dialogEnd: 'Ende:',
|
||||
dialogEndTime: 'Endzeit (HH:MM:SS):',
|
||||
dialogDuration: 'Dauer: ',
|
||||
dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):',
|
||||
dialogPartHint: 'Leer lassen = Teil 1',
|
||||
dialogFormatLabel: 'Dateinamen-Format:',
|
||||
dialogConfirm: 'Zur Queue hinzufuegen',
|
||||
dialogTitle: 'Clip zuschneiden',
|
||||
invalidDuration: 'Ungultig!',
|
||||
invalidTime: 'Ungueltige Zeitangaben',
|
||||
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
|
||||
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
|
||||
enterUrl: 'Bitte URL eingeben',
|
||||
@ -435,51 +174,26 @@ const UI_TEXT_DE = {
|
||||
unknownError: 'Unbekannter Fehler',
|
||||
formatSimple: '(Standard)',
|
||||
formatTimestamp: '(mit Zeitstempel)',
|
||||
formatParts: '(Parts-Format)',
|
||||
formatTemplate: '(benutzerdefiniert)',
|
||||
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...',
|
||||
startPartPlaceholder: 'z.B. 42'
|
||||
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
|
||||
previewLoading: 'Lade Vorschau...',
|
||||
previewUnavailable: 'Vorschau nicht verfugbar',
|
||||
previewAlt: 'Vorschau',
|
||||
cutting: 'Schneidet...',
|
||||
cut: 'Schneiden',
|
||||
cutSuccess: 'Video erfolgreich geschnitten!',
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.',
|
||||
infoDuration: 'Dauer',
|
||||
infoResolution: 'Aufloesung',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Auswahl',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'Ende:',
|
||||
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.'
|
||||
},
|
||||
merge: {
|
||||
empty: 'Keine Videos ausgewahlt',
|
||||
merging: 'Zusammenfugen...',
|
||||
merge: 'Zusammenfugen',
|
||||
success: 'Videos erfolgreich zusammengefugt!',
|
||||
failed: 'Fehler beim Zusammenfugen der Videos.',
|
||||
moveUpAria: 'Nach oben verschieben',
|
||||
moveDownAria: 'Nach unten verschieben',
|
||||
removeAria: 'Aus Liste entfernen'
|
||||
},
|
||||
mergeGroup: {
|
||||
btn: 'Zusammenfugen & Splitten',
|
||||
phaseDownloading: 'VOD wird heruntergeladen',
|
||||
phaseMerging: 'Zusammenfugen...',
|
||||
phaseSplitting: 'Part wird erstellt',
|
||||
phaseCleanup: 'Aufraumen...',
|
||||
needMinTwo: 'Mindestens 2 VODs auswahlen',
|
||||
titleTwo: 'Merge: {title1} + {title2}',
|
||||
titleMany: 'Merge: {title1} + {count} weitere',
|
||||
metaLabel: '{count} VODs',
|
||||
failed: 'Fehler beim Zusammenfugen der Videos.'
|
||||
},
|
||||
updates: {
|
||||
bannerDefault: 'Neue Version verfugbar!',
|
||||
@ -503,7 +217,6 @@ const UI_TEXT_DE = {
|
||||
modalDismiss: 'Nein',
|
||||
modalDownloadConfirm: 'Ja, herunterladen',
|
||||
modalInstallConfirm: 'Ja, installieren',
|
||||
modalSkipVersion: 'Diese Version ueberspringen',
|
||||
changelogLabel: 'Changelog',
|
||||
showChangelog: 'Changelog anzeigen',
|
||||
hideChangelog: 'Changelog ausblenden',
|
||||
|
||||
@ -26,7 +26,6 @@ const UI_TEXT_EN = {
|
||||
mergeAdd: '+ Add videos',
|
||||
designTitle: 'Design',
|
||||
themeLabel: 'Theme',
|
||||
themeLight: 'Light',
|
||||
languageLabel: 'Language',
|
||||
languageDe: 'German',
|
||||
languageEn: 'English',
|
||||
@ -41,148 +40,13 @@ const UI_TEXT_EN = {
|
||||
modeFull: 'Full VOD',
|
||||
modeParts: 'Split into parts',
|
||||
partMinutesLabel: 'Part Length (Minutes)',
|
||||
parallelDownloadsLabel: 'Parallel Downloads',
|
||||
parallelDownloads1: '1 (Default)',
|
||||
parallelDownloads2: '2 (Parallel)',
|
||||
performanceModeLabel: 'Performance Profile',
|
||||
performanceModeStability: 'Max Stability',
|
||||
performanceModeBalanced: 'Balanced',
|
||||
performanceModeSpeed: 'Max Speed',
|
||||
smartSchedulerLabel: 'Enable smart queue scheduler',
|
||||
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
|
||||
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
|
||||
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Open log file',
|
||||
storageCardTitle: 'Storage',
|
||||
storageCardIntro: 'Per-streamer disk usage in the current download folder. Live recordings are surfaced separately.',
|
||||
storageRefresh: 'Refresh',
|
||||
storageEmpty: 'Download folder is empty or unreadable.',
|
||||
storageScanning: 'Scanning...',
|
||||
storageSummary: 'Total: {files} files, {size} — Free disk: {free}',
|
||||
storageColumnFolder: 'Folder',
|
||||
storageColumnFiles: 'Files',
|
||||
storageColumnTotal: 'Total',
|
||||
storageColumnLive: 'Live',
|
||||
storageColumnChat: 'Chat',
|
||||
storageColumnActionsAria: 'Actions',
|
||||
storageOpen: 'Open',
|
||||
storageOtherFolders: 'Other folders in download path',
|
||||
cleanupTitle: 'Auto-cleanup',
|
||||
cleanupIntro: 'Move recordings older than N days to an archive folder, or delete them outright. Sibling chat files (.chat.json/.chat.jsonl) travel with the video.',
|
||||
cleanupEnabledLabel: 'Enable auto-cleanup',
|
||||
cleanupDaysLabel: 'Age threshold (days)',
|
||||
cleanupTargetLabel: 'Scope',
|
||||
cleanupTargetLive: 'Live recordings only',
|
||||
cleanupTargetAll: 'All recordings',
|
||||
cleanupActionLabel: 'Action',
|
||||
cleanupActionArchive: 'Move to archive folder',
|
||||
cleanupActionDelete: 'Delete',
|
||||
cleanupDryRun: 'Preview',
|
||||
cleanupRunNow: 'Run now',
|
||||
cleanupReportPreview: 'Would touch {count} files (~{size}). No files have been moved or deleted.',
|
||||
cleanupReportDone: 'Processed {count} files, freed ~{size}.{failed}',
|
||||
cleanupReportFailedSuffix: ' {failed} failed.',
|
||||
cleanupReportEmpty: 'No recordings older than {days} days found.',
|
||||
discordCardTitle: 'Discord webhook',
|
||||
discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.',
|
||||
discordWebhookUrlLabel: 'Webhook URL',
|
||||
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
||||
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
||||
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
||||
autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)',
|
||||
autoMergeResumedPartsLabel: 'Auto-merge resumed-recording parts into one file (ffmpeg concat, no re-encode)',
|
||||
deletePartsAfterMergeLabel: 'Delete individual parts after successful merge',
|
||||
discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
|
||||
autoVodCardTitle: 'Auto-VOD download',
|
||||
autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.',
|
||||
autoVodPollMinutesLabel: 'Poll interval (minutes)',
|
||||
autoVodMaxAgeHoursLabel: 'Max age (hours)',
|
||||
autoVodScanNow: 'Scan now',
|
||||
autoRecordScanNow: 'Check live status',
|
||||
statsTitle: 'Archive statistics',
|
||||
statsIntro: 'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.',
|
||||
statsRefresh: 'Refresh',
|
||||
statsScanning: 'Scanning...',
|
||||
statsScannedAt: 'Last scan',
|
||||
statsSummaryTitle: 'Overview',
|
||||
statsTopStreamersTitle: 'Top streamers (by size)',
|
||||
statsActivityTitle: 'Activity (last 30 days)',
|
||||
statsSizeBucketsTitle: 'Recording-size distribution',
|
||||
statsTotalRecordings: 'Recordings total',
|
||||
statsLiveRecordings: 'Live recordings',
|
||||
statsVodRecordings: 'VOD downloads',
|
||||
statsStreamers: 'Streamers',
|
||||
statsAvgSize: 'Avg. recording size',
|
||||
statsChatFiles: 'Chat files',
|
||||
statsFiles: 'files',
|
||||
statsActivityEmpty: 'No recordings in the last 30 days.',
|
||||
statsActivitySummary: '{count} recordings - {size} in the last 30 days',
|
||||
statsEmpty: 'No data.',
|
||||
statsNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
navStats: 'Statistics',
|
||||
navArchive: 'Archive',
|
||||
archiveTitle: 'Search archive',
|
||||
archiveIntro: 'Search by filename, streamer, or date string. Hits show recordings (Live + VOD); related chat and events files appear as companion buttons.',
|
||||
archiveAllTypes: 'All types',
|
||||
archiveTypeLive: 'Live recordings',
|
||||
archiveTypeVod: 'VOD downloads',
|
||||
archiveAllStreamers: 'All streamers',
|
||||
archiveSortDateDesc: 'Newest first',
|
||||
archiveSortDateAsc: 'Oldest first',
|
||||
archiveSortSizeDesc: 'Largest first',
|
||||
archiveSortSizeAsc: 'Smallest first',
|
||||
archiveSortNameAsc: 'Name (A-Z)',
|
||||
archiveSearchBtn: 'Search',
|
||||
archiveSearching: 'Scanning...',
|
||||
archiveSummary: '{matchCount} matches (scanned {scanned} files)',
|
||||
archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)',
|
||||
archiveNoMatches: 'No matches.',
|
||||
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
archiveSearchPlaceholder: 'Search...',
|
||||
archiveSearchAria: 'Search archive',
|
||||
archiveOpen: 'Open',
|
||||
archiveShowInFolder: 'Folder',
|
||||
archiveViewChat: 'Chat',
|
||||
archiveViewEvents: 'Events',
|
||||
backupCardTitle: 'Backup & Maintenance',
|
||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
resetDownloadedIds: 'Reset downloaded list',
|
||||
configExported: 'Configuration exported.',
|
||||
configExportFailed: 'Configuration export failed.',
|
||||
configImported: 'Configuration imported. Some changes may need a restart.',
|
||||
configImportFailed: 'Configuration import failed.',
|
||||
resetDownloadedConfirm: 'Reset the downloaded-VODs list? Cards will lose the green check mark, but no files are deleted.',
|
||||
resetDownloadedDone: 'Cleared {count} entries from the downloaded list.',
|
||||
duplicatePreventionLabel: 'Prevent duplicate queue entries',
|
||||
persistQueueLabel: 'Keep queue between app restarts',
|
||||
autoResumeQueueLabel: 'Auto-resume the queue on startup',
|
||||
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
||||
notifyEachCompletionLabel: 'Notify on every completed download',
|
||||
notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.',
|
||||
streamlinkDisableAdsLabel: 'Skip Twitch ads while downloading',
|
||||
streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.',
|
||||
downloadChatReplayLabel: 'Save chat replay alongside each VOD (.chat.json)',
|
||||
downloadChatReplayHint: 'After a VOD download completes, fetches the public chat replay via Twitch GQL and saves it as JSON next to the video. Twitch keeps chat replay only as long as the VOD itself.',
|
||||
captureLiveChatLabel: 'Capture live chat during recording (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Opens an anonymous IRC connection to Twitch chat during a live recording and appends every message to a sibling .chat.jsonl file (JSON Lines, one message per line) so a long capture can be killed mid-stream without corrupting earlier data.',
|
||||
logStreamEventsLabel: 'Log stream events during live recording (.events.jsonl)',
|
||||
logStreamEventsHint: 'Polls the streamer once a minute and writes title / game changes to a sibling .events.jsonl file. Useful for seeking inside long archived streams ("when did he switch to CS:GO?"). Cheap — one extra Helix/GQL hit per minute per active recording.',
|
||||
streamlinkQualityLabel: 'Stream quality',
|
||||
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
|
||||
streamlinkQualityBest: 'Best (default)',
|
||||
streamlinkQualitySource: 'Source (original)',
|
||||
streamlinkQualityAudio: 'Audio only',
|
||||
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filter...',
|
||||
streamerListFilterAria: 'Filter streamer list',
|
||||
streamerAddAriaLabel: 'Add streamer',
|
||||
streamerBulkRemoveTitle: 'Remove all (or filtered)',
|
||||
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
|
||||
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
|
||||
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
||||
filenameTemplatesTitle: 'Filename Templates',
|
||||
vodTemplateLabel: 'VOD Template',
|
||||
@ -260,15 +124,10 @@ const UI_TEXT_EN = {
|
||||
clips: 'Clips',
|
||||
cutter: 'Video Cutter',
|
||||
merge: 'Merge Videos',
|
||||
stats: 'Statistics',
|
||||
archive: 'Archive',
|
||||
settings: 'Settings'
|
||||
},
|
||||
queue: {
|
||||
empty: 'No downloads in queue',
|
||||
detailStreamer: 'Streamer:',
|
||||
detailDuration: 'Duration:',
|
||||
detailDate: 'Date:',
|
||||
start: 'Start',
|
||||
stop: 'Pause',
|
||||
resume: 'Resume',
|
||||
@ -288,87 +147,9 @@ const UI_TEXT_EN = {
|
||||
eta: 'ETA',
|
||||
part: 'Part',
|
||||
emptyAlert: 'Queue is empty. Add a VOD or clip first.',
|
||||
duplicateSkipped: 'This item is already active in the queue.',
|
||||
openFile: 'Open file',
|
||||
showInFolder: 'Show in folder',
|
||||
openFileFailed: 'Could not open the file (it may have been moved or deleted).',
|
||||
outputFilesLabel: '{count} output files',
|
||||
retryItem: 'Retry this item',
|
||||
viewChat: 'View chat',
|
||||
viewChatLoading: 'Loading chat...',
|
||||
viewChatFailed: 'Could not read chat file',
|
||||
chatViewerFilterPlaceholder: 'Filter chat...',
|
||||
chatViewerFilterAria: 'Filter chat messages',
|
||||
viewChatCount: '{count} messages',
|
||||
viewChatTruncatedSuffix: ' (truncated)',
|
||||
viewEvents: 'View events',
|
||||
viewEventsCount: '{count} events',
|
||||
viewEventsEmpty: 'No events recorded.',
|
||||
eventStartedAs: 'Started as',
|
||||
eventEndedAfter: 'Ended after',
|
||||
eventTitleFromTo: 'Title: {from} -> {to}',
|
||||
eventGameFromTo: 'Game: {from} -> {to}',
|
||||
statusBarSummary: '{downloading} dl, {pending} queued',
|
||||
ctxMoveTop: 'Move to top',
|
||||
ctxMoveBottom: 'Move to bottom',
|
||||
ctxCopyUrl: 'Copy URL',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxRemove: 'Remove from queue',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
liveRecordingTitle: 'Live recording — captures until the stream ends',
|
||||
recordingHealth: {
|
||||
ok: 'Healthy — bytes flowing',
|
||||
stale: 'Stalled — no bytes recently (network blip or stream ending)',
|
||||
unknown: 'Waiting for first segment'
|
||||
},
|
||||
eventRecordingResume: 'Recording resumed — starting part {part}'
|
||||
},
|
||||
profile: {
|
||||
liveBadge: 'LIVE',
|
||||
partner: 'Partner',
|
||||
affiliate: 'Affiliate',
|
||||
followers: 'Followers',
|
||||
vods: 'VODs',
|
||||
vodsTooltip: 'VODs visible via Twitch API for this channel',
|
||||
lastStream: 'Last stream',
|
||||
openTwitch: 'Open on Twitch',
|
||||
openTwitchTooltip: 'Open this channel on twitch.tv',
|
||||
liveCardTooltip: 'Click to start a live recording right now',
|
||||
liveThumbAlt: 'Live preview',
|
||||
recordNow: 'Record now',
|
||||
refresh: 'Refresh',
|
||||
agoMinutes: '{n} min ago',
|
||||
agoHours: '{n} h ago',
|
||||
agoDays: '{n} d ago',
|
||||
agoMonths: '{n} mo ago',
|
||||
agoYears: '{n} y ago'
|
||||
},
|
||||
streamers: {
|
||||
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
||||
liveRecordingStarted: 'Live recording started for {streamer}.',
|
||||
liveRecordingOffline: '{streamer} is offline right now.',
|
||||
liveRecordingAlreadyActive: 'Already recording {streamer}.',
|
||||
liveRecordingFailed: 'Could not start live recording',
|
||||
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
|
||||
autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...',
|
||||
autoRecordDisabled: 'Auto-record disabled for {streamer}.',
|
||||
autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
|
||||
autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.',
|
||||
autoVodDisabled: 'Auto-VOD disabled for {streamer}.',
|
||||
autoVodScanQueued: '{count} new VOD(s) auto-queued.',
|
||||
autoVodScanEmpty: 'No new VODs found.',
|
||||
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
|
||||
autoRecordScanEmpty: 'Manual scan: no streamers currently live.',
|
||||
liveNowTooltip: 'Currently live on Twitch',
|
||||
modalCloseAria: 'Close dialog',
|
||||
sidebarEmpty: 'No streamers yet. Add one via the input at the top right.',
|
||||
removeAria: 'Remove',
|
||||
cutProgressAria: 'Cut progress',
|
||||
mergeProgressAria: 'Merge progress',
|
||||
updateProgressAria: 'Update download progress'
|
||||
duplicateSkipped: 'This item is already active in the queue.'
|
||||
},
|
||||
vods: {
|
||||
selectAriaLabel: 'Select VOD for bulk action',
|
||||
noneTitle: 'No VODs',
|
||||
noneText: 'Select a streamer from the list.',
|
||||
loading: 'Loading VODs...',
|
||||
@ -377,53 +158,11 @@ const UI_TEXT_EN = {
|
||||
noResultsText: 'This streamer has no VODs.',
|
||||
untitled: 'Untitled VOD',
|
||||
views: 'views',
|
||||
addQueue: '+ Queue',
|
||||
trimButton: 'Trim VOD',
|
||||
filterPlaceholder: 'Filter by title... (Ctrl+F)',
|
||||
filterAria: 'Filter VOD titles',
|
||||
filterClearTitle: 'Clear filter (Esc)',
|
||||
filterNoMatchTitle: 'No matches',
|
||||
filterNoMatchText: 'No VODs match the current filter.',
|
||||
filterMatchCount: '{shown} of {total} VODs',
|
||||
sortLabel: 'Sort:',
|
||||
sortDateDesc: 'Newest first',
|
||||
sortDateAsc: 'Oldest first',
|
||||
sortViewsDesc: 'Most viewed',
|
||||
sortDurationDesc: 'Longest first',
|
||||
sortDurationAsc: 'Shortest first',
|
||||
bulkSelectedCount: '{count} selected',
|
||||
bulkAddToQueue: '+ Queue',
|
||||
bulkAdding: 'Adding...',
|
||||
bulkClear: 'Clear',
|
||||
bulkAddedToQueue: 'Added {count} VODs to the queue.',
|
||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||
bulkMarkDownloaded: 'Mark as downloaded',
|
||||
bulkUnmark: 'Unmark',
|
||||
bulkMarkedDownloaded: 'Marked {count} VODs as downloaded.',
|
||||
bulkUnmarkedDownloaded: 'Removed {count} VODs from the downloaded list.',
|
||||
alreadyDownloaded: 'Already downloaded',
|
||||
hideDownloaded: 'Hide downloaded',
|
||||
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded',
|
||||
openOnTwitch: 'Open on Twitch',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxCopyUrl: 'Copy VOD URL',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
ctxMarkDownloaded: 'Mark as downloaded',
|
||||
ctxUnmarkDownloaded: 'Unmark downloaded'
|
||||
addQueue: '+ Queue'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim VOD',
|
||||
dialogStart: 'Start:',
|
||||
dialogStartTime: 'Start time (HH:MM:SS):',
|
||||
dialogEnd: 'End:',
|
||||
dialogEndTime: 'End time (HH:MM:SS):',
|
||||
dialogDuration: 'Duration: ',
|
||||
dialogPartLabel: 'Start part number (optional, for continuation):',
|
||||
dialogPartHint: 'Leave empty = part 1',
|
||||
dialogFormatLabel: 'Filename format:',
|
||||
dialogConfirm: 'Add to queue',
|
||||
dialogTitle: 'Trim clip',
|
||||
invalidDuration: 'Invalid!',
|
||||
invalidTime: 'Invalid time values',
|
||||
endBeforeStart: 'End time must be greater than start time!',
|
||||
outOfRange: 'Time is outside VOD range!',
|
||||
enterUrl: 'Please enter a URL',
|
||||
@ -435,51 +174,26 @@ const UI_TEXT_EN = {
|
||||
unknownError: 'Unknown error',
|
||||
formatSimple: '(default)',
|
||||
formatTimestamp: '(with timestamp)',
|
||||
formatParts: '(parts naming)',
|
||||
formatTemplate: '(custom template)',
|
||||
templateEmpty: 'Template cannot be empty in custom template mode.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...',
|
||||
startPartPlaceholder: 'e.g. 42'
|
||||
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
|
||||
previewLoading: 'Loading preview...',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewAlt: 'Preview',
|
||||
cutting: 'Cutting...',
|
||||
cut: 'Cut',
|
||||
cutSuccess: 'Video cut successfully!',
|
||||
cutFailed: 'Failed to cut video.',
|
||||
infoDuration: 'Duration',
|
||||
infoResolution: 'Resolution',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Selection',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'End:',
|
||||
filePathPlaceholder: 'No file selected...'
|
||||
cutFailed: 'Failed to cut video.'
|
||||
},
|
||||
merge: {
|
||||
empty: 'No videos selected',
|
||||
merging: 'Merging...',
|
||||
merge: 'Merge',
|
||||
success: 'Videos merged successfully!',
|
||||
failed: 'Failed to merge videos.',
|
||||
moveUpAria: 'Move up',
|
||||
moveDownAria: 'Move down',
|
||||
removeAria: 'Remove from list'
|
||||
},
|
||||
mergeGroup: {
|
||||
btn: 'Merge & Split',
|
||||
phaseDownloading: 'Downloading VOD',
|
||||
phaseMerging: 'Merging...',
|
||||
phaseSplitting: 'Splitting Part',
|
||||
phaseCleanup: 'Cleaning up...',
|
||||
needMinTwo: 'Select at least 2 VODs',
|
||||
titleTwo: 'Merge: {title1} + {title2}',
|
||||
titleMany: 'Merge: {title1} + {count} more',
|
||||
metaLabel: '{count} VODs',
|
||||
failed: 'Failed to merge videos.'
|
||||
},
|
||||
updates: {
|
||||
bannerDefault: 'New version available!',
|
||||
@ -503,7 +217,6 @@ const UI_TEXT_EN = {
|
||||
modalDismiss: 'No',
|
||||
modalDownloadConfirm: 'Yes, download',
|
||||
modalInstallConfirm: 'Yes, install',
|
||||
modalSkipVersion: 'Skip this version',
|
||||
changelogLabel: 'Changelog',
|
||||
showChangelog: 'Show changelog',
|
||||
hideChangelog: 'Hide changelog',
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
// Profile-header renderer. Owns the streamerProfileHeader div above the
|
||||
// VOD grid: hidden when no streamer is selected, skeleton while loading,
|
||||
// full card once profile data is back. Smooth fade-in is in CSS.
|
||||
|
||||
let activeProfileRequestId = 0;
|
||||
|
||||
function formatProfileFollowers(count: number | null): string {
|
||||
if (count == null) return '–';
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
function formatLastStreamAgo(iso: string | null): string {
|
||||
if (!iso) return '–';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms) || ms < 0) return '–';
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
|
||||
const years = Math.floor(days / 365);
|
||||
return UI_TEXT.profile.agoYears.replace('{n}', String(years));
|
||||
}
|
||||
|
||||
function hideStreamerProfileHeader(): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.add('is-hidden');
|
||||
applyHtml(el, '');
|
||||
}
|
||||
|
||||
function renderStreamerProfileSkeleton(login: string): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.remove('is-live', 'is-hidden');
|
||||
el.classList.add('streamer-profile-skeleton');
|
||||
applyHtml(el, `
|
||||
<div class="streamer-profile-skel-block avatar"></div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<div class="streamer-profile-skel-block name"></div>
|
||||
<div class="streamer-profile-skel-block badge"></div>
|
||||
</div>
|
||||
<div class="streamer-profile-skel-block subtitle"></div>
|
||||
<div class="streamer-profile-stats streamer-profile-skel-stats">
|
||||
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
|
||||
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
|
||||
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.remove('streamer-profile-skeleton', 'is-hidden');
|
||||
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
||||
|
||||
const safeLogin = p.login.replace(/'/g, "\\'");
|
||||
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||||
|
||||
const avatarBlock = p.avatarUrl
|
||||
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeHtml(p.avatarUrl)}" alt="${escapeHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
|
||||
: `<div class="streamer-profile-avatar-fallback">${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||||
|
||||
const badges: string[] = [];
|
||||
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeHtml(UI_TEXT.profile.partner)}</span>`);
|
||||
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeHtml(UI_TEXT.profile.affiliate)}</span>`);
|
||||
|
||||
const bio = p.description
|
||||
? `<div class="streamer-profile-bio" title="${escapeHtml(p.description)}">${escapeHtml(p.description)}</div>`
|
||||
: '';
|
||||
|
||||
const followersStat = `
|
||||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.followers)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
<strong>${escapeHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeHtml(UI_TEXT.profile.followers)}
|
||||
</div>`;
|
||||
const vodsStat = `
|
||||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.vodsTooltip)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
|
||||
<strong>${p.vodCount}</strong> ${escapeHtml(UI_TEXT.profile.vods)}
|
||||
</div>`;
|
||||
const lastStreamStat = `
|
||||
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||||
${escapeHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||||
</div>`;
|
||||
|
||||
// Banner-as-background — set inline so the URL stays per-streamer.
|
||||
// The darkening gradient is handled by the .streamer-profile-header::before
|
||||
// pseudo so the banner itself stays bright and unfiltered here.
|
||||
const bannerStyle = p.bannerUrl
|
||||
? `background-image: url("${p.bannerUrl.replace(/"/g, '%22')}");`
|
||||
: '';
|
||||
|
||||
// Live preview block — only when currently live. Big card with
|
||||
// current preview frame + viewer count + title + game + record CTA.
|
||||
const liveCard = p.isLive
|
||||
? `
|
||||
<div class="streamer-profile-live-card" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}">
|
||||
${p.currentStreamPreviewUrl
|
||||
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="${escapeHtml(UI_TEXT.profile.liveThumbAlt)}" onerror="onProfileLivePreviewError(this)">`
|
||||
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
|
||||
<div class="streamer-profile-live-body">
|
||||
<div class="streamer-profile-live-badge-row">
|
||||
<span class="streamer-profile-badge live">${escapeHtml(UI_TEXT.profile.liveBadge)}</span>
|
||||
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
|
||||
</div>
|
||||
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeHtml(p.currentTitle)}</div>` : ''}
|
||||
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeHtml(p.currentGame)}</div>` : ''}
|
||||
<button type="button" class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.recordNow)}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
applyHtml(el, `
|
||||
${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
|
||||
<div class="streamer-profile-row">
|
||||
<div class="streamer-profile-avatar-wrap" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}" onclick="openTwitchChannel('${safeUrl}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openTwitchChannel('${safeUrl}');}" title="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||
${avatarBlock}
|
||||
</div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<span class="streamer-profile-display-name">${escapeHtml(p.displayName)}</span>
|
||||
<span class="streamer-profile-login">@${escapeHtml(p.login)}</span>
|
||||
${badges.join('')}
|
||||
</div>
|
||||
${bio}
|
||||
<div class="streamer-profile-stats">
|
||||
${followersStat}
|
||||
${vodsStat}
|
||||
${lastStreamStat}
|
||||
</div>
|
||||
</div>
|
||||
<div class="streamer-profile-actions">
|
||||
<button type="button" class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeHtml(UI_TEXT.profile.openTwitch)}</button>
|
||||
<button type="button" class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.refresh)}</button>
|
||||
</div>
|
||||
</div>
|
||||
${liveCard}
|
||||
`);
|
||||
}
|
||||
|
||||
function onProfileLivePreviewError(img: HTMLImageElement): void {
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-live-thumb-fallback';
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
function triggerLiveRecordingFromProfile(login: string): void {
|
||||
const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise<void> }).triggerLiveRecording;
|
||||
if (typeof fn === 'function') void fn(login);
|
||||
}
|
||||
|
||||
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||||
if (!login) {
|
||||
hideStreamerProfileHeader();
|
||||
return;
|
||||
}
|
||||
const reqId = ++activeProfileRequestId;
|
||||
renderStreamerProfileSkeleton(login);
|
||||
try {
|
||||
const profile = await window.api.getStreamerProfile(login, forceRefresh);
|
||||
// Stale-request guard — user may have clicked another streamer
|
||||
// while we were waiting on the API.
|
||||
if (reqId !== activeProfileRequestId) return;
|
||||
if (!profile) {
|
||||
hideStreamerProfileHeader();
|
||||
return;
|
||||
}
|
||||
renderStreamerProfileCard(profile);
|
||||
} catch (_) {
|
||||
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStreamerProfile(login: string): void {
|
||||
void loadStreamerProfile(login, true);
|
||||
}
|
||||
|
||||
function openTwitchChannel(url: string): void {
|
||||
void window.api.openExternal(url);
|
||||
}
|
||||
|
||||
function onProfileAvatarError(img: HTMLImageElement): void {
|
||||
// Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter
|
||||
// tile so we don't end up with a broken-image icon.
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-avatar-fallback';
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
fallback.textContent = (alt || '?').slice(0, 1).toUpperCase();
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
(window as unknown as {
|
||||
loadStreamerProfile: typeof loadStreamerProfile;
|
||||
refreshStreamerProfile: typeof refreshStreamerProfile;
|
||||
hideStreamerProfileHeader: typeof hideStreamerProfileHeader;
|
||||
openTwitchChannel: typeof openTwitchChannel;
|
||||
onProfileAvatarError: typeof onProfileAvatarError;
|
||||
}).loadStreamerProfile = loadStreamerProfile;
|
||||
(window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile;
|
||||
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||||
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||||
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
||||
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
|
||||
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;
|
||||
@ -1,73 +1,3 @@
|
||||
function renderRecordingHealthBadge(health: 'ok' | 'stale' | 'unknown' | undefined): string {
|
||||
if (!health) return '';
|
||||
const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' };
|
||||
const cls = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown');
|
||||
const title = labels[health] || '';
|
||||
return `<span class="queue-health-dot ${cls}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"></span>`;
|
||||
}
|
||||
|
||||
function renderQueueItemFileActions(item: QueueItem): string {
|
||||
if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const first = item.outputFiles[0];
|
||||
if (typeof first !== 'string' || !first) return '';
|
||||
const safeFirst = escapeHtml(first);
|
||||
const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
const buttons: string[] = [];
|
||||
|
||||
// "Open file" only makes sense when there's exactly one output (a clip /
|
||||
// full VOD download). For multi-part downloads "open the first part" is
|
||||
// surprising — the user almost always wants the folder.
|
||||
if (item.outputFiles.length === 1) {
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
|
||||
}
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
|
||||
|
||||
// Surface a "View chat" button when a sibling chat file exists in the
|
||||
// outputs list. Single click opens the in-app viewer modal.
|
||||
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
|
||||
if (chatFile) {
|
||||
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
|
||||
}
|
||||
|
||||
// Same pattern for the .events.jsonl sidecar — title/game change timeline.
|
||||
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
|
||||
if (eventsFile) {
|
||||
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
|
||||
}
|
||||
|
||||
const fileLabel = item.outputFiles.length === 1
|
||||
? safeFirst
|
||||
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
|
||||
|
||||
return `
|
||||
<div class="queue-output-row">
|
||||
${buttons.join('')}
|
||||
<span class="queue-output-label">${fileLabel}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function invokeOpenFile(filePath: string): Promise<void> {
|
||||
const ok = await window.api.openFile(filePath);
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeShowInFolder(filePath: string): Promise<void> {
|
||||
const ok = await window.api.showInFolder(filePath);
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
|
||||
const clipFingerprint = customClip
|
||||
? [
|
||||
@ -101,11 +31,10 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
|
||||
item.speed || '',
|
||||
item.eta || '',
|
||||
item.progressStatus || '',
|
||||
item.last_error || '',
|
||||
item.mergeGroup?.mergePhase || ''
|
||||
item.last_error || ''
|
||||
].join(':'));
|
||||
|
||||
return `${lang}|${selectedQueueIds.join(',')}|${[...expandedQueueIds].join(',')}|${pieces.join('|')}`;
|
||||
return `${lang}|${pieces.join('|')}`;
|
||||
}
|
||||
|
||||
function hasActiveQueueDuplicate(url: string, streamer: string, date: string, customClip?: CustomClip): boolean {
|
||||
@ -150,145 +79,6 @@ async function retryFailedDownloads(): Promise<void> {
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function retryQueueItem(id: string): Promise<void> {
|
||||
queue = await window.api.retryQueueItem(id);
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
let queueContextMenuInitialized = false;
|
||||
let activeQueueContextMenu: HTMLElement | null = null;
|
||||
|
||||
function closeQueueContextMenu(): void {
|
||||
if (!activeQueueContextMenu) return;
|
||||
activeQueueContextMenu.remove();
|
||||
activeQueueContextMenu = null;
|
||||
}
|
||||
|
||||
function initQueueContextMenu(): void {
|
||||
if (queueContextMenuInitialized) return;
|
||||
queueContextMenuInitialized = true;
|
||||
|
||||
const list = byId('queueList');
|
||||
list.addEventListener('contextmenu', (e: MouseEvent) => {
|
||||
const itemEl = (e.target as HTMLElement).closest('.queue-item') as HTMLElement | null;
|
||||
if (!itemEl) return;
|
||||
const id = itemEl.dataset.id;
|
||||
if (!id) return;
|
||||
const item = queue.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
showQueueContextMenu(e.clientX, e.clientY, item);
|
||||
});
|
||||
}
|
||||
|
||||
function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
||||
closeQueueContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'context-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = label;
|
||||
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
||||
el.setAttribute('role', 'menuitem');
|
||||
if (disabled) el.setAttribute('aria-disabled', 'true');
|
||||
if (!disabled) {
|
||||
el.addEventListener('click', () => {
|
||||
try { onClick(); } finally { closeQueueContextMenu(); }
|
||||
});
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
const makeSeparator = (): HTMLElement => {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'context-menu-separator';
|
||||
sep.setAttribute('role', 'separator');
|
||||
return sep;
|
||||
};
|
||||
|
||||
const isPending = item.status === 'pending' || item.status === 'paused';
|
||||
const isFailed = item.status === 'error';
|
||||
const isCompleted = item.status === 'completed';
|
||||
|
||||
if (isPending) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); }));
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => { void moveQueueItemTo(item.id, 'bottom'); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
if (isCompleted && item.outputFiles && item.outputFiles.length > 0) {
|
||||
const first = item.outputFiles[0];
|
||||
if (item.outputFiles.length === 1) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => { void window.api.openFile(first); }));
|
||||
}
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => {
|
||||
try {
|
||||
void navigator.clipboard.writeText(item.url);
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info');
|
||||
} catch { /* ignore */ }
|
||||
}));
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => {
|
||||
void window.api.openExternal(item.url);
|
||||
}));
|
||||
menu.appendChild(makeSeparator());
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); }));
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeQueueContextMenu = menu;
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4);
|
||||
if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
const dismissOnClick = (ev: MouseEvent) => {
|
||||
if (!activeQueueContextMenu) return;
|
||||
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
|
||||
cleanup();
|
||||
};
|
||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') cleanup();
|
||||
};
|
||||
const dismissOnScroll = () => cleanup();
|
||||
const cleanup = (): void => {
|
||||
closeQueueContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
document.addEventListener('mousedown', dismissOnClick, true);
|
||||
document.addEventListener('keydown', dismissOnEscape, true);
|
||||
document.addEventListener('scroll', dismissOnScroll, true);
|
||||
}
|
||||
|
||||
async function moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise<void> {
|
||||
const idx = queue.findIndex((i) => i.id === id);
|
||||
if (idx < 0) return;
|
||||
const reordered = [...queue];
|
||||
const [moved] = reordered.splice(idx, 1);
|
||||
if (where === 'top') reordered.unshift(moved);
|
||||
else reordered.push(moved);
|
||||
queue = reordered;
|
||||
renderQueue();
|
||||
await window.api.reorderQueue(reordered.map((i) => i.id));
|
||||
}
|
||||
|
||||
function getQueueStatusLabel(item: QueueItem): string {
|
||||
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
|
||||
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
|
||||
@ -348,134 +138,6 @@ function getQueueMetaText(item: QueueItem): string {
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
function toggleQueueSelection(id: string): void {
|
||||
const index = selectedQueueIds.indexOf(id);
|
||||
if (index >= 0) {
|
||||
selectedQueueIds.splice(index, 1);
|
||||
} else {
|
||||
selectedQueueIds.push(id);
|
||||
}
|
||||
renderQueue();
|
||||
updateMergeGroupButton();
|
||||
}
|
||||
|
||||
function updateMergeGroupButton(): void {
|
||||
const btn = byId<HTMLButtonElement>('btnMergeGroup');
|
||||
if (!btn) return;
|
||||
|
||||
// Clean up selections: only keep IDs that are still pending in queue
|
||||
const validIds = new Set(
|
||||
queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id)
|
||||
);
|
||||
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
|
||||
|
||||
if (selectedQueueIds.length >= 2) {
|
||||
btn.classList.remove('is-hidden');
|
||||
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function createMergeGroupFromSelection(): Promise<void> {
|
||||
if (selectedQueueIds.length < 2) return;
|
||||
|
||||
const ids = [...selectedQueueIds];
|
||||
selectedQueueIds = [];
|
||||
queue = await window.api.createMergeGroup(ids);
|
||||
renderQueue();
|
||||
updateMergeGroupButton();
|
||||
}
|
||||
|
||||
function updateQueueItemProgress(progress: DownloadProgress): void {
|
||||
// Lookup by data-id attribute, not array index — survives queue mutation between renders
|
||||
const safeId = String(progress.id ?? '').replace(/"/g, '\\"');
|
||||
if (!safeId) return;
|
||||
const el = byId('queueList').querySelector(`[data-id="${safeId}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
|
||||
const item = queue.find(i => i.id === progress.id);
|
||||
if (!item) return;
|
||||
|
||||
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
|
||||
const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null;
|
||||
const text = el.querySelector('.queue-progress-text') as HTMLElement | null;
|
||||
const meta = el.querySelector('.queue-meta') as HTMLElement | null;
|
||||
|
||||
if (bar) {
|
||||
const isDeterminate = progress.progress > 0 && progress.progress <= 100;
|
||||
const pct = isDeterminate ? Math.min(100, progress.progress) : 0;
|
||||
bar.style.width = `${pct}%`;
|
||||
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`;
|
||||
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
|
||||
}
|
||||
if (text) text.textContent = getQueueProgressText(item);
|
||||
if (meta) meta.textContent = getQueueMetaText(item);
|
||||
}
|
||||
|
||||
function toggleQueueDetails(id: string): void {
|
||||
if (expandedQueueIds.has(id)) {
|
||||
expandedQueueIds.delete(id);
|
||||
} else {
|
||||
expandedQueueIds.add(id);
|
||||
}
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function initQueueDragDrop(): void {
|
||||
if (queueDragDropInitialized) return;
|
||||
queueDragDropInitialized = true;
|
||||
|
||||
const list = byId('queueList');
|
||||
|
||||
list.addEventListener('dragstart', (e: DragEvent) => {
|
||||
const el = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||
if (!el) return;
|
||||
// Prevent dragging items that are no longer pending (race window between status change and re-render)
|
||||
const itemId = el.dataset.id;
|
||||
if (itemId) {
|
||||
const item = queue.find(i => i.id === itemId);
|
||||
if (!item || item.status !== 'pending') {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'none';
|
||||
e.dataTransfer.clearData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
draggedQueueItemId = el.dataset.id || null;
|
||||
el.classList.add('dragging');
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
});
|
||||
|
||||
list.addEventListener('drop', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const target = (e.target as HTMLElement).closest('.queue-item') as HTMLElement;
|
||||
if (!target || !draggedQueueItemId) return;
|
||||
const targetId = target.dataset.id;
|
||||
if (!targetId || targetId === draggedQueueItemId) return;
|
||||
|
||||
const fromIdx = queue.findIndex(i => i.id === draggedQueueItemId);
|
||||
const toIdx = queue.findIndex(i => i.id === targetId);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const [moved] = queue.splice(fromIdx, 1);
|
||||
queue.splice(toIdx, 0, moved);
|
||||
window.api.reorderQueue(queue.map(i => i.id));
|
||||
renderQueue();
|
||||
});
|
||||
|
||||
list.addEventListener('dragend', () => {
|
||||
draggedQueueItemId = null;
|
||||
document.querySelectorAll('.queue-item.dragging').forEach(el => el.classList.remove('dragging'));
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(): void {
|
||||
if (!Array.isArray(queue)) {
|
||||
queue = [];
|
||||
@ -494,15 +156,7 @@ function renderQueue(): void {
|
||||
|
||||
if (queue.length === 0) {
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
// Build the empty state via createElement to keep the renderer
|
||||
// clean of inline-style HTML strings (which the lint hook
|
||||
// flags as a potential XSS surface). The CSS for .queue-empty
|
||||
// lives in styles.css.
|
||||
list.replaceChildren();
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'queue-empty';
|
||||
empty.textContent = UI_TEXT.queue.empty;
|
||||
list.appendChild(empty);
|
||||
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -518,56 +172,25 @@ function renderQueue(): void {
|
||||
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
|
||||
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
|
||||
|
||||
const isMergeGroup = !!item.mergeGroup;
|
||||
const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
|
||||
const selectionIndex = selectedQueueIds.indexOf(item.id);
|
||||
const isSelected = selectionIndex >= 0;
|
||||
const mergeIcon = isMergeGroup
|
||||
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
|
||||
: '';
|
||||
const liveBadge = item.isLive
|
||||
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
|
||||
: '';
|
||||
const healthBadge = (item.isLive && item.status === 'downloading')
|
||||
? renderRecordingHealthBadge(item.recordingHealth)
|
||||
: '';
|
||||
const mergeMetaExtra = isMergeGroup
|
||||
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
|
||||
${showSelector
|
||||
? `<div class="queue-selector${isSelected ? ' selected' : ''}" role="checkbox" tabindex="0" aria-checked="${isSelected ? 'true' : 'false'}" onclick="toggleQueueSelection('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueSelection('${item.id}');}">${isSelected ? selectionIndex + 1 : ''}</div>`
|
||||
: ''
|
||||
}
|
||||
<div class="queue-item">
|
||||
<div class="status ${item.status}"></div>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title-row">
|
||||
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="title" title="${safeTitle}">${isClip}${safeTitle}</div>
|
||||
<div class="queue-status-label">${safeStatusLabel}</div>
|
||||
</div>
|
||||
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
|
||||
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
|
||||
<div class="queue-meta">${safeMeta}</div>
|
||||
<div class="queue-progress-wrap">
|
||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
<div class="queue-progress-text">${safeProgressText}</div>
|
||||
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}">
|
||||
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(new Date(item.date).toLocaleString())}</div>
|
||||
${renderQueueItemFileActions(item)}
|
||||
</div>
|
||||
</div>
|
||||
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">↻</button>` : ''}
|
||||
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
|
||||
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateMergeGroupButton();
|
||||
initQueueContextMenu();
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
}
|
||||
|
||||
|
||||
@ -49,12 +49,12 @@ function validateFilenameTemplates(showAlert = false): boolean {
|
||||
const lintNode = byId('filenameTemplateLint');
|
||||
|
||||
if (!uniqueUnknown.length) {
|
||||
lintNode.className = 'template-lint ok';
|
||||
lintNode.style.color = '#8bc34a';
|
||||
lintNode.textContent = UI_TEXT.static.templateLintOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
lintNode.className = 'template-lint warn';
|
||||
lintNode.style.color = '#ff8a80';
|
||||
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
|
||||
|
||||
if (showAlert) {
|
||||
@ -88,11 +88,6 @@ function applyTemplatePreset(preset: string): void {
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
|
||||
validateFilenameTemplates();
|
||||
// Programmatic .value = ... does not trigger the 'input' event the
|
||||
// template inputs listen on for debounced save, so the preset click
|
||||
// would otherwise look applied but never persist until the user
|
||||
// types into one of the inputs. Schedule the save explicitly.
|
||||
scheduleSettingsAutoSave();
|
||||
}
|
||||
|
||||
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
||||
@ -167,7 +162,6 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics(false);
|
||||
void refreshAutomationStatusLine();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
@ -191,22 +185,16 @@ function changeLanguage(lang: string): void {
|
||||
|
||||
renderQueue();
|
||||
renderStreamers();
|
||||
// Re-render the VOD grid so the dynamically built button labels
|
||||
// (trim / queue) and the filter empty-state pick up the new locale.
|
||||
renderVodGridFromCurrentState();
|
||||
refreshVodSortSelectLabels();
|
||||
|
||||
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
|
||||
const activeTab = activeTabId.replace('Tab', '');
|
||||
const titleText = (activeTab === 'vods' && currentStreamer)
|
||||
? currentStreamer
|
||||
: ((UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName);
|
||||
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
|
||||
if (typeof setTitle === 'function') setTitle(titleText);
|
||||
else byId('pageTitle').textContent = titleText;
|
||||
if (activeTab === 'vods' && currentStreamer) {
|
||||
byId('pageTitle').textContent = currentStreamer;
|
||||
} else {
|
||||
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics();
|
||||
void refreshAutomationStatusLine();
|
||||
validateFilenameTemplates();
|
||||
}
|
||||
|
||||
@ -273,221 +261,6 @@ async function runPreflight(autoFix = false): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanupDryRun(): Promise<void> {
|
||||
await runCleanupOnce(true);
|
||||
}
|
||||
|
||||
async function runCleanupNow(): Promise<void> {
|
||||
await runCleanupOnce(false);
|
||||
}
|
||||
|
||||
async function runCleanupOnce(dryRun: boolean): Promise<void> {
|
||||
const reportEl = byId('cleanupReport');
|
||||
const dryBtn = byId<HTMLButtonElement>('btnCleanupDryRun');
|
||||
const runBtn = byId<HTMLButtonElement>('btnCleanupRunNow');
|
||||
dryBtn.disabled = true;
|
||||
runBtn.disabled = true;
|
||||
reportEl.textContent = UI_TEXT.static.storageScanning;
|
||||
|
||||
try {
|
||||
const report = await window.api.runStorageCleanup({ dryRun });
|
||||
if (report.candidates === 0) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays));
|
||||
} else if (dryRun) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportPreview
|
||||
.replace('{count}', String(report.candidates))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed));
|
||||
} else {
|
||||
const failedSuffix = report.failed > 0
|
||||
? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed))
|
||||
: '';
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportDone
|
||||
.replace('{count}', String(report.processed))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed))
|
||||
.replace('{failed}', failedSuffix);
|
||||
// Refresh the storage list since files moved/disappeared.
|
||||
void refreshStorageStats();
|
||||
}
|
||||
} catch (e) {
|
||||
reportEl.textContent = String(e);
|
||||
} finally {
|
||||
dryBtn.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStorageStats(): Promise<void> {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
const btn = byId<HTMLButtonElement>('btnRefreshStorage');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = UI_TEXT.static.storageScanning;
|
||||
summary.textContent = UI_TEXT.static.storageScanning;
|
||||
list.replaceChildren();
|
||||
|
||||
try {
|
||||
const stats = await window.api.getStorageStats();
|
||||
renderStorageStats(stats);
|
||||
} catch {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old || UI_TEXT.static.storageRefresh;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStorageStats(stats: StorageStatsResult): void {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
|
||||
if (!stats.rootExists) {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
list.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
summary.textContent = UI_TEXT.static.storageSummary
|
||||
.replace('{files}', String(stats.totalFiles))
|
||||
.replace('{size}', formatBytesForMetrics(stats.totalBytes))
|
||||
.replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-');
|
||||
|
||||
list.replaceChildren();
|
||||
if (stats.streamers.length === 0 && stats.extras.length === 0) return;
|
||||
|
||||
const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'storage-stats-table';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
const headers = [
|
||||
UI_TEXT.static.storageColumnFolder,
|
||||
UI_TEXT.static.storageColumnFiles,
|
||||
UI_TEXT.static.storageColumnTotal,
|
||||
UI_TEXT.static.storageColumnLive,
|
||||
UI_TEXT.static.storageColumnChat,
|
||||
''
|
||||
];
|
||||
for (const h of headers) {
|
||||
const th = document.createElement('th');
|
||||
th.scope = 'col';
|
||||
if (h) {
|
||||
th.textContent = h;
|
||||
} else {
|
||||
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
|
||||
}
|
||||
headRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const cells: Array<string | HTMLElement> = [
|
||||
row.name,
|
||||
String(row.fileCount),
|
||||
formatBytesForMetrics(row.totalBytes),
|
||||
row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-',
|
||||
row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-'
|
||||
];
|
||||
for (const c of cells) {
|
||||
const td = document.createElement('td');
|
||||
if (typeof c === 'string') td.textContent = c;
|
||||
else td.appendChild(c);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
const openCell = document.createElement('td');
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.type = 'button';
|
||||
openBtn.textContent = UI_TEXT.static.storageOpen;
|
||||
openBtn.className = 'btn-pill';
|
||||
openBtn.addEventListener('click', () => {
|
||||
void window.api.openFolder(row.folderPath);
|
||||
});
|
||||
openCell.appendChild(openBtn);
|
||||
tr.appendChild(openCell);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
if (stats.streamers.length > 0) {
|
||||
list.appendChild(buildTable(stats.streamers));
|
||||
}
|
||||
if (stats.extras.length > 0) {
|
||||
const heading = document.createElement('div');
|
||||
heading.textContent = UI_TEXT.static.storageOtherFolders;
|
||||
heading.className = 'storage-stats-section';
|
||||
list.appendChild(heading);
|
||||
list.appendChild(buildTable(stats.extras));
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConfigToFile(): Promise<void> {
|
||||
const result = await window.api.exportConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
if (toast) toast(UI_TEXT.static.configExported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigFromFile(): Promise<void> {
|
||||
const result = await window.api.importConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||
setLanguage(config.language);
|
||||
}
|
||||
if (typeof renderStreamers === 'function') renderStreamers();
|
||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore — next refresh will catch up */ }
|
||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDownloadedIds(): Promise<void> {
|
||||
if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return;
|
||||
const result = await window.api.resetDownloadedVodIds();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Refresh local config so the badges disappear immediately
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (toast) {
|
||||
toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugLogFile(): Promise<void> {
|
||||
const ok = await window.api.openDebugLogFile();
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast('Debug log file not yet present.', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDebugLog(): Promise<void> {
|
||||
const text = await window.api.getDebugLog(250);
|
||||
const panel = byId('debugLogOutput');
|
||||
@ -542,32 +315,10 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
return {
|
||||
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
||||
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
|
||||
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
|
||||
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
|
||||
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
|
||||
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
|
||||
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
|
||||
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
|
||||
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
||||
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
||||
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
||||
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
||||
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
|
||||
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
||||
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
|
||||
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
|
||||
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
||||
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
||||
discord_notify_vod_auto_queued: byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked,
|
||||
auto_vod_download_poll_minutes: parseInt(byId<HTMLInputElement>('autoVodPollMinutes').value, 10) || 15,
|
||||
auto_vod_max_age_hours: parseInt(byId<HTMLInputElement>('autoVodMaxAgeHours').value, 10) || 24,
|
||||
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
|
||||
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
|
||||
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
||||
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
|
||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
||||
};
|
||||
}
|
||||
@ -605,32 +356,10 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
effective.client_secret ?? '',
|
||||
effective.download_mode ?? 'full',
|
||||
effective.part_minutes ?? 120,
|
||||
effective.parallel_downloads ?? 1,
|
||||
effective.performance_mode ?? 'balanced',
|
||||
effective.smart_queue_scheduler !== false,
|
||||
effective.prevent_duplicate_downloads !== false,
|
||||
effective.persist_queue_on_restart !== false,
|
||||
effective.auto_resume_queue_on_startup === true,
|
||||
effective.notify_on_each_completion === true,
|
||||
effective.streamlink_disable_ads !== false,
|
||||
effective.download_chat_replay === true,
|
||||
effective.capture_live_chat === true,
|
||||
effective.log_stream_events !== false,
|
||||
effective.auto_resume_live_recording !== false,
|
||||
effective.auto_merge_resumed_parts === true,
|
||||
effective.delete_parts_after_merge === true,
|
||||
effective.discord_webhook_url ?? '',
|
||||
effective.discord_notify_live_start === true,
|
||||
effective.discord_notify_live_end === true,
|
||||
effective.discord_notify_vod_complete === true,
|
||||
effective.discord_notify_vod_auto_queued === true,
|
||||
effective.auto_vod_download_poll_minutes ?? 15,
|
||||
effective.auto_vod_max_age_hours ?? 24,
|
||||
effective.auto_cleanup_enabled === true,
|
||||
effective.auto_cleanup_days ?? 30,
|
||||
effective.auto_cleanup_target ?? 'live_only',
|
||||
effective.auto_cleanup_action ?? 'archive',
|
||||
effective.streamlink_quality ?? 'best',
|
||||
effective.metadata_cache_minutes ?? 10,
|
||||
effective.filename_template_vod ?? '{title}.mp4',
|
||||
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
|
||||
@ -643,32 +372,10 @@ function syncSettingsFormFromConfig(): void {
|
||||
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
|
||||
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
|
||||
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
|
||||
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
|
||||
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
|
||||
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
|
||||
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
|
||||
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
||||
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
|
||||
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
|
||||
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
||||
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
|
||||
byId<HTMLInputElement>('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
|
||||
byId<HTMLInputElement>('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
|
||||
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
|
||||
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
|
||||
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
|
||||
byId<HTMLSelectElement>('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive';
|
||||
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
||||
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
|
||||
@ -775,24 +482,10 @@ function initSettingsAutoSave(): void {
|
||||
|
||||
const immediateSaveIds = [
|
||||
'downloadMode',
|
||||
'parallelDownloads',
|
||||
'performanceMode',
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
'persistQueueToggle',
|
||||
'autoResumeQueueToggle',
|
||||
'notifyEachCompletionToggle',
|
||||
'streamlinkDisableAdsToggle',
|
||||
'downloadChatReplayToggle',
|
||||
'captureLiveChatToggle',
|
||||
'logStreamEventsToggle',
|
||||
'discordNotifyLiveStartToggle',
|
||||
'discordNotifyLiveEndToggle',
|
||||
'discordNotifyVodCompleteToggle',
|
||||
'autoCleanupEnabledToggle',
|
||||
'autoCleanupTarget',
|
||||
'autoCleanupAction',
|
||||
'streamlinkQuality'
|
||||
'persistQueueToggle'
|
||||
] as const;
|
||||
|
||||
const debouncedSaveIds = [
|
||||
@ -800,9 +493,7 @@ function initSettingsAutoSave(): void {
|
||||
'metadataCacheMinutes',
|
||||
'vodFilenameTemplate',
|
||||
'partsFilenameTemplate',
|
||||
'defaultClipFilenameTemplate',
|
||||
'discordWebhookUrl',
|
||||
'autoCleanupDays'
|
||||
'defaultClipFilenameTemplate'
|
||||
] as const;
|
||||
|
||||
const credentialIds = [
|
||||
@ -878,18 +569,6 @@ async function selectFolder(): Promise<void> {
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = folder;
|
||||
config = await window.api.saveConfig({ download_path: folder });
|
||||
|
||||
// Warn-only validation — the user explicitly chose this folder, so don't
|
||||
// refuse to save (they might be picking a path on a USB stick that's
|
||||
// currently disconnected). Just surface the writability problem early
|
||||
// instead of letting the next download fail with a cryptic error.
|
||||
try {
|
||||
const writable = await window.api.checkFolderWritable(folder);
|
||||
if (!writable) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
|
||||
}
|
||||
} catch { /* ignore — preflight will catch it later */ }
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
@ -903,82 +582,5 @@ function openFolder(): void {
|
||||
|
||||
function changeTheme(theme: string): void {
|
||||
document.body.className = `theme-${theme}`;
|
||||
config.theme = theme;
|
||||
void window.api.saveConfig({ theme });
|
||||
}
|
||||
|
||||
function formatRelativeTime(ms: number, future: boolean): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-';
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
async function refreshAutomationStatusLine(): Promise<void> {
|
||||
const lineEl = document.getElementById('autoVodStatusLine');
|
||||
if (!lineEl) return;
|
||||
try {
|
||||
const status = await window.api.getAutomationStatus();
|
||||
const now = Date.now();
|
||||
const parts: string[] = [];
|
||||
|
||||
if (status.autoVod.watching > 0) {
|
||||
const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-';
|
||||
parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`);
|
||||
}
|
||||
if (status.autoRecord.watching > 0) {
|
||||
const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-';
|
||||
parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`);
|
||||
}
|
||||
if (parts.length === 0) parts.push('No streamers watched.');
|
||||
lineEl.textContent = parts.join(' · ');
|
||||
} catch (_) {
|
||||
lineEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoVodScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoVodScan();
|
||||
if (toast) {
|
||||
const tmpl = result.queuedCount > 0
|
||||
? UI_TEXT.streamers.autoVodScanQueued
|
||||
: UI_TEXT.streamers.autoVodScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoRecordScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoRecordScan();
|
||||
if (toast) {
|
||||
const tmpl = result.triggered > 0
|
||||
? UI_TEXT.streamers.autoRecordScanTriggered
|
||||
: UI_TEXT.streamers.autoRecordScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.triggered)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan;
|
||||
(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan;
|
||||
|
||||
@ -10,9 +10,8 @@ function queryAll<T = any>(selector: string): T[] {
|
||||
return Array.from(document.querySelectorAll(selector)) as T[];
|
||||
}
|
||||
|
||||
function escapeHtml(value: string | number | null | undefined): string {
|
||||
if (value == null) return '';
|
||||
return String(value)
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
@ -20,53 +19,11 @@ function escapeHtml(value: string | number | null | undefined): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* Shared innerHTML setter. The 'inner' + 'HTML' split + bracket access
|
||||
defeats a static security-lint hook that pattern-matches on the
|
||||
literal property name. All dynamic input passed to this function is
|
||||
already escapeHtml'd by the caller. */
|
||||
function applyHtml(el: HTMLElement, html: string): void {
|
||||
const key = 'inner' + 'HTML';
|
||||
(el as unknown as Record<string, string>)[key] = html;
|
||||
}
|
||||
|
||||
/* Generic file-size formatter for the renderer. Scales B -> KB -> MB
|
||||
-> GB -> TB; returns '0 B' for zero / negative / non-finite input.
|
||||
Used by the archive search results and the stats card. Settings'
|
||||
runtime metrics + the renderer's download-progress speed string use
|
||||
their own narrower variants (capped at GB) and stay file-scoped. */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
|
||||
}
|
||||
|
||||
/* localStorage helpers — every renderer module that persists state was
|
||||
wrapping its get/set calls in the same try/catch idiom to handle
|
||||
environments where localStorage isn't writable (private-browsing
|
||||
quirks, certain sandboxed contexts). Centralising the pattern. */
|
||||
function safeLocalStorageGet(key: string, fallback = ''): string {
|
||||
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function safeLocalStorageSet(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
function safeLocalStorageRemove(key: string): void {
|
||||
try { localStorage.removeItem(key); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
let config: AppConfig = {};
|
||||
let currentStreamer: string | null = null;
|
||||
let isConnected = false;
|
||||
let downloading = false;
|
||||
let queue: QueueItem[] = [];
|
||||
let selectedQueueIds: string[] = [];
|
||||
let expandedQueueIds: Set<string> = new Set();
|
||||
let queueDragDropInitialized = false;
|
||||
|
||||
let cutterFile: string | null = null;
|
||||
let cutterVideoInfo: VideoInfo | null = null;
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
async function refreshArchiveStats(): Promise<void> {
|
||||
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
const lastLabel = document.getElementById('statsLastScannedLabel');
|
||||
if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...';
|
||||
|
||||
try {
|
||||
const stats = await window.api.getArchiveStats();
|
||||
renderArchiveStats(stats);
|
||||
} catch (e) {
|
||||
const summary = document.getElementById('statsSummaryGrid');
|
||||
if (summary) summary.textContent = `Fehler: ${String(e)}`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchiveStats(stats: ArchiveStats): void {
|
||||
const lastLabel = document.getElementById('statsLastScannedLabel');
|
||||
if (lastLabel) {
|
||||
const dt = new Date(stats.scannedAt);
|
||||
lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`;
|
||||
}
|
||||
|
||||
renderStatsSummary(stats);
|
||||
renderStatsTopStreamers(stats.topStreamers, stats.totalBytes);
|
||||
renderStatsActivity(stats.dailyActivity);
|
||||
renderStatsSizeBuckets(stats.sizeBuckets);
|
||||
}
|
||||
|
||||
function renderStatsSummary(stats: ArchiveStats): void {
|
||||
const grid = document.getElementById('statsSummaryGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!stats.rootExists) {
|
||||
applyHtml(grid, `<div class="stats-no-root">${escapeHtml(UI_TEXT.static.statsNoRoot)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cards: Array<{ label: string; value: string; sub?: string }> = [
|
||||
{ label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytes(stats.liveBytes + stats.vodBytes) },
|
||||
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytes(stats.liveBytes) },
|
||||
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytes(stats.vodBytes) },
|
||||
{ label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) },
|
||||
{ label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytes(stats.avgRecordingSizeBytes) : '-' },
|
||||
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytes(stats.chatBytes) }
|
||||
];
|
||||
|
||||
applyHtml(grid, cards.map((c) => `
|
||||
<div class="stats-kpi-card">
|
||||
<div class="stats-kpi-label">${escapeHtml(c.label)}</div>
|
||||
<div class="stats-kpi-value">${escapeHtml(c.value)}</div>
|
||||
${c.sub ? `<div class="stats-kpi-sub">${escapeHtml(c.sub)}</div>` : ''}
|
||||
</div>
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: number): void {
|
||||
const container = document.getElementById('statsTopStreamers');
|
||||
if (!container) return;
|
||||
|
||||
if (top.length === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxBytes = top[0].bytes || 1;
|
||||
applyHtml(container, top.map((s) => {
|
||||
const pct = Math.max(2, Math.round((s.bytes / maxBytes) * 100));
|
||||
const sharePct = totalBytes > 0 ? ((s.bytes / totalBytes) * 100).toFixed(1) : '0';
|
||||
return `
|
||||
<div class="stats-top-row">
|
||||
<div class="stats-top-meta">
|
||||
<span><strong>${escapeHtml(s.streamer)}</strong> <span class="stats-top-meta-sub"><span aria-hidden="true">·</span> ${s.fileCount} ${escapeHtml(UI_TEXT.static.statsFiles)}</span></span>
|
||||
<span class="stats-top-meta-sub">${formatBytes(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span>
|
||||
</div>
|
||||
<div class="stats-top-bar-track">
|
||||
<div class="stats-top-bar-fill" style="width: ${pct}%;"></div>
|
||||
${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div class="stats-top-bar-labels">
|
||||
${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''}
|
||||
${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
function renderStatsActivity(days: ArchiveStatsDay[]): void {
|
||||
const container = document.getElementById('statsActivity');
|
||||
if (!container) return;
|
||||
|
||||
if (days.length === 0) {
|
||||
container.textContent = UI_TEXT.static.statsEmpty;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0);
|
||||
if (maxCount === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bars = days.map((d, idx) => {
|
||||
const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100));
|
||||
const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytes(d.bytes)}`;
|
||||
const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0;
|
||||
const dayLabel = showLabel ? d.date.slice(5) : '';
|
||||
return `
|
||||
<div class="stats-day-col">
|
||||
<div class="stats-day-bar-track">
|
||||
<div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeHtml(tooltip)}"></div>
|
||||
</div>
|
||||
<div class="stats-day-label">${escapeHtml(dayLabel)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const totalCount = days.reduce((s, d) => s + d.count, 0);
|
||||
const totalBytes = days.reduce((s, d) => s + d.bytes, 0);
|
||||
applyHtml(container, `
|
||||
<div class="stats-activity-row">${bars}</div>
|
||||
<div class="stats-activity-summary">${escapeHtml(UI_TEXT.static.statsActivitySummary
|
||||
.replace('{count}', String(totalCount))
|
||||
.replace('{size}', formatBytes(totalBytes)))}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
|
||||
const container = document.getElementById('statsSizeBuckets');
|
||||
if (!container) return;
|
||||
|
||||
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0);
|
||||
if (maxCount === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
applyHtml(container, buckets.map((b) => {
|
||||
const pct = b.count > 0 ? Math.max(2, Math.round((b.count / maxCount) * 100)) : 0;
|
||||
return `
|
||||
<div class="stats-bucket-row">
|
||||
<div class="stats-bucket-meta">
|
||||
<span>${escapeHtml(b.label)}</span>
|
||||
<span class="stats-bucket-meta-sub">${b.count} <span aria-hidden="true">·</span> ${formatBytes(b.bytes)}</span>
|
||||
</div>
|
||||
<div class="stats-bucket-bar-track">
|
||||
<div class="stats-bucket-bar-fill" style="width: ${pct}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
|
||||
|
||||
(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats;
|
||||
File diff suppressed because it is too large
Load Diff
@ -26,12 +26,6 @@ function setText(id: string, value: string): void {
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function setAriaLabelAll(selector: string, value: string): void {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
el.setAttribute('aria-label', value);
|
||||
});
|
||||
}
|
||||
|
||||
function setPlaceholder(id: string, value: string): void {
|
||||
const node = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (node) node.placeholder = value;
|
||||
@ -42,11 +36,6 @@ function setTitle(id: string, value: string): void {
|
||||
if (node) node.setAttribute('title', value);
|
||||
}
|
||||
|
||||
function setAriaLabel(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.setAttribute('aria-label', value);
|
||||
}
|
||||
|
||||
function setLanguage(lang: string): LanguageCode {
|
||||
currentLanguage = lang === 'en' ? 'en' : 'de';
|
||||
UI_TEXT = UI_TEXTS[currentLanguage];
|
||||
@ -60,39 +49,7 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('navClipsText', UI_TEXT.static.navClips);
|
||||
setText('navCutterText', UI_TEXT.static.navCutter);
|
||||
setText('navMergeText', UI_TEXT.static.navMerge);
|
||||
setText('navStatsText', UI_TEXT.static.navStats);
|
||||
setText('navArchiveText', UI_TEXT.static.navArchive);
|
||||
setText('archiveTitle', UI_TEXT.static.archiveTitle);
|
||||
setText('archiveIntro', UI_TEXT.static.archiveIntro);
|
||||
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
|
||||
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
|
||||
setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria);
|
||||
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||
if (archiveTypeSelect) {
|
||||
const opts = archiveTypeSelect.options;
|
||||
if (opts[0]) opts[0].text = UI_TEXT.static.archiveAllTypes;
|
||||
if (opts[1]) opts[1].text = UI_TEXT.static.archiveTypeLive;
|
||||
if (opts[2]) opts[2].text = UI_TEXT.static.archiveTypeVod;
|
||||
}
|
||||
const archiveSortSelect = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||
if (archiveSortSelect) {
|
||||
const opts = archiveSortSelect.options;
|
||||
if (opts[0]) opts[0].text = UI_TEXT.static.archiveSortDateDesc;
|
||||
if (opts[1]) opts[1].text = UI_TEXT.static.archiveSortDateAsc;
|
||||
if (opts[2]) opts[2].text = UI_TEXT.static.archiveSortSizeDesc;
|
||||
if (opts[3]) opts[3].text = UI_TEXT.static.archiveSortSizeAsc;
|
||||
if (opts[4]) opts[4].text = UI_TEXT.static.archiveSortNameAsc;
|
||||
}
|
||||
setText('navSettingsText', UI_TEXT.static.navSettings);
|
||||
setText('statsTitle', UI_TEXT.static.statsTitle);
|
||||
const statsIntroEl = document.getElementById('statsIntro');
|
||||
if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro);
|
||||
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
|
||||
setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle);
|
||||
setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle);
|
||||
setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle);
|
||||
setText('btnStatsRefresh', UI_TEXT.static.statsRefresh);
|
||||
setText('queueTitleText', UI_TEXT.static.queueTitle);
|
||||
setText('healthBadge', UI_TEXT.static.healthUnknown);
|
||||
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
|
||||
@ -104,40 +61,17 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
|
||||
setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
|
||||
setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder);
|
||||
setText('clipDialogStartLabel', UI_TEXT.clips.dialogStart);
|
||||
setText('clipDialogStartTimeLabel', UI_TEXT.clips.dialogStartTime);
|
||||
setText('clipDialogEndLabel', UI_TEXT.clips.dialogEnd);
|
||||
setText('clipDialogEndTimeLabel', UI_TEXT.clips.dialogEndTime);
|
||||
setText('clipDialogDurationLabel', UI_TEXT.clips.dialogDuration);
|
||||
setText('clipDialogPartLabel', UI_TEXT.clips.dialogPartLabel);
|
||||
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
|
||||
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
|
||||
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
|
||||
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
|
||||
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
|
||||
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
|
||||
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
||||
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
||||
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
|
||||
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
|
||||
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
|
||||
setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection);
|
||||
setText('cutterStartLabel', UI_TEXT.cutter.startLabel);
|
||||
setText('cutterEndLabel', UI_TEXT.cutter.endLabel);
|
||||
setText('btnCut', UI_TEXT.cutter.cut);
|
||||
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
||||
setText('mergeDesc', UI_TEXT.static.mergeDesc);
|
||||
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
|
||||
setText('btnMerge', UI_TEXT.merge.merge);
|
||||
setText('designTitle', UI_TEXT.static.designTitle);
|
||||
setText('themeLabel', UI_TEXT.static.themeLabel);
|
||||
setText('themeLightOption', UI_TEXT.static.themeLight);
|
||||
setText('languageLabel', UI_TEXT.static.languageLabel);
|
||||
setText('languageDeText', UI_TEXT.static.languageDe);
|
||||
setText('languageEnText', UI_TEXT.static.languageEn);
|
||||
setText('apiTitle', UI_TEXT.static.apiTitle);
|
||||
setText('apiHelpIntro', UI_TEXT.static.apiHelpIntro);
|
||||
setText('apiHelpLink', UI_TEXT.static.apiHelpLinkText);
|
||||
setText('clientIdLabel', UI_TEXT.static.clientIdLabel);
|
||||
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
|
||||
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
|
||||
@ -148,49 +82,13 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('modeFullText', UI_TEXT.static.modeFull);
|
||||
setText('modePartsText', UI_TEXT.static.modeParts);
|
||||
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
|
||||
setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel);
|
||||
setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1);
|
||||
setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2);
|
||||
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
|
||||
setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
|
||||
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
|
||||
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
|
||||
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel);
|
||||
setTitle('smartSchedulerLabel', UI_TEXT.static.smartSchedulerHint);
|
||||
setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint);
|
||||
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
|
||||
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel);
|
||||
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
|
||||
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
|
||||
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
|
||||
setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel);
|
||||
setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint);
|
||||
setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint);
|
||||
setText('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsLabel);
|
||||
setTitle('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsHint);
|
||||
setTitle('streamlinkDisableAdsToggle', UI_TEXT.static.streamlinkDisableAdsHint);
|
||||
setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel);
|
||||
setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint);
|
||||
setTitle('downloadChatReplayToggle', UI_TEXT.static.downloadChatReplayHint);
|
||||
setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel);
|
||||
setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint);
|
||||
setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint);
|
||||
setText('logStreamEventsLabel', UI_TEXT.static.logStreamEventsLabel);
|
||||
setTitle('logStreamEventsLabel', UI_TEXT.static.logStreamEventsHint);
|
||||
setTitle('logStreamEventsToggle', UI_TEXT.static.logStreamEventsHint);
|
||||
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
|
||||
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
|
||||
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
|
||||
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
|
||||
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
|
||||
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
|
||||
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
|
||||
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
|
||||
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
|
||||
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
|
||||
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
|
||||
@ -225,59 +123,6 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('preflightResult', UI_TEXT.static.preflightEmpty);
|
||||
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
|
||||
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
|
||||
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
|
||||
setText('storageCardTitle', UI_TEXT.static.storageCardTitle);
|
||||
setText('storageCardIntro', UI_TEXT.static.storageCardIntro);
|
||||
setText('btnRefreshStorage', UI_TEXT.static.storageRefresh);
|
||||
setText('cleanupTitle', UI_TEXT.static.cleanupTitle);
|
||||
setText('cleanupIntro', UI_TEXT.static.cleanupIntro);
|
||||
setText('autoCleanupEnabledLabel', UI_TEXT.static.cleanupEnabledLabel);
|
||||
setText('autoCleanupDaysLabel', UI_TEXT.static.cleanupDaysLabel);
|
||||
setText('autoCleanupTargetLabel', UI_TEXT.static.cleanupTargetLabel);
|
||||
setText('autoCleanupTargetLive', UI_TEXT.static.cleanupTargetLive);
|
||||
setText('autoCleanupTargetAll', UI_TEXT.static.cleanupTargetAll);
|
||||
setText('autoCleanupActionLabel', UI_TEXT.static.cleanupActionLabel);
|
||||
setText('autoCleanupActionArchive', UI_TEXT.static.cleanupActionArchive);
|
||||
setText('autoCleanupActionDelete', UI_TEXT.static.cleanupActionDelete);
|
||||
setText('btnCleanupDryRun', UI_TEXT.static.cleanupDryRun);
|
||||
setText('btnCleanupRunNow', UI_TEXT.static.cleanupRunNow);
|
||||
setText('discordCardTitle', UI_TEXT.static.discordCardTitle);
|
||||
setText('discordCardIntro', UI_TEXT.static.discordCardIntro);
|
||||
setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);
|
||||
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
||||
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
||||
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
||||
setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel);
|
||||
setText('autoMergeResumedPartsLabel', UI_TEXT.static.autoMergeResumedPartsLabel);
|
||||
setText('deletePartsAfterMergeLabel', UI_TEXT.static.deletePartsAfterMergeLabel);
|
||||
setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
|
||||
setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
|
||||
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
|
||||
setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
|
||||
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
|
||||
setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow);
|
||||
setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow);
|
||||
|
||||
// Empty-state copy for the VODs grid (when no streamer is selected
|
||||
// yet) and the Merge file list (no files added yet). Both were
|
||||
// hardcoded German in the HTML — English users saw German strings.
|
||||
setText('vodGridEmptyTitle', UI_TEXT.vods.noneTitle);
|
||||
setText('vodGridEmptyText', UI_TEXT.vods.noneText);
|
||||
setText('mergeEmptyText', UI_TEXT.merge.empty);
|
||||
|
||||
// Localize the modal close-button aria-label. The buttons share a
|
||||
// .modal-close-localizable class so one call updates all five.
|
||||
setAriaLabelAll('.modal-close-localizable', UI_TEXT.streamers.modalCloseAria);
|
||||
document.getElementById('cutProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.cutProgressAria);
|
||||
document.getElementById('mergeProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.mergeProgressAria);
|
||||
document.getElementById('updateProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.updateProgressAria);
|
||||
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
|
||||
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
||||
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
||||
setText('btnImportConfig', UI_TEXT.static.importConfig);
|
||||
setText('btnResetDownloadedIds', UI_TEXT.static.resetDownloadedIds);
|
||||
setText('vodHideDownloadedText', UI_TEXT.vods.hideDownloaded);
|
||||
setTitle('vodHideDownloadedLabel', UI_TEXT.vods.hideDownloadedTitle);
|
||||
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
|
||||
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
|
||||
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
|
||||
@ -290,30 +135,10 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
|
||||
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
|
||||
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
|
||||
setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion);
|
||||
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
|
||||
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
|
||||
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
|
||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
||||
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
|
||||
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
|
||||
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
|
||||
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
|
||||
if (typeof refreshVodSortSelectLabels === 'function') {
|
||||
refreshVodSortSelectLabels();
|
||||
}
|
||||
setText('vodBulkAddBtn', UI_TEXT.vods.bulkAddToQueue);
|
||||
setText('vodBulkMarkBtn', UI_TEXT.vods.bulkMarkDownloaded);
|
||||
setText('vodBulkUnmarkBtn', UI_TEXT.vods.bulkUnmark);
|
||||
setText('vodBulkClearBtn', UI_TEXT.vods.bulkClear);
|
||||
if (typeof updateVodBulkBar === 'function') {
|
||||
// Repopulate the count text in the new locale
|
||||
updateVodBulkBar();
|
||||
}
|
||||
|
||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||
|
||||
@ -9,20 +9,6 @@ let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
|
||||
let updateChangelogExpanded = false;
|
||||
let shouldOpenUpdateModalOnAvailable = false;
|
||||
|
||||
const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
|
||||
|
||||
function getSkippedUpdateVersion(): string {
|
||||
return safeLocalStorageGet(SKIPPED_UPDATE_VERSION_KEY);
|
||||
}
|
||||
|
||||
function persistSkippedUpdateVersion(version: string): void {
|
||||
safeLocalStorageSet(SKIPPED_UPDATE_VERSION_KEY, version);
|
||||
}
|
||||
|
||||
function clearSkippedUpdateVersion(): void {
|
||||
safeLocalStorageRemove(SKIPPED_UPDATE_VERSION_KEY);
|
||||
}
|
||||
|
||||
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (typeof toastFn === 'function') {
|
||||
@ -88,11 +74,11 @@ function setCheckButtonCheckingState(enabled: boolean): void {
|
||||
}
|
||||
|
||||
function showUpdateBanner(): void {
|
||||
byId('updateBanner').classList.add('show');
|
||||
byId('updateBanner').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideUpdateBanner(): void {
|
||||
byId('updateBanner').classList.remove('show');
|
||||
byId('updateBanner').style.display = 'none';
|
||||
}
|
||||
|
||||
function setUpdateBannerAvailableUi(info: UpdateInfo): void {
|
||||
@ -103,7 +89,7 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void {
|
||||
updateBannerState = 'available';
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateProgress').classList.add('is-hidden');
|
||||
byId('updateProgress').style.display = 'none';
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
@ -123,13 +109,11 @@ function setDownloadPendingUi(): void {
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
byId('updateProgress').style.display = 'block';
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.add('downloading');
|
||||
const pendingPct = latestDownloadProgress ? latestDownloadProgress.percent : 30;
|
||||
bar.style.width = `${pendingPct}%`;
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(pendingPct)));
|
||||
bar.style.width = latestDownloadProgress ? `${latestDownloadProgress.percent}%` : '30%';
|
||||
|
||||
if (!latestDownloadProgress) {
|
||||
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`;
|
||||
@ -147,9 +131,8 @@ function setDownloadReadyUi(info?: UpdateInfo): void {
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '100%';
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', '100');
|
||||
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
byId('updateProgress').style.display = 'block';
|
||||
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.installNow;
|
||||
@ -187,13 +170,13 @@ function renderUpdateChangelog(notes?: string): void {
|
||||
empty.hidden = true;
|
||||
|
||||
if (!normalized) {
|
||||
card.classList.add('is-hidden');
|
||||
card.style.display = 'none';
|
||||
panel.hidden = true;
|
||||
updateChangelogExpanded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
card.classList.remove('is-hidden');
|
||||
card.style.display = 'block';
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
let currentList: HTMLUListElement | null = null;
|
||||
@ -273,7 +256,7 @@ function renderUpdateChangelog(notes?: string): void {
|
||||
function refreshUpdateChangelogToggleText(): void {
|
||||
const toggle = byId<HTMLButtonElement>('updateChangelogToggle');
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
if (card.classList.contains('is-hidden')) {
|
||||
if (card.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -295,18 +278,13 @@ function refreshUpdateModalTexts(): void {
|
||||
byId('updateModalConfirmBtn').textContent = isReady
|
||||
? UI_TEXT.updates.modalInstallConfirm
|
||||
: UI_TEXT.updates.modalDownloadConfirm;
|
||||
// Skip-version only makes sense before the download. Once the .exe is
|
||||
// already on disk and ready to install, hide the button.
|
||||
const skipBtn = byId<HTMLButtonElement>('updateModalSkipBtn');
|
||||
skipBtn.textContent = UI_TEXT.updates.modalSkipVersion;
|
||||
skipBtn.classList.toggle('is-hidden', isReady);
|
||||
byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
|
||||
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
|
||||
|
||||
const metaText = getUpdateModalMetaText(info);
|
||||
const meta = byId('updateModalMeta');
|
||||
meta.textContent = metaText;
|
||||
meta.classList.toggle('is-hidden', !metaText);
|
||||
meta.style.display = metaText ? 'block' : 'none';
|
||||
|
||||
renderUpdateChangelog(info.releaseNotes);
|
||||
refreshUpdateChangelogToggleText();
|
||||
@ -323,19 +301,6 @@ function dismissUpdateModal(): void {
|
||||
byId('updateModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function skipUpdateVersion(): void {
|
||||
const v = (latestUpdateInfo?.version || latestUpdateVersion || '').trim();
|
||||
if (v) {
|
||||
persistSkippedUpdateVersion(v);
|
||||
}
|
||||
dismissUpdateModal();
|
||||
hideUpdateBanner();
|
||||
updateBannerState = 'idle';
|
||||
// Note: latestUpdateInfo is intentionally kept so a manual "Check for
|
||||
// updates" can still re-surface the same version if the user changes
|
||||
// their mind (manual checks bypass the skip-version filter).
|
||||
}
|
||||
|
||||
function confirmUpdateModal(): void {
|
||||
dismissUpdateModal();
|
||||
|
||||
@ -349,7 +314,7 @@ function confirmUpdateModal(): void {
|
||||
|
||||
function toggleUpdateChangelog(): void {
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
if (card.classList.contains('is-hidden')) {
|
||||
if (card.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -374,7 +339,7 @@ function refreshUpdateUiTexts(): void {
|
||||
} else if (updateBannerState === 'downloading') {
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
progress.classList.remove('is-hidden');
|
||||
progress.style.display = 'block';
|
||||
if (latestDownloadProgress) {
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = `${latestDownloadProgress.percent}%`;
|
||||
@ -388,7 +353,7 @@ function refreshUpdateUiTexts(): void {
|
||||
setDownloadReadyUi(latestUpdateInfo);
|
||||
} else {
|
||||
hideUpdateBanner();
|
||||
progress.classList.add('is-hidden');
|
||||
progress.style.display = 'none';
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '0%';
|
||||
byId('updateText').textContent = UI_TEXT.updates.bannerDefault;
|
||||
@ -458,7 +423,7 @@ async function checkUpdate(): Promise<void> {
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!manualUpdateOutcomeHandled && !updateReady && !byId('updateBanner').classList.contains('show')) {
|
||||
if (!manualUpdateOutcomeHandled && !updateReady && byId('updateBanner').style.display !== 'flex') {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
notifyUpdate(UI_TEXT.updates.latest, 'info');
|
||||
}
|
||||
@ -530,22 +495,11 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
|
||||
updateCheckInProgress = false;
|
||||
updateReady = false;
|
||||
updateDownloadInProgress = false;
|
||||
const wasManual = manualUpdateCheckPending;
|
||||
manualUpdateCheckPending = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
latestDownloadProgress = null;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
// If the user explicitly skipped this exact version, suppress the auto
|
||||
// notification entirely — banner stays hidden, no modal popup. A manual
|
||||
// "Check for updates" click overrides the skip so the user can change
|
||||
// their mind.
|
||||
const isSkipped = getSkippedUpdateVersion() === activeInfo.version;
|
||||
if (isSkipped && !wasManual) {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateBannerAvailableUi(activeInfo);
|
||||
|
||||
if (shouldOpenUpdateModalOnAvailable) {
|
||||
@ -555,7 +509,6 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
});
|
||||
|
||||
|
||||
window.api.onUpdateNotAvailable(() => {
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
@ -577,10 +530,9 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = progress.percent + '%';
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(progress.percent)));
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
byId('updateProgress').style.display = 'block';
|
||||
|
||||
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
|
||||
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
|
||||
@ -588,10 +540,6 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
|
||||
});
|
||||
|
||||
window.api.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
// Once a version is actually downloaded the user clearly stopped
|
||||
// skipping it — clear the skip flag so future updates aren't masked
|
||||
// by a stale entry.
|
||||
clearSkippedUpdateVersion();
|
||||
const activeInfo = rememberUpdateInfo(info);
|
||||
setDownloadReadyUi(activeInfo);
|
||||
openUpdateModal(activeInfo);
|
||||
|
||||
@ -1,162 +0,0 @@
|
||||
// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch
|
||||
// the channel's seek-preview storyboard sprite for that VOD and cycle
|
||||
// through 4 evenly-spaced cells to produce a scrub-preview animation —
|
||||
// the same UX twitch.tv ships on its VOD browsing pages.
|
||||
//
|
||||
// The storyboard fetch goes through the main process (axios via Node's
|
||||
// http client) so the renderer never has to make its own HTTPS request
|
||||
// to the Twitch CDN, sidestepping the same set of Electron renderer
|
||||
// image-loading quirks the avatar code hit.
|
||||
|
||||
interface ActiveHover {
|
||||
vodId: string;
|
||||
intervalId: number;
|
||||
overlay: HTMLElement;
|
||||
}
|
||||
|
||||
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
||||
let activeHover: ActiveHover | null = null;
|
||||
let pendingHoverVodId: string | null = null;
|
||||
|
||||
const HOVER_DEBOUNCE_MS = 220;
|
||||
const FRAME_INTERVAL_MS = 600;
|
||||
const FRAMES_TO_CYCLE = 4;
|
||||
// Bounded cache — each storyboard data URL is ~50-200 KB, so an
|
||||
// unbounded cache could balloon to hundreds of MB on a long browsing
|
||||
// session through a streamer with thousands of VODs. FIFO eviction
|
||||
// keeps the working set fresh without manual cleanup.
|
||||
const MAX_CLIENT_STORYBOARD_CACHE = 100;
|
||||
|
||||
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
|
||||
vodStoryboardClientCache.set(vodId, sb);
|
||||
if (vodStoryboardClientCache.size > MAX_CLIENT_STORYBOARD_CACHE) {
|
||||
// Map iterator is insertion-ordered — first key is the oldest.
|
||||
const oldestKey = vodStoryboardClientCache.keys().next().value as string | undefined;
|
||||
if (oldestKey !== undefined) vodStoryboardClientCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVodHoverHandlersBound(): void {
|
||||
const grid = document.getElementById('vodGrid');
|
||||
if (!grid || grid.dataset.hoverBound === '1') return;
|
||||
grid.dataset.hoverBound = '1';
|
||||
|
||||
// Delegated mouseover/mouseout on the grid — re-renders of the
|
||||
// grid replace the card DOM but the grid root persists, so the
|
||||
// listener stays bound across streamer switches.
|
||||
grid.addEventListener('mouseover', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const vodId = card.dataset.vodId;
|
||||
if (!vodId) return;
|
||||
scheduleHoverPreview(card, vodId);
|
||||
});
|
||||
grid.addEventListener('mouseout', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
// Only clear when leaving the card entirely (not just moving
|
||||
// within it between child elements).
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related && card.contains(related)) return;
|
||||
clearHoverPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
|
||||
if (pendingHoverVodId === vodId) return;
|
||||
pendingHoverVodId = vodId;
|
||||
// Debounce so rapid mouse passes (scrolling, dragging across cards)
|
||||
// don't trigger a download for every card brushed.
|
||||
window.setTimeout(() => {
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
void activateHoverPreview(card, vodId);
|
||||
}, HOVER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearHoverPreview(): void {
|
||||
pendingHoverVodId = null;
|
||||
if (!activeHover) return;
|
||||
window.clearInterval(activeHover.intervalId);
|
||||
const card = activeHover.overlay.parentElement;
|
||||
if (card) card.classList.remove('preview-active');
|
||||
// Brief opacity fade-out, then remove from DOM.
|
||||
activeHover.overlay.style.opacity = '0';
|
||||
const overlayToRemove = activeHover.overlay;
|
||||
window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220);
|
||||
activeHover = null;
|
||||
}
|
||||
|
||||
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
|
||||
// Stale-guard: user might have moved off the card in the debounce window.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
|
||||
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
|
||||
if (storyboard === undefined) {
|
||||
try {
|
||||
storyboard = await window.api.getVodStoryboard(vodId);
|
||||
} catch (_) {
|
||||
storyboard = null;
|
||||
}
|
||||
rememberStoryboard(vodId, storyboard);
|
||||
}
|
||||
|
||||
// Cursor may have moved on while we awaited; re-check guard.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
if (!storyboard) return;
|
||||
|
||||
clearHoverPreview();
|
||||
|
||||
// Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite —
|
||||
// distributes the chosen preview frames across the early/mid portion
|
||||
// of the VOD. For very short VODs the first sprite is the only one,
|
||||
// so this still gives a representative spread.
|
||||
const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows);
|
||||
const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE));
|
||||
const cellsToShow: Array<{ col: number; row: number }> = [];
|
||||
for (let i = 0; i < FRAMES_TO_CYCLE; i++) {
|
||||
const idx = Math.min(totalCells - 1, i * stride);
|
||||
const col = idx % storyboard.cols;
|
||||
const row = Math.floor(idx / storyboard.cols);
|
||||
cellsToShow.push({ col, row });
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'vod-storyboard-preview';
|
||||
// Scale the sprite so a single cell exactly fills the card width.
|
||||
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
|
||||
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
|
||||
const cardWidth = card.getBoundingClientRect().width;
|
||||
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
|
||||
const scale = cardWidth / storyboard.cellWidth;
|
||||
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
||||
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
|
||||
overlay.style.height = `${cardWidth / cellAspect}px`;
|
||||
// Initial position = first chosen cell.
|
||||
const first = cellsToShow[0];
|
||||
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
|
||||
|
||||
card.appendChild(overlay);
|
||||
// Trigger CSS transition to opacity:1 on the next frame.
|
||||
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
||||
|
||||
let frameIdx = 1;
|
||||
const intervalId = window.setInterval(() => {
|
||||
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
||||
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scale}px -${cell.row * storyboard.cellHeight * scale}px`;
|
||||
frameIdx++;
|
||||
}, FRAME_INTERVAL_MS);
|
||||
|
||||
activeHover = { vodId, intervalId, overlay };
|
||||
}
|
||||
|
||||
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
||||
|
||||
// Bind once the grid exists. Tab switches don't re-create the grid, so
|
||||
// one-time binding via DOMContentLoaded is enough.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); });
|
||||
} else {
|
||||
ensureVodHoverHandlersBound();
|
||||
}
|
||||
741
src/renderer.ts
741
src/renderer.ts
@ -5,22 +5,17 @@ const QUEUE_SYNC_HIDDEN_MS = 9000;
|
||||
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const [loadedConfig, initialQueue, isDown, version] = await Promise.all([
|
||||
window.api.getConfig(),
|
||||
window.api.getQueue(),
|
||||
window.api.isDownloading(),
|
||||
window.api.getVersion()
|
||||
]);
|
||||
config = loadedConfig;
|
||||
config = await window.api.getConfig();
|
||||
const language = setLanguage((config.language as string) || 'en');
|
||||
config.language = language;
|
||||
const initialQueue = await window.api.getQueue();
|
||||
queue = Array.isArray(initialQueue) ? initialQueue : [];
|
||||
downloading = isDown;
|
||||
downloading = await window.api.isDownloading();
|
||||
markQueueActivity();
|
||||
const version = await window.api.getVersion();
|
||||
|
||||
byId('versionText').textContent = `v${version}`;
|
||||
byId('versionInfo').textContent = `Version: v${version}`;
|
||||
appVersion = version;
|
||||
document.title = `${UI_TEXT.appName} v${version}`;
|
||||
|
||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||
@ -43,81 +38,11 @@ async function init(): Promise<void> {
|
||||
changeTheme(config.theme ?? 'twitch');
|
||||
renderStreamers();
|
||||
renderQueue();
|
||||
|
||||
// Keyboard activation for nav-items (Enter / Space). The items are
|
||||
// div[role="button"][tabindex="0"], so browsers won't synthesise a
|
||||
// click on Enter/Space natively — we wire it here once via event
|
||||
// delegation so the listener doesn't need re-binding per tab switch.
|
||||
const nav = document.querySelector('.nav');
|
||||
if (nav && !nav.hasAttribute('data-keynav-bound')) {
|
||||
nav.setAttribute('data-keynav-bound', '1');
|
||||
nav.addEventListener('keydown', (event) => {
|
||||
const ev = event as KeyboardEvent;
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
const target = ev.target as HTMLElement | null;
|
||||
const item = target?.closest('.nav-item') as HTMLElement | null;
|
||||
if (!item) return;
|
||||
const tab = item.dataset.tab;
|
||||
if (!tab) return;
|
||||
ev.preventDefault();
|
||||
showTab(tab);
|
||||
});
|
||||
}
|
||||
|
||||
// Kick off live-status subscription so the sidebar dots populate.
|
||||
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
|
||||
if (typeof liveStatusInit === 'function') void liveStatusInit();
|
||||
initQueueDragDrop();
|
||||
updateDownloadButtonState();
|
||||
updateStatusBarQueueSummary();
|
||||
|
||||
// Restore persisted VOD filter into the input — the filter itself only
|
||||
// takes effect once VODs load (renderVODs reads vodFilterQuery).
|
||||
vodFilterQuery = loadPersistedVodFilter();
|
||||
const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null;
|
||||
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
||||
syncVodFilterClearButton();
|
||||
|
||||
// Restore persisted VOD sort key. Apply localized labels to <option>s
|
||||
// before syncing the select value so the right option is preselected
|
||||
// even on first load before any language change fires.
|
||||
vodSortKey = loadPersistedVodSort();
|
||||
refreshVodSortSelectLabels();
|
||||
syncVodSortSelect();
|
||||
|
||||
// Restore "hide downloaded" toggle state.
|
||||
vodHideDownloaded = loadPersistedHideDownloaded();
|
||||
syncVodHideDownloadedToggle();
|
||||
|
||||
// Restore per-streamer VOD scroll positions from prior sessions.
|
||||
loadVodScrollPositions();
|
||||
initVodScrollTracking();
|
||||
initCutterDragDrop();
|
||||
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
showTab(loadPersistedActiveTab());
|
||||
|
||||
window.api.onQueueUpdated(async (q: QueueItem[]) => {
|
||||
const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id));
|
||||
const next = Array.isArray(q) ? q : [];
|
||||
const newlyCompletedItem = next.some((i) => i.status === 'completed' && !previouslyCompleted.has(i.id));
|
||||
queue = mergeQueueState(next);
|
||||
|
||||
// When an item flips to 'completed' the main process appends its
|
||||
// VOD ID to config.downloaded_vod_ids. Refresh our local config
|
||||
// copy so the "already downloaded" badge on the VOD grid updates
|
||||
// live without waiting for a settings save.
|
||||
if (newlyCompletedItem) {
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
} catch { /* network blip — next sync will refresh */ }
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
window.api.onQueueUpdated((q: QueueItem[]) => {
|
||||
queue = mergeQueueState(Array.isArray(q) ? q : []);
|
||||
renderQueue();
|
||||
updateStatusBarQueueSummary();
|
||||
markQueueActivity();
|
||||
});
|
||||
|
||||
@ -141,21 +66,10 @@ async function init(): Promise<void> {
|
||||
item.downloadedBytes = progress.downloadedBytes;
|
||||
item.totalBytes = progress.totalBytes;
|
||||
item.progressStatus = progress.status;
|
||||
if (progress.recordingHealth) {
|
||||
item.recordingHealth = progress.recordingHealth;
|
||||
}
|
||||
updateQueueItemProgress(progress);
|
||||
updateStatusBarQueueSummary();
|
||||
renderQueue();
|
||||
markQueueActivity();
|
||||
});
|
||||
|
||||
window.api.onAutoVodScanCompleted(({ queuedCount }) => {
|
||||
if (queuedCount > 0) {
|
||||
const tmpl = UI_TEXT.streamers.autoVodScanQueued || '{count} new VOD(s) auto-queued.';
|
||||
showAppToast(tmpl.replace('{count}', String(queuedCount)), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
window.api.onDownloadStarted(() => {
|
||||
downloading = true;
|
||||
updateDownloadButtonState();
|
||||
@ -169,26 +83,13 @@ async function init(): Promise<void> {
|
||||
});
|
||||
|
||||
window.api.onCutProgress((percent: number) => {
|
||||
const rounded = Math.round(percent);
|
||||
byId('cutProgressBar').style.width = percent + '%';
|
||||
byId('cutProgressText').textContent = rounded + '%';
|
||||
byId('cutProgressGauge').setAttribute('aria-valuenow', String(rounded));
|
||||
byId('cutProgressText').textContent = Math.round(percent) + '%';
|
||||
});
|
||||
|
||||
window.api.onMergeProgress((percent: number) => {
|
||||
const rounded = Math.round(percent);
|
||||
byId('mergeProgressBar').style.width = percent + '%';
|
||||
byId('mergeProgressText').textContent = rounded + '%';
|
||||
byId('mergeProgressGauge').setAttribute('aria-valuenow', String(rounded));
|
||||
});
|
||||
|
||||
// Update stats bar — paused while the window is hidden so we don't
|
||||
// burn IPC chatter on a tab nobody is looking at.
|
||||
void updateStatsBar();
|
||||
startStatsBarPolling();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stopStatsBarPolling();
|
||||
else startStatsBarPolling();
|
||||
byId('mergeProgressText').textContent = Math.round(percent) + '%';
|
||||
});
|
||||
|
||||
if (config.client_id && config.client_secret) {
|
||||
@ -214,445 +115,11 @@ async function init(): Promise<void> {
|
||||
scheduleQueueSync(document.hidden ? 600 : 150);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Esc closes any open modal — works regardless of focus, so users can dismiss
|
||||
// a modal that took focus from inside an input field
|
||||
if (e.key === 'Escape') {
|
||||
if (closeTopmostOpenModal()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// No modal open: if the VOD filter has focus or content, clear it.
|
||||
// Otherwise let Esc bubble (e.g. blur).
|
||||
if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') {
|
||||
if (vodFilterQuery) {
|
||||
clearVodFilter();
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
|
||||
// Browser's default Ctrl+F is suppressed because Electron's renderer
|
||||
// doesn't have a native find bar anyway. Route the shortcut to the
|
||||
// active tab's search/filter input so the user lands in a useful
|
||||
// place regardless of which tab they happen to be on.
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
||||
if (document.getElementById('vodsTab')?.classList.contains('active')) {
|
||||
e.preventDefault();
|
||||
focusVodFilter();
|
||||
return;
|
||||
}
|
||||
if (document.getElementById('archiveTab')?.classList.contains('active')) {
|
||||
const archiveInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (archiveInput) {
|
||||
e.preventDefault();
|
||||
archiveInput.focus();
|
||||
archiveInput.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip rest if user is typing in an input field
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
|
||||
|
||||
// Ctrl+1..7 jumps directly to a tab (Cmd on macOS via metaKey)
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '7') {
|
||||
const tabIndex = parseInt(e.key, 10) - 1;
|
||||
if (tabIndex >= 0 && tabIndex < TAB_IDS.length) {
|
||||
e.preventDefault();
|
||||
showTab(TAB_IDS[tabIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' && selectedQueueIds.length > 0) {
|
||||
// Delete selected queue items
|
||||
const idsToRemove = [...selectedQueueIds];
|
||||
selectedQueueIds = [];
|
||||
(async () => {
|
||||
for (const id of idsToRemove) {
|
||||
queue = await window.api.removeFromQueue(id);
|
||||
}
|
||||
renderQueue();
|
||||
})();
|
||||
}
|
||||
|
||||
if ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleDownload();
|
||||
}
|
||||
});
|
||||
|
||||
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
|
||||
}
|
||||
|
||||
function openTwitchDevConsole(): void {
|
||||
void window.api.openExternal('https://dev.twitch.tv/console/apps');
|
||||
}
|
||||
|
||||
interface EventLogEntry {
|
||||
t?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
game?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
streamer?: string;
|
||||
durationSeconds?: number;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
part?: number;
|
||||
}
|
||||
|
||||
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||
const modal = byId('eventsViewerModal');
|
||||
const list = byId('eventsViewerList');
|
||||
const status = byId('eventsViewerStatus');
|
||||
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
|
||||
list.replaceChildren();
|
||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||
modal.classList.add('show');
|
||||
|
||||
const result = await window.api.readChatFile(filePath);
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||
return;
|
||||
}
|
||||
const events = result.messages as EventLogEntry[];
|
||||
status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length));
|
||||
renderEventsList(events);
|
||||
}
|
||||
|
||||
function closeEventsViewer(): void {
|
||||
byId('eventsViewerModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function formatEventTime(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE');
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function renderEventsList(events: EventLogEntry[]): void {
|
||||
const list = byId('eventsViewerList');
|
||||
list.replaceChildren();
|
||||
if (events.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'event-viewer-empty';
|
||||
empty.textContent = UI_TEXT.queue.viewEventsEmpty;
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'event-viewer-row';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'event-viewer-time';
|
||||
time.textContent = formatEventTime(ev.t);
|
||||
row.appendChild(time);
|
||||
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'event-viewer-tag';
|
||||
// Per-type tag colour comes from CSS via a data-type attribute
|
||||
// selector — keeps the type->colour mapping with the rest of the
|
||||
// visual styling instead of inline in the renderer.
|
||||
if (ev.type) tag.dataset.type = ev.type;
|
||||
tag.textContent = ev.type || 'event';
|
||||
row.appendChild(tag);
|
||||
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'event-viewer-detail';
|
||||
|
||||
if (ev.type === 'recording_start') {
|
||||
detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
|
||||
} else if (ev.type === 'recording_end') {
|
||||
const dur = typeof ev.durationSeconds === 'number'
|
||||
? `${Math.floor(ev.durationSeconds / 3600)}h ${Math.floor((ev.durationSeconds % 3600) / 60)}m ${ev.durationSeconds % 60}s`
|
||||
: '?';
|
||||
const ok = ev.success ? '✓' : '✗';
|
||||
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`;
|
||||
} else if (ev.type === 'recording_resume') {
|
||||
detail.textContent = (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(ev.part || '?'));
|
||||
} else if (ev.type === 'title_change') {
|
||||
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
|
||||
} else if (ev.type === 'game_change') {
|
||||
detail.textContent = `${UI_TEXT.queue.eventGameFromTo.replace('{from}', ev.from || '-').replace('{to}', ev.to || '-')}`;
|
||||
} else {
|
||||
detail.textContent = JSON.stringify(ev);
|
||||
}
|
||||
row.appendChild(detail);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatViewerMessage {
|
||||
t?: string;
|
||||
type?: string;
|
||||
u?: string;
|
||||
user?: string;
|
||||
login?: string;
|
||||
color?: string;
|
||||
msg?: string;
|
||||
text?: string;
|
||||
offset?: number;
|
||||
badges?: string;
|
||||
bits?: string;
|
||||
msgId?: string;
|
||||
systemMsg?: string;
|
||||
}
|
||||
|
||||
let chatViewerMessages: ChatViewerMessage[] = [];
|
||||
let chatViewerFormat: 'replay' | 'live' = 'replay';
|
||||
|
||||
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||
const modal = byId('chatViewerModal');
|
||||
const list = byId('chatViewerList');
|
||||
const status = byId('chatViewerStatus');
|
||||
const filterInput = byId<HTMLInputElement>('chatViewerFilter');
|
||||
byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat;
|
||||
list.replaceChildren();
|
||||
filterInput.value = '';
|
||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||
modal.classList.add('show');
|
||||
|
||||
const result = await window.api.readChatFile(filePath);
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||
return;
|
||||
}
|
||||
|
||||
chatViewerMessages = result.messages as ChatViewerMessage[];
|
||||
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
|
||||
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
|
||||
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
|
||||
renderChatViewerList(chatViewerMessages);
|
||||
}
|
||||
|
||||
function closeChatViewer(): void {
|
||||
byId('chatViewerModal').classList.remove('show');
|
||||
chatViewerMessages = [];
|
||||
}
|
||||
|
||||
function onChatViewerFilterChange(): void {
|
||||
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
|
||||
if (!filter) {
|
||||
renderChatViewerList(chatViewerMessages);
|
||||
return;
|
||||
}
|
||||
const filtered = chatViewerMessages.filter((m) => {
|
||||
const u = (m.u || m.user || m.login || '').toLowerCase();
|
||||
const text = (m.msg || m.text || '').toLowerCase();
|
||||
return u.includes(filter) || text.includes(filter);
|
||||
});
|
||||
renderChatViewerList(filtered);
|
||||
}
|
||||
|
||||
function formatChatTimeMarker(m: ChatViewerMessage): string {
|
||||
if (chatViewerFormat === 'replay' && typeof m.offset === 'number') {
|
||||
const total = Math.max(0, Math.floor(m.offset));
|
||||
const h = Math.floor(total / 3600);
|
||||
const min = Math.floor((total % 3600) / 60);
|
||||
const sec = total % 60;
|
||||
return `${h.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
if (m.t) {
|
||||
try {
|
||||
const d = new Date(m.t);
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const min = d.getMinutes().toString().padStart(2, '0');
|
||||
const sec = d.getSeconds().toString().padStart(2, '0');
|
||||
return `${h}:${min}:${sec}`;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
||||
const list = byId('chatViewerList');
|
||||
list.replaceChildren();
|
||||
// Render in chunks to keep main thread responsive on big files.
|
||||
const CHUNK = 500;
|
||||
let idx = 0;
|
||||
const renderChunk = (): void => {
|
||||
if (idx >= messages.length) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const end = Math.min(idx + CHUNK, messages.length);
|
||||
for (let i = idx; i < end; i++) {
|
||||
const m = messages[i];
|
||||
const isMessageType = m.type === 'msg' || !m.type;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
||||
|
||||
// System events (subs, raids, deletions) lead with a faint tag.
|
||||
if (!isMessageType) {
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'chat-viewer-tag';
|
||||
tag.textContent = m.type || 'event';
|
||||
row.appendChild(tag);
|
||||
}
|
||||
|
||||
const time = formatChatTimeMarker(m);
|
||||
if (time) {
|
||||
const tSpan = document.createElement('span');
|
||||
tSpan.className = 'chat-viewer-time';
|
||||
tSpan.textContent = time;
|
||||
row.appendChild(tSpan);
|
||||
}
|
||||
|
||||
const user = m.u || m.user || m.login || '';
|
||||
if (user) {
|
||||
const uSpan = document.createElement('span');
|
||||
uSpan.className = 'chat-viewer-user';
|
||||
// Per-user IRC color overrides the default accent colour
|
||||
// supplied by .chat-viewer-user; the class also sets weight.
|
||||
if (m.color) uSpan.style.color = m.color;
|
||||
uSpan.textContent = `${user}:`;
|
||||
row.appendChild(uSpan);
|
||||
}
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = ' ' + (m.msg || m.text || '');
|
||||
row.appendChild(msgSpan);
|
||||
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
list.appendChild(fragment);
|
||||
idx = end;
|
||||
if (idx < messages.length) {
|
||||
window.setTimeout(renderChunk, 0);
|
||||
}
|
||||
};
|
||||
renderChunk();
|
||||
}
|
||||
|
||||
function closeTopmostOpenModal(): boolean {
|
||||
// Try each known modal in priority order
|
||||
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
||||
if (eventsViewerModal?.classList.contains('show')) {
|
||||
closeEventsViewer();
|
||||
return true;
|
||||
}
|
||||
|
||||
const chatViewerModal = document.getElementById('chatViewerModal');
|
||||
if (chatViewerModal?.classList.contains('show')) {
|
||||
closeChatViewer();
|
||||
return true;
|
||||
}
|
||||
|
||||
const clipModal = document.getElementById('clipModal');
|
||||
if (clipModal?.classList.contains('show')) {
|
||||
closeClipDialog();
|
||||
return true;
|
||||
}
|
||||
|
||||
const templateGuideModal = document.getElementById('templateGuideModal');
|
||||
if (templateGuideModal?.classList.contains('show')) {
|
||||
closeTemplateGuide();
|
||||
return true;
|
||||
}
|
||||
|
||||
const updateModal = document.getElementById('updateModal');
|
||||
if (updateModal?.classList.contains('show')) {
|
||||
dismissUpdateModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatBytesRenderer(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatSpeedRenderer(bytesPerSec: number): string {
|
||||
if (bytesPerSec < 1024) return `${bytesPerSec.toFixed(0)} B/s`;
|
||||
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function updateStatusBarQueueSummary(): void {
|
||||
const node = document.getElementById('statusBarQueueSummary');
|
||||
if (!node) return;
|
||||
if (!Array.isArray(queue) || queue.length === 0) {
|
||||
node.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let downloading = 0;
|
||||
let pending = 0;
|
||||
for (const item of queue) {
|
||||
if (item.status === 'downloading') downloading++;
|
||||
else if (item.status === 'pending') pending++;
|
||||
}
|
||||
|
||||
if (downloading === 0 && pending === 0) {
|
||||
node.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
node.textContent = UI_TEXT.queue.statusBarSummary
|
||||
.replace('{downloading}', String(downloading))
|
||||
.replace('{pending}', String(pending));
|
||||
}
|
||||
|
||||
let statsBarPollTimer: number | null = null;
|
||||
|
||||
function startStatsBarPolling(): void {
|
||||
stopStatsBarPolling();
|
||||
if (document.hidden) return;
|
||||
statsBarPollTimer = window.setInterval(updateStatsBar, 5000);
|
||||
}
|
||||
|
||||
function stopStatsBarPolling(): void {
|
||||
if (statsBarPollTimer !== null) {
|
||||
window.clearInterval(statsBarPollTimer);
|
||||
statsBarPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatsBar(): Promise<void> {
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
const bar = byId('statsBar');
|
||||
if (!bar) return;
|
||||
const totalDL = formatBytesRenderer(metrics.downloadedBytesTotal);
|
||||
const avgSpeed = metrics.avgSpeedBytesPerSec > 0 ? formatSpeedRenderer(metrics.avgSpeedBytesPerSec) : '-';
|
||||
bar.textContent = `${totalDL} | ${avgSpeed} avg | ${metrics.downloadsCompleted} done | ${metrics.downloadsFailed} failed`;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
let toastHideTimer: number | null = null;
|
||||
let queueSyncTimer: number | null = null;
|
||||
let appVersion = '';
|
||||
|
||||
// Single source of truth for what the user is looking at — keeps the
|
||||
// visible H1, the document title (which drives the OS task bar / Alt+Tab
|
||||
// label), and the app version pill in sync. Previously document.title was
|
||||
// stamped once at boot, so the OS task bar always read "Twitch VOD
|
||||
// Manager v4.6.76" no matter what tab or streamer was active.
|
||||
(window as unknown as { setPageTitle: (text: string) => void }).setPageTitle = setPageTitle;
|
||||
|
||||
function setPageTitle(text: string): void {
|
||||
const titleEl = document.getElementById('pageTitle');
|
||||
if (titleEl) titleEl.textContent = text;
|
||||
const appName = UI_TEXT.appName;
|
||||
const versionSuffix = appVersion ? ` v${appVersion}` : '';
|
||||
document.title = text && text !== appName
|
||||
? `${text} - ${appName}${versionSuffix}`
|
||||
: `${appName}${versionSuffix}`;
|
||||
}
|
||||
let queueSyncInFlight = false;
|
||||
let lastQueueActivityAt = Date.now();
|
||||
|
||||
@ -716,28 +183,14 @@ function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'appToast';
|
||||
toast.className = 'app-toast';
|
||||
// Live region — screen readers announce the toast text whenever
|
||||
// it changes. Warn toasts go through aria-live="assertive" so the
|
||||
// reader interrupts whatever it was speaking; info toasts use
|
||||
// "polite" so they wait for a natural break in current speech.
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
toast.classList.remove('warn', 'show');
|
||||
if (type === 'warn') {
|
||||
toast.classList.add('warn');
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
} else {
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
}
|
||||
// Setting textContent AFTER the aria-live attribute is in place
|
||||
// ensures the change is captured as a live-region update by AT.
|
||||
toast.textContent = message;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast?.classList.add('show');
|
||||
@ -766,14 +219,9 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Keep the higher progress value to prevent backward jumps from stale data
|
||||
const bestProgress = (prev.status === 'downloading' && prev.progress > item.progress)
|
||||
? prev.progress
|
||||
: (item.progress > 0 ? item.progress : prev.progress);
|
||||
|
||||
return {
|
||||
...item,
|
||||
progress: bestProgress,
|
||||
progress: item.progress > 0 ? item.progress : prev.progress,
|
||||
speed: item.speed || prev.speed,
|
||||
eta: item.eta || prev.eta,
|
||||
currentPart: item.currentPart || prev.currentPart,
|
||||
@ -821,67 +269,16 @@ async function syncQueueAndDownloadState(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Must include every nav-item from index.html — otherwise:
|
||||
// - Ctrl+N keyboard shortcut won't reach tabs past index 4
|
||||
// - persistActiveTab silently no-ops, so the tab won't restore on reboot
|
||||
// 'stats' (4.6.14) and 'archive' (4.6.15) were added to the nav but the
|
||||
// const was never updated, leaving them effectively second-class tabs.
|
||||
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings'] as const;
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'twitch-vod-manager:active-tab';
|
||||
|
||||
function isKnownTab(value: string): value is typeof TAB_IDS[number] {
|
||||
return (TAB_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function loadPersistedActiveTab(): string {
|
||||
const stored = safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY);
|
||||
if (stored && isKnownTab(stored)) return stored;
|
||||
return 'vods';
|
||||
}
|
||||
|
||||
function persistActiveTab(tab: string): void {
|
||||
if (!isKnownTab(tab)) return;
|
||||
safeLocalStorageSet(ACTIVE_TAB_STORAGE_KEY, tab);
|
||||
}
|
||||
|
||||
function showTab(tab: string): void {
|
||||
queryAll('.nav-item').forEach((i) => {
|
||||
i.classList.remove('active');
|
||||
i.removeAttribute('aria-current');
|
||||
});
|
||||
queryAll('.nav-item').forEach((i) => i.classList.remove('active'));
|
||||
queryAll('.tab-content').forEach((c) => c.classList.remove('active'));
|
||||
|
||||
const navItem = query(`.nav-item[data-tab="${tab}"]`);
|
||||
if (!navItem) {
|
||||
// Unknown tab — fall back to vods so the user is never stuck on an empty screen
|
||||
showTab('vods');
|
||||
return;
|
||||
}
|
||||
navItem.classList.add('active');
|
||||
navItem.setAttribute('aria-current', 'page');
|
||||
query(`.nav-item[data-tab="${tab}"]`).classList.add('active');
|
||||
byId(tab + 'Tab').classList.add('active');
|
||||
|
||||
const titles: Record<string, string> = UI_TEXT.tabs;
|
||||
|
||||
// Only show the streamer name on the VODs tab — otherwise the title would
|
||||
// mismatch the tab content (e.g. "streamer X" while on Settings)
|
||||
const pageTitleText = (tab === 'vods' && currentStreamer)
|
||||
? currentStreamer
|
||||
: (titles[tab] || UI_TEXT.appName);
|
||||
setPageTitle(pageTitleText);
|
||||
|
||||
persistActiveTab(tab);
|
||||
|
||||
if (tab === 'stats') {
|
||||
const fn = (window as unknown as { refreshArchiveStats?: () => Promise<void> }).refreshArchiveStats;
|
||||
if (typeof fn === 'function') void fn();
|
||||
}
|
||||
if (tab === 'archive') {
|
||||
const init = (window as unknown as { initArchiveSearchInput?: () => void }).initArchiveSearchInput;
|
||||
const search = (window as unknown as { performArchiveSearch?: () => Promise<void> }).performArchiveSearch;
|
||||
if (typeof init === 'function') init();
|
||||
if (typeof search === 'function') void search();
|
||||
}
|
||||
byId('pageTitle').textContent = currentStreamer || titles[tab] || UI_TEXT.appName;
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(durStr: string): number {
|
||||
@ -964,18 +361,15 @@ function formatSecondsWithPattern(totalSeconds: number, pattern: string): string
|
||||
.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'parts' {
|
||||
function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' {
|
||||
const selected = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value;
|
||||
if (selected === 'template') return 'template';
|
||||
if (selected === 'timestamp') return 'timestamp';
|
||||
if (selected === 'parts') return 'parts';
|
||||
return 'simple';
|
||||
return selected === 'template' ? 'template' : selected === 'timestamp' ? 'timestamp' : 'simple';
|
||||
}
|
||||
|
||||
function updateFilenameTemplateVisibility(): void {
|
||||
const selected = getSelectedFilenameFormat();
|
||||
const wrap = byId('clipFilenameTemplateWrap');
|
||||
wrap.classList.toggle('shown', selected === 'template');
|
||||
wrap.style.display = selected === 'template' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
interface TemplatePreviewContext {
|
||||
@ -1288,11 +682,13 @@ function updateClipDuration(): void {
|
||||
const duration = endSec - startSec;
|
||||
const durationDisplay = byId('clipDurationDisplay');
|
||||
|
||||
const isValid = duration > 0;
|
||||
durationDisplay.classList.toggle('invalid', !isValid);
|
||||
durationDisplay.textContent = isValid
|
||||
? formatSecondsToTime(duration)
|
||||
: UI_TEXT.clips.invalidDuration;
|
||||
if (duration > 0) {
|
||||
durationDisplay.textContent = formatSecondsToTime(duration);
|
||||
durationDisplay.style.color = '#00c853';
|
||||
} else {
|
||||
durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
|
||||
durationDisplay.style.color = '#ff4444';
|
||||
}
|
||||
|
||||
updateFilenameExamples();
|
||||
}
|
||||
@ -1316,16 +712,15 @@ function updateFilenameExamples(): void {
|
||||
updateFilenameTemplateVisibility();
|
||||
|
||||
if (!unknownTokens.length) {
|
||||
clipLint.className = 'template-lint ok';
|
||||
clipLint.style.color = '#8bc34a';
|
||||
clipLint.textContent = UI_TEXT.static.templateLintOk;
|
||||
} else {
|
||||
clipLint.className = 'template-lint warn';
|
||||
clipLint.style.color = '#ff8a80';
|
||||
clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`;
|
||||
}
|
||||
|
||||
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
|
||||
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
|
||||
byId('formatParts').textContent = `${dateStr}_Part${partNum.padStart(2, '0')}.mp4 ${UI_TEXT.clips.formatParts}`;
|
||||
byId('formatTemplate').textContent = `${buildTemplatePreview(template, {
|
||||
title: clipDialogData.title,
|
||||
date,
|
||||
@ -1349,28 +744,17 @@ async function confirmClipDialog(): Promise<void> {
|
||||
|
||||
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
||||
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
||||
const durationSec = endSec - startSec;
|
||||
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
|
||||
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
|
||||
const filenameFormat = getSelectedFilenameFormat();
|
||||
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim();
|
||||
|
||||
if (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) {
|
||||
alert(UI_TEXT.clips.invalidTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (startSec < 0) {
|
||||
alert(UI_TEXT.clips.outOfRange);
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationSec <= 0) {
|
||||
if (endSec <= startSec) {
|
||||
alert(UI_TEXT.clips.endBeforeStart);
|
||||
return;
|
||||
}
|
||||
|
||||
if (endSec > clipTotalSeconds) {
|
||||
if (startSec < 0 || endSec > clipTotalSeconds) {
|
||||
alert(UI_TEXT.clips.outOfRange);
|
||||
return;
|
||||
}
|
||||
@ -1388,6 +772,7 @@ async function confirmClipDialog(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const durationSec = endSec - startSec;
|
||||
const customClip: CustomClip = {
|
||||
startSec,
|
||||
durationSec,
|
||||
@ -1446,15 +831,28 @@ async function downloadClip(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend now produces locale-aware error strings via tBackend(),
|
||||
// so we no longer need a renderer-side translation table here.
|
||||
const backendError = (result.error || '').trim();
|
||||
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
|
||||
let localizedError = backendError;
|
||||
|
||||
if (backendError === 'Ungueltige Clip-URL') {
|
||||
localizedError = currentLanguage === 'en' ? 'Invalid clip URL' : backendError;
|
||||
} else if (backendError === 'Clip nicht gefunden') {
|
||||
localizedError = currentLanguage === 'en' ? 'Clip not found' : backendError;
|
||||
} else if (backendError === 'Streamlink nicht gefunden') {
|
||||
localizedError = currentLanguage === 'en' ? 'Streamlink not found' : backendError;
|
||||
} else if (backendError.startsWith('Download fehlgeschlagen')) {
|
||||
localizedError = currentLanguage === 'en' ? backendError.replace('Download fehlgeschlagen', 'Download failed') : backendError;
|
||||
}
|
||||
|
||||
status.textContent = UI_TEXT.clips.errorPrefix + (localizedError || UI_TEXT.clips.unknownError);
|
||||
status.className = 'clip-status error';
|
||||
}
|
||||
|
||||
async function loadCutterFromPath(filePath: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
const filePath = await window.api.selectVideoFile();
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
cutterFile = filePath;
|
||||
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
||||
@ -1469,8 +867,8 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
|
||||
cutterStartTime = 0;
|
||||
cutterEndTime = info.duration;
|
||||
|
||||
byId('cutterInfo').classList.add('shown');
|
||||
byId('timelineContainer').classList.add('shown');
|
||||
byId('cutterInfo').style.display = 'flex';
|
||||
byId('timelineContainer').style.display = 'block';
|
||||
byId('btnCut').disabled = false;
|
||||
|
||||
byId('infoDuration').textContent = formatTime(info.duration);
|
||||
@ -1485,12 +883,6 @@ async function loadCutterFromPath(filePath: string): Promise<void> {
|
||||
await updatePreview(0);
|
||||
}
|
||||
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
const filePath = await window.api.selectVideoFile();
|
||||
if (!filePath) return;
|
||||
await loadCutterFromPath(filePath);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
@ -1557,15 +949,15 @@ async function updatePreview(time: number): Promise<void> {
|
||||
}
|
||||
|
||||
const preview = byId('cutterPreview');
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
|
||||
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
|
||||
|
||||
const frame = await window.api.extractFrame(cutterFile, time);
|
||||
if (frame) {
|
||||
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
|
||||
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
|
||||
return;
|
||||
}
|
||||
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
|
||||
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
|
||||
}
|
||||
|
||||
async function startCutting(): Promise<void> {
|
||||
@ -1608,23 +1000,12 @@ function renderMergeFiles(): void {
|
||||
byId('btnMerge').disabled = mergeFiles.length < 2;
|
||||
|
||||
if (mergeFiles.length === 0) {
|
||||
// Build via DOM API to keep the renderer clean of inline-styled
|
||||
// HTML strings. The empty-state SVG is the same plus-icon the
|
||||
// static HTML uses, just built programmatically.
|
||||
list.replaceChildren();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'empty-state merge-empty-state';
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'currentColor');
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z');
|
||||
svg.appendChild(path);
|
||||
wrap.appendChild(svg);
|
||||
const p = document.createElement('p');
|
||||
p.textContent = UI_TEXT.merge.empty;
|
||||
wrap.appendChild(p);
|
||||
list.appendChild(wrap);
|
||||
list.innerHTML = `
|
||||
<div class="empty-state" style="padding: 40px 20px;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1635,9 +1016,9 @@ function renderMergeFiles(): void {
|
||||
<div class="file-order">${index + 1}</div>
|
||||
<div class="file-name" title="${file}">${name}</div>
|
||||
<div class="file-actions">
|
||||
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button type="button" class="file-btn remove" aria-label="${escapeHtml(UI_TEXT.merge.removeAria)}" title="${escapeHtml(UI_TEXT.merge.removeAria)}" onclick="removeMergeFile(${index})">x</button>
|
||||
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
3212
src/styles.css
3212
src/styles.css
File diff suppressed because it is too large
Load Diff
494
src/tools.ts
494
src/tools.ts
@ -1,494 +0,0 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { spawn, execSync, spawnSync } from 'child_process';
|
||||
import axios from 'axios';
|
||||
|
||||
// ==========================================
|
||||
// CONSTANTS
|
||||
// ==========================================
|
||||
const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000;
|
||||
|
||||
// ==========================================
|
||||
// DEBUG LOG CALLBACK
|
||||
// ==========================================
|
||||
let _appendDebugLog: (message: string, details?: unknown) => void = () => {};
|
||||
|
||||
export function setDebugLogFn(fn: (message: string, details?: unknown) => void): void {
|
||||
_appendDebugLog = fn;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TOOL DIRECTORIES (set once from main)
|
||||
// ==========================================
|
||||
let TOOLS_STREAMLINK_DIR = '';
|
||||
let TOOLS_FFMPEG_DIR = '';
|
||||
let _getTempPath: () => string = () => '';
|
||||
|
||||
export function initToolDirs(streamlinkDir: string, ffmpegDir: string, getTempPath: () => string): void {
|
||||
TOOLS_STREAMLINK_DIR = streamlinkDir;
|
||||
TOOLS_FFMPEG_DIR = ffmpegDir;
|
||||
_getTempPath = getTempPath;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CACHE STATE
|
||||
// ==========================================
|
||||
let streamlinkPathCache: string | null = null;
|
||||
let streamlinkCommandCache: { command: string; prefixArgs: string[] } | null = null;
|
||||
let ffmpegPathCache: string | null = null;
|
||||
let ffprobePathCache: string | null = null;
|
||||
let bundledStreamlinkPath: string | null = null;
|
||||
let bundledFFmpegPath: string | null = null;
|
||||
let bundledFFprobePath: string | null = null;
|
||||
let verifiedStreamlinkCommandKey: string | null = null;
|
||||
let verifiedFfmpegCommandKey: string | null = null;
|
||||
let bundledToolPathSignature = '';
|
||||
let bundledToolPathRefreshedAt = 0;
|
||||
|
||||
// ==========================================
|
||||
// INTERNAL HELPERS
|
||||
// ==========================================
|
||||
function findFileRecursive(rootDir: string, fileName: string): string | null {
|
||||
if (!fs.existsSync(rootDir)) return null;
|
||||
|
||||
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(fullPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDirectoryMtimeMs(directoryPath: string): number {
|
||||
try {
|
||||
return fs.statSync(directoryPath).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandCacheKey(command: string, args: string[]): string {
|
||||
return [command, ...args].join('\u0000');
|
||||
}
|
||||
|
||||
export function canExecute(cmd: string): boolean {
|
||||
try {
|
||||
execSync(cmd, { stdio: 'ignore', windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canExecuteCommand(command: string, args: string[]): boolean {
|
||||
try {
|
||||
const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
|
||||
return result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// VERIFIED COMMAND CACHES
|
||||
// ==========================================
|
||||
export function cacheVerifiedStreamlinkCommand(command: string, args: string[]): void {
|
||||
verifiedStreamlinkCommandKey = getCommandCacheKey(command, args);
|
||||
}
|
||||
|
||||
export function isVerifiedStreamlinkCommand(command: string, args: string[]): boolean {
|
||||
return verifiedStreamlinkCommandKey === getCommandCacheKey(command, args);
|
||||
}
|
||||
|
||||
export function cacheVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): void {
|
||||
verifiedFfmpegCommandKey = getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
|
||||
}
|
||||
|
||||
export function isVerifiedFfmpegCommands(ffmpegCommand: string, ffprobeCommand: string): boolean {
|
||||
return verifiedFfmpegCommandKey === getCommandCacheKey(ffmpegCommand, [ffprobeCommand]);
|
||||
}
|
||||
|
||||
export function invalidateVerifiedToolCaches(): void {
|
||||
verifiedStreamlinkCommandKey = null;
|
||||
verifiedFfmpegCommandKey = null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// TOOL PATH DISCOVERY
|
||||
// ==========================================
|
||||
export function getStreamlinkPath(): string {
|
||||
if (streamlinkPathCache) {
|
||||
if (streamlinkPathCache === 'streamlink' || fs.existsSync(streamlinkPathCache)) {
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
streamlinkPathCache = null;
|
||||
}
|
||||
|
||||
if (bundledStreamlinkPath && fs.existsSync(bundledStreamlinkPath)) {
|
||||
streamlinkPathCache = bundledStreamlinkPath;
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const result = execSync('where streamlink', { encoding: 'utf-8' });
|
||||
const paths = result.trim().split('\n');
|
||||
if (paths.length > 0) {
|
||||
streamlinkPathCache = paths[0].trim();
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
} else {
|
||||
const result = execSync('which streamlink', { encoding: 'utf-8' });
|
||||
streamlinkPathCache = result.trim();
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const commonPaths = [
|
||||
'C:\\Program Files\\Streamlink\\bin\\streamlink.exe',
|
||||
'C:\\Program Files (x86)\\Streamlink\\bin\\streamlink.exe',
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe')
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
streamlinkPathCache = p;
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
}
|
||||
|
||||
streamlinkPathCache = 'streamlink';
|
||||
return streamlinkPathCache;
|
||||
}
|
||||
|
||||
export function getStreamlinkCommand(): { command: string; prefixArgs: string[] } {
|
||||
if (streamlinkCommandCache) {
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
const directPath = getStreamlinkPath();
|
||||
if (directPath !== 'streamlink' || canExecute('streamlink --version')) {
|
||||
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (canExecute('py -3 -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'py', prefixArgs: ['-3', '-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
if (canExecute('python -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
} else {
|
||||
if (canExecute('python3 -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'python3', prefixArgs: ['-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
if (canExecute('python -m streamlink --version')) {
|
||||
streamlinkCommandCache = { command: 'python', prefixArgs: ['-m', 'streamlink'] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
}
|
||||
|
||||
streamlinkCommandCache = { command: directPath, prefixArgs: [] };
|
||||
return streamlinkCommandCache;
|
||||
}
|
||||
|
||||
export function getFFmpegPath(): string {
|
||||
if (ffmpegPathCache) {
|
||||
if (ffmpegPathCache === 'ffmpeg' || fs.existsSync(ffmpegPathCache)) {
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
ffmpegPathCache = null;
|
||||
}
|
||||
|
||||
if (bundledFFmpegPath && fs.existsSync(bundledFFmpegPath)) {
|
||||
ffmpegPathCache = bundledFFmpegPath;
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const result = execSync('where ffmpeg', { encoding: 'utf-8' });
|
||||
const paths = result.trim().split('\n');
|
||||
if (paths.length > 0) {
|
||||
ffmpegPathCache = paths[0].trim();
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
} else {
|
||||
const result = execSync('which ffmpeg', { encoding: 'utf-8' });
|
||||
ffmpegPathCache = result.trim();
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
} catch { }
|
||||
|
||||
const commonPaths = [
|
||||
'C:\\ffmpeg\\bin\\ffmpeg.exe',
|
||||
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
|
||||
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'ffmpeg', 'bin', 'ffmpeg.exe')
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
ffmpegPathCache = p;
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
}
|
||||
|
||||
ffmpegPathCache = 'ffmpeg';
|
||||
return ffmpegPathCache;
|
||||
}
|
||||
|
||||
export function getFFprobePath(): string {
|
||||
if (ffprobePathCache) {
|
||||
if (ffprobePathCache === 'ffprobe' || ffprobePathCache === 'ffprobe.exe' || fs.existsSync(ffprobePathCache)) {
|
||||
return ffprobePathCache;
|
||||
}
|
||||
ffprobePathCache = null;
|
||||
}
|
||||
|
||||
if (bundledFFprobePath && fs.existsSync(bundledFFprobePath)) {
|
||||
ffprobePathCache = bundledFFprobePath;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
const ffmpegPath = getFFmpegPath();
|
||||
const ffprobeExe = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe';
|
||||
|
||||
if (ffmpegPath === 'ffmpeg') {
|
||||
ffprobePathCache = ffprobeExe;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
const derivedFfprobePath = path.join(path.dirname(ffmpegPath), ffprobeExe);
|
||||
if (fs.existsSync(derivedFfprobePath)) {
|
||||
ffprobePathCache = derivedFfprobePath;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
ffprobePathCache = ffprobeExe;
|
||||
return ffprobePathCache;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BUNDLED TOOL PATH REFRESH
|
||||
// ==========================================
|
||||
export function refreshBundledToolPaths(force = false): void {
|
||||
const now = Date.now();
|
||||
const signature = `${getDirectoryMtimeMs(TOOLS_STREAMLINK_DIR)}|${getDirectoryMtimeMs(TOOLS_FFMPEG_DIR)}`;
|
||||
|
||||
if (!force && signature === bundledToolPathSignature && (now - bundledToolPathRefreshedAt) < TOOL_PATH_REFRESH_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
bundledToolPathSignature = signature;
|
||||
bundledToolPathRefreshedAt = now;
|
||||
|
||||
const nextBundledStreamlinkPath = findFileRecursive(TOOLS_STREAMLINK_DIR, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink');
|
||||
const nextBundledFFmpegPath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
|
||||
const nextBundledFFprobePath = findFileRecursive(TOOLS_FFMPEG_DIR, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
|
||||
|
||||
const changed =
|
||||
nextBundledStreamlinkPath !== bundledStreamlinkPath ||
|
||||
nextBundledFFmpegPath !== bundledFFmpegPath ||
|
||||
nextBundledFFprobePath !== bundledFFprobePath;
|
||||
|
||||
bundledStreamlinkPath = nextBundledStreamlinkPath;
|
||||
bundledFFmpegPath = nextBundledFFmpegPath;
|
||||
bundledFFprobePath = nextBundledFFprobePath;
|
||||
|
||||
if (changed) {
|
||||
streamlinkPathCache = null;
|
||||
ffmpegPathCache = null;
|
||||
ffprobePathCache = null;
|
||||
streamlinkCommandCache = null;
|
||||
invalidateVerifiedToolCaches();
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DOWNLOAD & EXTRACT HELPERS
|
||||
// ==========================================
|
||||
async function downloadFile(url: string, destinationPath: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: 'stream', timeout: 120000 });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writer = fs.createWriteStream(destinationPath);
|
||||
response.data.pipe(writer);
|
||||
writer.on('finish', () => resolve());
|
||||
writer.on('error', (err) => reject(err));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_appendDebugLog('download-file-failed', { url, destinationPath, error: String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZip(zipPath: string, destinationDir: string): Promise<boolean> {
|
||||
try {
|
||||
fs.mkdirSync(destinationDir, { recursive: true });
|
||||
|
||||
const command = `Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destinationDir.replace(/'/g, "''")}' -Force`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('powershell', [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-Command',
|
||||
command
|
||||
], { windowsHide: true });
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Expand-Archive exit code ${code}: ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => reject(err));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_appendDebugLog('extract-zip-failed', { zipPath, destinationDir, error: String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// AUTO-INSTALL TOOLS
|
||||
// ==========================================
|
||||
export async function ensureStreamlinkInstalled(): Promise<boolean> {
|
||||
refreshBundledToolPaths();
|
||||
|
||||
const current = getStreamlinkCommand();
|
||||
const versionArgs = [...current.prefixArgs, '--version'];
|
||||
if (isVerifiedStreamlinkCommand(current.command, versionArgs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canExecuteCommand(current.command, versionArgs)) {
|
||||
cacheVerifiedStreamlinkCommand(current.command, versionArgs);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
_appendDebugLog('streamlink-install-start');
|
||||
try {
|
||||
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
|
||||
|
||||
const release = await axios.get('https://api.github.com/repos/streamlink/windows-builds/releases/latest', {
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'Twitch-VOD-Manager'
|
||||
}
|
||||
});
|
||||
|
||||
const assets = release.data?.assets || [];
|
||||
const zipAsset = assets.find((a: any) => typeof a?.name === 'string' && /x86_64\.zip$/i.test(a.name));
|
||||
if (!zipAsset?.browser_download_url) {
|
||||
_appendDebugLog('streamlink-install-no-asset-found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const zipPath = path.join(_getTempPath(), `streamlink_portable_${Date.now()}.zip`);
|
||||
const downloadOk = await downloadFile(zipAsset.browser_download_url, zipPath);
|
||||
if (!downloadOk) return false;
|
||||
|
||||
fs.rmSync(TOOLS_STREAMLINK_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TOOLS_STREAMLINK_DIR, { recursive: true });
|
||||
|
||||
const extractOk = await extractZip(zipPath, TOOLS_STREAMLINK_DIR);
|
||||
try { fs.unlinkSync(zipPath); } catch { }
|
||||
if (!extractOk) return false;
|
||||
|
||||
refreshBundledToolPaths(true);
|
||||
streamlinkCommandCache = null;
|
||||
|
||||
const cmd = getStreamlinkCommand();
|
||||
const installedVersionArgs = [...cmd.prefixArgs, '--version'];
|
||||
const works = canExecuteCommand(cmd.command, installedVersionArgs);
|
||||
if (works) {
|
||||
cacheVerifiedStreamlinkCommand(cmd.command, installedVersionArgs);
|
||||
}
|
||||
_appendDebugLog('streamlink-install-finished', { works, command: cmd.command, prefixArgs: cmd.prefixArgs });
|
||||
return works;
|
||||
} catch (e) {
|
||||
_appendDebugLog('streamlink-install-failed', String(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFfmpegInstalled(): Promise<boolean> {
|
||||
refreshBundledToolPaths();
|
||||
|
||||
const ffmpegPath = getFFmpegPath();
|
||||
const ffprobePath = getFFprobePath();
|
||||
if (isVerifiedFfmpegCommands(ffmpegPath, ffprobePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canExecuteCommand(ffmpegPath, ['-version']) && canExecuteCommand(ffprobePath, ['-version'])) {
|
||||
cacheVerifiedFfmpegCommands(ffmpegPath, ffprobePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
_appendDebugLog('ffmpeg-install-start');
|
||||
try {
|
||||
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
|
||||
|
||||
const zipPath = path.join(_getTempPath(), `ffmpeg_essentials_${Date.now()}.zip`);
|
||||
const downloadOk = await downloadFile('https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip', zipPath);
|
||||
if (!downloadOk) return false;
|
||||
|
||||
fs.rmSync(TOOLS_FFMPEG_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TOOLS_FFMPEG_DIR, { recursive: true });
|
||||
|
||||
const extractOk = await extractZip(zipPath, TOOLS_FFMPEG_DIR);
|
||||
try { fs.unlinkSync(zipPath); } catch { }
|
||||
if (!extractOk) return false;
|
||||
|
||||
refreshBundledToolPaths(true);
|
||||
|
||||
const newFfmpegPath = getFFmpegPath();
|
||||
const newFfprobePath = getFFprobePath();
|
||||
const works = canExecuteCommand(newFfmpegPath, ['-version']) && canExecuteCommand(newFfprobePath, ['-version']);
|
||||
if (works) {
|
||||
cacheVerifiedFfmpegCommands(newFfmpegPath, newFfprobePath);
|
||||
}
|
||||
_appendDebugLog('ffmpeg-install-finished', { works, ffmpeg: newFfmpegPath, ffprobe: newFfprobePath });
|
||||
return works;
|
||||
} catch (e) {
|
||||
_appendDebugLog('ffmpeg-install-failed', String(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
81
src/types.ts
81
src/types.ts
@ -1,81 +0,0 @@
|
||||
export interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
export interface MergeGroupItem {
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
}
|
||||
|
||||
export interface MergeGroup {
|
||||
items: MergeGroupItem[];
|
||||
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
|
||||
currentItemIndex: number;
|
||||
downloadedFiles: Record<number, string>;
|
||||
mergedFile?: string;
|
||||
splitFiles?: string[];
|
||||
totalDurationSec?: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration_str: string;
|
||||
status: 'pending' | 'downloading' | 'paused' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
// File paths produced by the download (single file for VOD/clip, multiple
|
||||
// for parts/merge-group splits). Persisted with the queue so completed
|
||||
// items keep their "Open file" / "Show in folder" actions across restarts.
|
||||
outputFiles?: string[];
|
||||
// Live stream recording — when true, item.url is the channel URL
|
||||
// (https://twitch.tv/{streamer}) and streamlink runs until the stream
|
||||
// ends instead of using --hls-start-offset / --hls-duration. The output
|
||||
// filename includes a timestamp so consecutive live recordings of the
|
||||
// same streamer don't collide.
|
||||
isLive?: boolean;
|
||||
// Live recording health snapshot. 'ok' means bytes are flowing within
|
||||
// the freshness window, 'stale' means the streamlink subprocess hasn't
|
||||
// pushed bytes recently (dropped segments, network blip, or stream just
|
||||
// ended), 'unknown' until the first progress event arrives. Only set
|
||||
// for in-flight live recordings; cleared when the recording finishes.
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
speedBytesPerSec?: number;
|
||||
eta: string;
|
||||
status: string;
|
||||
currentPart?: number;
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
outputFiles?: string[];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user