From 9f1b052afd87586f89b44710701be0814d2f743a Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:15:15 +0200 Subject: [PATCH] Release Twitch VOD Manager 1.0.18 Harden update, system-check, queue, cutter, streamer and shutdown state transitions. Add multi-user installer recovery, secret-safe config migration, provider fallback handling, managed-tool validation and real media export coverage. Refresh the English public documentation, release notes and 1.0.18 product screenshot. --- .gitea/workflows/windows-ci.yml | 194 +- .github/workflows/windows-ci.yml | 194 +- CHANGELOG.md | 11 + README.md | 15 +- build/installer.nsh | 27 +- docs/images/twitch-vod-manager-overview.png | Bin 556059 -> 555950 bytes package-lock.json | 10 +- package.json | 39 +- scripts/dev.mjs | 8 +- scripts/public-release-files.json | 59 + scripts/smoke-test-ci-contract.js | 475 +- scripts/smoke-test-cutter-media-matrix.js | 1052 ++++ .../smoke-test-cutter-media-matrix.test.js | 588 ++ scripts/smoke-test-cutter.js | 226 +- scripts/smoke-test-installer.js | 295 +- scripts/smoke-test-installer.test.js | 213 + .../smoke-test-live-integration-contract.js | 333 ++ scripts/smoke-test-live-integration.js | 743 +++ scripts/smoke-test-live-integration.test.js | 704 +++ scripts/smoke-test-managed-tools-live.js | 168 + scripts/smoke-test-managed-tools-live.test.js | 54 + scripts/smoke-test-public-release-config.js | 68 +- scripts/smoke-test-workspace-ui.js | 8 +- ...-workspace-actions.production-path.test.ts | 66 + ...r-workspace-styles.production-path.test.ts | 8 +- ...german-source-text.production-path.test.ts | 15 + src/index.html | 57 +- src/main-runtime.production-path.test.ts | 239 + src/main-shutdown.production-path.test.ts | 66 + src/main.ts | 1928 ++++--- src/main/cutter/index.ts | 13 + src/main/dev-reload.test.ts | 7 + src/main/dev-reload.ts | 9 +- src/main/domain/app-state-store.test.ts | 38 + src/main/domain/app-state-store.ts | 84 +- src/main/domain/config-export.test.ts | 21 + src/main/domain/config-export.ts | 9 +- .../config-import.production-path.test.ts | 16 + src/main/domain/config-input.test.ts | 88 + src/main/domain/config-input.ts | 224 + .../domain/cutter-vfr.production-path.test.ts | 18 + src/main/domain/download-policy.test.ts | 20 + src/main/domain/download-policy.ts | 4 + src/main/domain/external-error.test.ts | 273 + src/main/domain/external-error.ts | 289 + src/main/domain/i18n-backend.test.ts | 9 + src/main/domain/i18n-backend.ts | 6 + src/main/domain/last-good-cache.test.ts | 24 + src/main/domain/last-good-cache.ts | 25 + src/main/domain/merge-recovery.test.ts | 177 + src/main/domain/merge-recovery.ts | 197 + .../merge-split.production-path.test.ts | 39 + src/main/domain/migrator.test.ts | 65 +- src/main/domain/migrator.ts | 81 +- src/main/domain/persistence-commit.test.ts | 19 +- src/main/domain/persistence-commit.ts | 10 + .../domain/phase-boundary-process.test.ts | 68 + src/main/domain/phase-boundary-process.ts | 39 + .../phase-boundary.production-path.test.ts | 23 + src/main/domain/privileged-ipc.test.ts | 2 +- src/main/domain/provider-payload.test.ts | 25 + src/main/domain/provider-payload.ts | 26 + .../queue-addition.production-path.test.ts | 16 + src/main/domain/queue-addition.test.ts | 48 + src/main/domain/queue-addition.ts | 32 + src/main/domain/queue-runtime.test.ts | 172 + src/main/domain/queue-runtime.ts | 124 + src/main/domain/refresh-result.test.ts | 39 + src/main/domain/refresh-result.ts | 17 + src/main/domain/renderer-queue-input.test.ts | 37 + src/main/domain/renderer-queue-input.ts | 4 + src/main/domain/runtime-safety.test.ts | 94 + src/main/domain/runtime-safety.ts | 99 + src/main/domain/top-clips-crawler.test.ts | 31 +- src/main/domain/top-clips-crawler.ts | 27 +- src/main/domain/twitch-oauth.test.ts | 30 +- src/main/domain/twitch-oauth.ts | 58 +- .../twitch-refresh.production-path.test.ts | 29 + src/main/infra/format-helpers.test.ts | 4 +- src/main/infra/format-helpers.ts | 4 +- src/main/queue/index.ts | 7 + .../process-lifecycle.integration.test.ts | 15 +- src/main/queue/process-registry.test.ts | 140 +- src/main/queue/process-registry.ts | 81 +- src/main/storage/index.ts | 11 + src/main/twitch/app-token.test.ts | 148 + src/main/twitch/app-token.ts | 121 + src/main/twitch/index.ts | 8 + src/main/twitch/provider-refresh.test.ts | 92 + src/main/twitch/provider-refresh.ts | 282 + src/main/updates/index.ts | 3 + .../update-lifecycle.production-path.test.ts | 28 + src/main/updates/update-lifecycle.test.ts | 61 + src/main/updates/update-lifecycle.ts | 71 + src/preload.ts | 12 +- src/renderer-archive.ts | 2 +- src/renderer-cutter.production-path.test.ts | 174 + src/renderer-cutter.ts | 51 +- src/renderer-globals.d.ts | 10 +- src/renderer-locale-de.ts | 70 +- src/renderer-locale-en.ts | 46 + src/renderer-profile.production-path.test.ts | 63 + src/renderer-profile.ts | 5 +- src/renderer-queue.production-path.test.ts | 572 ++ src/renderer-queue.ts | 274 +- src/renderer-settings-autosave.test.ts | 149 + src/renderer-settings.production-path.test.ts | 323 ++ src/renderer-settings.ts | 73 +- ...nderer-streamers.state-regressions.test.ts | 1193 ++++ src/renderer-streamers.ts | 368 +- src/renderer-texts.ts | 20 + src/renderer-updates.production-path.test.ts | 559 +- src/renderer-updates.ts | 105 +- src/renderer-vod-hover.lifecycle.test.ts | 242 + src/renderer-vod-hover.ts | 22 +- src/renderer.ts | 19 +- src/style-modules.production-path.test.ts | 70 + src/styles-overlays.css | 1421 +++++ src/styles-workflows.css | 3618 ++++++++++++ src/styles.css | 5027 ----------------- src/tools.ts | 7 + src/types.ts | 7 + src/workspace-refinements.css | 1325 +++++ src/workspace.css | 1325 ----- 124 files changed, 21119 insertions(+), 7777 deletions(-) create mode 100644 scripts/smoke-test-cutter-media-matrix.js create mode 100644 scripts/smoke-test-cutter-media-matrix.test.js create mode 100644 scripts/smoke-test-installer.test.js create mode 100644 scripts/smoke-test-live-integration-contract.js create mode 100644 scripts/smoke-test-live-integration.js create mode 100644 scripts/smoke-test-live-integration.test.js create mode 100644 scripts/smoke-test-managed-tools-live.js create mode 100644 scripts/smoke-test-managed-tools-live.test.js create mode 100644 src/cutter-workspace-actions.production-path.test.ts create mode 100644 src/german-source-text.production-path.test.ts create mode 100644 src/main-runtime.production-path.test.ts create mode 100644 src/main-shutdown.production-path.test.ts create mode 100644 src/main/cutter/index.ts create mode 100644 src/main/domain/config-import.production-path.test.ts create mode 100644 src/main/domain/config-input.test.ts create mode 100644 src/main/domain/config-input.ts create mode 100644 src/main/domain/cutter-vfr.production-path.test.ts create mode 100644 src/main/domain/external-error.test.ts create mode 100644 src/main/domain/external-error.ts create mode 100644 src/main/domain/last-good-cache.test.ts create mode 100644 src/main/domain/last-good-cache.ts create mode 100644 src/main/domain/merge-recovery.test.ts create mode 100644 src/main/domain/merge-recovery.ts create mode 100644 src/main/domain/merge-split.production-path.test.ts create mode 100644 src/main/domain/phase-boundary-process.test.ts create mode 100644 src/main/domain/phase-boundary-process.ts create mode 100644 src/main/domain/phase-boundary.production-path.test.ts create mode 100644 src/main/domain/provider-payload.test.ts create mode 100644 src/main/domain/provider-payload.ts create mode 100644 src/main/domain/queue-addition.production-path.test.ts create mode 100644 src/main/domain/queue-addition.test.ts create mode 100644 src/main/domain/queue-addition.ts create mode 100644 src/main/domain/queue-runtime.test.ts create mode 100644 src/main/domain/queue-runtime.ts create mode 100644 src/main/domain/refresh-result.test.ts create mode 100644 src/main/domain/refresh-result.ts create mode 100644 src/main/domain/runtime-safety.test.ts create mode 100644 src/main/domain/runtime-safety.ts create mode 100644 src/main/domain/twitch-refresh.production-path.test.ts create mode 100644 src/main/queue/index.ts create mode 100644 src/main/storage/index.ts create mode 100644 src/main/twitch/app-token.test.ts create mode 100644 src/main/twitch/app-token.ts create mode 100644 src/main/twitch/index.ts create mode 100644 src/main/twitch/provider-refresh.test.ts create mode 100644 src/main/twitch/provider-refresh.ts create mode 100644 src/main/updates/index.ts create mode 100644 src/main/updates/update-lifecycle.production-path.test.ts create mode 100644 src/main/updates/update-lifecycle.test.ts create mode 100644 src/main/updates/update-lifecycle.ts create mode 100644 src/renderer-profile.production-path.test.ts create mode 100644 src/renderer-queue.production-path.test.ts create mode 100644 src/renderer-settings.production-path.test.ts create mode 100644 src/renderer-streamers.state-regressions.test.ts create mode 100644 src/renderer-vod-hover.lifecycle.test.ts create mode 100644 src/style-modules.production-path.test.ts create mode 100644 src/styles-overlays.css create mode 100644 src/styles-workflows.css create mode 100644 src/workspace-refinements.css diff --git a/.gitea/workflows/windows-ci.yml b/.gitea/workflows/windows-ci.yml index 69d7a11..b3f7b85 100644 --- a/.gitea/workflows/windows-ci.yml +++ b/.gitea/workflows/windows-ci.yml @@ -4,6 +4,32 @@ on: push: pull_request: workflow_dispatch: + inputs: + live_gate: + description: Optional live gate to run after verification + required: true + default: none + type: choice + options: + - none + - twitch + - updater-postpublish + source_version: + description: Published source version for the updater gate + required: false + type: string + source_sha256: + description: SHA-256 of the published source installer + required: false + type: string + update_version: + description: Newly published target version for the updater gate + required: false + type: string + update_sha512: + description: SHA-512 from the newly published latest.yml + required: false + type: string permissions: contents: read @@ -11,11 +37,14 @@ permissions: jobs: verify: runs-on: windows-latest + timeout-minutes: 120 env: CI: 'true' steps: - uses: actions/checkout@v4 + timeout-minutes: 10 - uses: actions/setup-node@v4 + timeout-minutes: 10 with: node-version: '24.11.1' cache: npm @@ -45,14 +74,32 @@ jobs: - name: CI contract run: npm run test:ci-contract timeout-minutes: 10 + - name: Installer contract + run: npm run test:installer-contract + timeout-minutes: 10 + - name: Managed tools contract + run: npm run test:managed-tools-contract + timeout-minutes: 10 + - name: Cutter matrix provisioning contract + run: npm run test:cutter-matrix-contract + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Live integration contract + run: npm run test:live-integration-contract + timeout-minutes: 10 - name: Unit tests run: npm run test:unit timeout-minutes: 10 - name: Focused Electron smoke run: npm run test:e2e:focused timeout-minutes: 10 - - name: Build - run: npm run build + - name: Cutter media matrix + run: node scripts/smoke-test-cutter-media-matrix.js + timeout-minutes: 10 + - name: Clean managed tools provision and repair + run: npm run test:managed-tools-live timeout-minutes: 10 - name: Package directory run: | @@ -76,3 +123,146 @@ jobs: - name: Installer smoke run: npm run test:installer timeout-minutes: 10 + + twitch-live: + if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'twitch' + needs: verify + runs-on: windows-latest + timeout-minutes: 60 + env: + CI: 'true' + steps: + - uses: actions/checkout@v4 + timeout-minutes: 10 + - uses: actions/setup-node@v4 + timeout-minutes: 10 + with: + node-version: '24.11.1' + cache: npm + - name: Clean install + run: npm ci + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Verify live integration contract + run: npm run test:live-integration-contract + timeout-minutes: 10 + - name: Provision pinned media tools + run: | + $env:TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT = Join-Path $env:RUNNER_TEMP "tvm-live-tools-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + @' + const fs = require('node:fs'); + const path = require('node:path'); + const tools = require('./dist/tools.js'); + + (async () => { + const root = process.env.TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT; + const streamlinkDirectory = path.join(root, 'streamlink'); + const ffmpegDirectory = path.join(root, 'ffmpeg'); + const temporaryDirectory = path.join(root, 'temporary'); + fs.mkdirSync(temporaryDirectory, { recursive: true }); + tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory); + const result = await tools.repairManagedTools(); + if (!result.success) throw new Error(`Pinned media tool provisioning failed: ${JSON.stringify(result.statuses)}`); + if (!process.env.GITHUB_ENV) throw new Error('Actions environment export file is unavailable'); + fs.appendFileSync(process.env.GITHUB_ENV, [ + `TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH=${tools.getStreamlinkPath()}`, + `TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH=${tools.getFFprobePath()}`, + '' + ].join('\n')); + })().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); + '@ | node + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + timeout-minutes: 10 + - name: Twitch provider OAuth, Helix and bounded VOD gate + env: + TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID }} + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }} + TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN }} + TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID }} + run: npm run test:live:twitch + timeout-minutes: 10 + + updater-live-postpublish: + if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish' + needs: verify + runs-on: windows-latest + timeout-minutes: 60 + env: + CI: 'true' + TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' + TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: ${{ github.event.inputs.source_version }} + TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: ${{ github.event.inputs.source_sha256 }} + TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: ${{ github.event.inputs.update_version }} + TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: ${{ github.event.inputs.update_sha512 }} + TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + timeout-minutes: 10 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + timeout-minutes: 10 + with: + node-version: '24.11.1' + cache: npm + - name: Clean install + run: npm ci + timeout-minutes: 10 + - name: Require explicit post-publish updater inputs + run: | + $required = @( + 'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION', + 'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA' + ) + foreach ($name in $required) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Missing required post-publish updater input: $name" + } + } + if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') { + throw 'Pinned updater commit provenance must be a 40-character hexadecimal SHA' + } + if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Pinned updater commit provenance must match GITHUB_SHA' + } + $sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim() + $updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim() + $versionPattern = '^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$' + if ($sourceVersionText -notmatch $versionPattern) { + throw 'Pinned source version must be an exact three-segment numeric release version' + } + if ($updateVersionText -notmatch $versionPattern) { + throw 'Pinned update version must be an exact three-segment numeric release version' + } + $sourceVersion = [version]$sourceVersionText + $updateVersion = [version]$updateVersionText + if ($sourceVersion -ge $updateVersion) { + throw 'Pinned source version must be older than the update version' + } + $packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version + if ($updateVersionText -ne $packageVersion) { + throw "Pinned update version must match package.json version $packageVersion" + } + $expectedRef = "refs/tags/v$updateVersionText" + if ($env:GITHUB_REF -ne $expectedRef) { + throw "Post-publish updater gate must run from release tag $expectedRef" + } + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Verify live integration contract + run: npm run test:live-integration-contract + timeout-minutes: 10 + - name: Verify published updater path + run: npm run test:live:updater-postpublish + timeout-minutes: 25 diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 69d7a11..b3f7b85 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -4,6 +4,32 @@ on: push: pull_request: workflow_dispatch: + inputs: + live_gate: + description: Optional live gate to run after verification + required: true + default: none + type: choice + options: + - none + - twitch + - updater-postpublish + source_version: + description: Published source version for the updater gate + required: false + type: string + source_sha256: + description: SHA-256 of the published source installer + required: false + type: string + update_version: + description: Newly published target version for the updater gate + required: false + type: string + update_sha512: + description: SHA-512 from the newly published latest.yml + required: false + type: string permissions: contents: read @@ -11,11 +37,14 @@ permissions: jobs: verify: runs-on: windows-latest + timeout-minutes: 120 env: CI: 'true' steps: - uses: actions/checkout@v4 + timeout-minutes: 10 - uses: actions/setup-node@v4 + timeout-minutes: 10 with: node-version: '24.11.1' cache: npm @@ -45,14 +74,32 @@ jobs: - name: CI contract run: npm run test:ci-contract timeout-minutes: 10 + - name: Installer contract + run: npm run test:installer-contract + timeout-minutes: 10 + - name: Managed tools contract + run: npm run test:managed-tools-contract + timeout-minutes: 10 + - name: Cutter matrix provisioning contract + run: npm run test:cutter-matrix-contract + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Live integration contract + run: npm run test:live-integration-contract + timeout-minutes: 10 - name: Unit tests run: npm run test:unit timeout-minutes: 10 - name: Focused Electron smoke run: npm run test:e2e:focused timeout-minutes: 10 - - name: Build - run: npm run build + - name: Cutter media matrix + run: node scripts/smoke-test-cutter-media-matrix.js + timeout-minutes: 10 + - name: Clean managed tools provision and repair + run: npm run test:managed-tools-live timeout-minutes: 10 - name: Package directory run: | @@ -76,3 +123,146 @@ jobs: - name: Installer smoke run: npm run test:installer timeout-minutes: 10 + + twitch-live: + if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'twitch' + needs: verify + runs-on: windows-latest + timeout-minutes: 60 + env: + CI: 'true' + steps: + - uses: actions/checkout@v4 + timeout-minutes: 10 + - uses: actions/setup-node@v4 + timeout-minutes: 10 + with: + node-version: '24.11.1' + cache: npm + - name: Clean install + run: npm ci + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Verify live integration contract + run: npm run test:live-integration-contract + timeout-minutes: 10 + - name: Provision pinned media tools + run: | + $env:TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT = Join-Path $env:RUNNER_TEMP "tvm-live-tools-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + @' + const fs = require('node:fs'); + const path = require('node:path'); + const tools = require('./dist/tools.js'); + + (async () => { + const root = process.env.TWITCH_VOD_MANAGER_LIVE_TOOL_ROOT; + const streamlinkDirectory = path.join(root, 'streamlink'); + const ffmpegDirectory = path.join(root, 'ffmpeg'); + const temporaryDirectory = path.join(root, 'temporary'); + fs.mkdirSync(temporaryDirectory, { recursive: true }); + tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory); + const result = await tools.repairManagedTools(); + if (!result.success) throw new Error(`Pinned media tool provisioning failed: ${JSON.stringify(result.statuses)}`); + if (!process.env.GITHUB_ENV) throw new Error('Actions environment export file is unavailable'); + fs.appendFileSync(process.env.GITHUB_ENV, [ + `TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH=${tools.getStreamlinkPath()}`, + `TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH=${tools.getFFprobePath()}`, + '' + ].join('\n')); + })().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); + '@ | node + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + timeout-minutes: 10 + - name: Twitch provider OAuth, Helix and bounded VOD gate + env: + TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID }} + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }} + TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN }} + TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: ${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID }} + run: npm run test:live:twitch + timeout-minutes: 10 + + updater-live-postpublish: + if: github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish' + needs: verify + runs-on: windows-latest + timeout-minutes: 60 + env: + CI: 'true' + TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' + TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: ${{ github.event.inputs.source_version }} + TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: ${{ github.event.inputs.source_sha256 }} + TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: ${{ github.event.inputs.update_version }} + TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: ${{ github.event.inputs.update_sha512 }} + TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + timeout-minutes: 10 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + timeout-minutes: 10 + with: + node-version: '24.11.1' + cache: npm + - name: Clean install + run: npm ci + timeout-minutes: 10 + - name: Require explicit post-publish updater inputs + run: | + $required = @( + 'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION', + 'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA' + ) + foreach ($name in $required) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Missing required post-publish updater input: $name" + } + } + if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') { + throw 'Pinned updater commit provenance must be a 40-character hexadecimal SHA' + } + if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Pinned updater commit provenance must match GITHUB_SHA' + } + $sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim() + $updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim() + $versionPattern = '^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$' + if ($sourceVersionText -notmatch $versionPattern) { + throw 'Pinned source version must be an exact three-segment numeric release version' + } + if ($updateVersionText -notmatch $versionPattern) { + throw 'Pinned update version must be an exact three-segment numeric release version' + } + $sourceVersion = [version]$sourceVersionText + $updateVersion = [version]$updateVersionText + if ($sourceVersion -ge $updateVersion) { + throw 'Pinned source version must be older than the update version' + } + $packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version + if ($updateVersionText -ne $packageVersion) { + throw "Pinned update version must match package.json version $packageVersion" + } + $expectedRef = "refs/tags/v$updateVersionText" + if ($env:GITHUB_REF -ne $expectedRef) { + throw "Post-publish updater gate must run from release tag $expectedRef" + } + timeout-minutes: 10 + - name: Build + run: npm run build + timeout-minutes: 10 + - name: Verify live integration contract + run: npm run test:live-integration-contract + timeout-minutes: 10 + - name: Verify published updater path + run: npm run test:live:updater-postpublish + timeout-minutes: 25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 765ed12..82bc962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.0.18 - 2026-08-13 + +- Keep update downloads, progress, errors and changelog controls contained and responsive throughout the complete update flow. +- Preserve System Check results across language changes and keep repeated diagnostics in a clear terminal state. +- Expand Live Debug Log and Runtime Metrics layouts, improve dark-theme controls and make navigation and settings labels easier to read. +- Improve the video cutter with accessible new, open and save actions, unambiguous frame timecodes, multi-audio exports and verified VFR, AV1, HEVC, MKV, TS and AVI handling. +- Make streamer switching, multi-selection and bulk queue operations race-safe while preserving failed selections for retry. +- Keep queue status, progress, health, speed and remaining time accurate across pause, retry, completion and live recording changes. +- Harden sensitive configuration migration, provider error redaction, process shutdown, crash recovery and Windows installer upgrades. +- Add isolated validation for managed media tools, supported cutter media, Windows installation modes, Twitch provider access and published updater downloads. + ## 1.0.17 - 2026-08-13 - Keep the loaded video cutter focused at every supported window size and give recovery notices their own layout space. diff --git a/README.md b/README.md index 5b6182d..4b3aaf0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The application works in public mode without a Twitch login. Connecting a Twitch ## Installation 1. Open the [latest GitHub release](https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest). -2. Download `Twitch-VOD-Manager-Setup-1.0.17.exe`. +2. Download `Twitch-VOD-Manager-Setup-1.0.18.exe`. 3. Run the installer and choose the installation directory. 4. Start Twitch VOD Manager and add a streamer. @@ -117,12 +117,17 @@ The Windows installer and updater metadata are written to `release/`. | Path | Purpose | | --- | --- | -| `src/main.ts` | Electron main process and desktop integrations | -| `src/main/` | Domain logic, persistence and infrastructure | +| `src/main.ts` | Electron main-process orchestration and desktop integrations | +| `src/main/queue/` | Queue process lifecycle and runtime coordination | +| `src/main/cutter/` | Video cutter integration surface | +| `src/main/twitch/` | Twitch authentication and provider integration | +| `src/main/updates/` | Update lifecycle coordination | +| `src/main/storage/` | Persistence integration surface | +| `src/main/domain/` | Shared domain logic and validation | | `src/renderer-*.ts` | Workspace features and renderer behavior | | `src/index.html` | Application shell and settings pages | -| `src/styles.css` | Shared component styles | -| `src/workspace.css` | Desktop workspace layout and motion | +| `src/styles*.css` | Shared components, workflows and overlays | +| `src/workspace*.css` | Desktop workspace layout, motion and responsive refinements | | `scripts/` | Development, test and release checks | | `build/` | Installer resources and application icons | diff --git a/build/installer.nsh b/build/installer.nsh index ef38475..1a45768 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -3,20 +3,27 @@ nsExec::ExecToLog 'taskkill /F /IM "Twitch VOD Manager.exe"' !macroend -!macro preInit - ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation - ${if} $0 != "" - ${ifNot} ${FileExists} "$0\${APP_EXECUTABLE_FILENAME}" - DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}" - DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}" - ${endIf} +!macro removeOrphanedRegistration ROOT + ReadRegStr $0 ${ROOT} "${INSTALL_REGISTRY_KEY}" InstallLocation + ${if} $0 == "" + ${orIfNot} ${FileExists} "$0\${APP_EXECUTABLE_FILENAME}" + ClearErrors + DeleteRegKey ${ROOT} "${INSTALL_REGISTRY_KEY}" + DeleteRegKey ${ROOT} "${UNINSTALL_REGISTRY_KEY}" + ClearErrors ${endIf} !macroend +!macro preInit + !ifndef BUILD_UNINSTALLER + !insertmacro check64BitAndSetRegView + !insertmacro removeOrphanedRegistration HKCU + !insertmacro removeOrphanedRegistration HKLM + !endif +!macroend + !macro customInstall - CreateDirectory "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons" - CopyFiles /SILENT "$INSTDIR\resources\app-icons\icon-${VERSION}.ico" "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons" - StrCpy $0 "$LOCALAPPDATA\Twitch VOD Manager\Shortcut Icons\icon-${VERSION}.ico" + StrCpy $0 "$INSTDIR\resources\app-icons\icon-${VERSION}.ico" Delete "$SMPROGRAMS\Twitch VOD Manager v*.lnk" Delete "$DESKTOP\Twitch VOD Manager v*.lnk" ${if} ${FileExists} "$newDesktopLink" diff --git a/docs/images/twitch-vod-manager-overview.png b/docs/images/twitch-vod-manager-overview.png index bd377bd35e47499d35d08ff787408e566a35cd0c..aadad03248e667ee6f1bfdbf47228a6760e6e74c 100644 GIT binary patch delta 485607 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�p_D#yjwuw>>_^} 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

WNn>cE|th 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�Uv$$eE0EN|>t%x|t0| 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

U1+ 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�&RV5tS6IQV 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 delta 485633 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�L_rTG`KwxHx2A!jJ6>bZ{JoGgKvYL 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%0A@<@y@f6r{#xH`=;R8w`; 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¨KW{B`Ys0}9rKZWd@l-<&9mvA2dM9nmPlw~) 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

', '
{ + const cutterContext = fragment(html, '
{ + const trimCard = fragment(html, '
', '
'); + + expect(trimCard.match(/placeholder="HH:MM:SS:FF"/g)).toHaveLength(2); + expect(trimCard.match(/title="HH:MM:SS:FF"/g)).toHaveLength(2); + }); + + test('uses correct German umlauts in owned fallback and locale sources', () => { + const german = readFileSync(join(__dirname, 'renderer-locale-de.ts'), 'utf8'); + + expect(html).toContain('Max Stabilität'); + expect(german).toContain('Unterstützte Formate'); + expect(german).toContain("openFolder: 'Öffnen'"); + expect(german).toContain("partMinutesLabel: 'Teil-Länge (Minuten)'"); + expect(german).toContain('Einige Änderungen erfordern'); + expect(german).toContain('Öffnet während einer Live-Aufnahme'); + expect(german).toContain("invalidDuration: 'Ungültig!'"); + expect(german).toContain("empty: 'Keine Videos ausgewählt'"); + expect(german).toContain("success: 'Videos erfolgreich zusammengefügt!'"); + expect(german).toContain("phaseCleanup: 'Aufräumen...'"); + expect(german).toContain("checkInProgress: 'Update-Prüfung läuft bereits.'"); + expect(german).toContain("checkFailed: 'Update-Prüfung fehlgeschlagen.'"); + expect(german).toContain("downloadInProgress: 'Update-Download läuft bereits.'"); + expect(html).not.toContain('Max Stabilitat'); + expect(german).not.toMatch(/Unterstutzte|\bOffnen\b|Teil-Lange|Aenderungen|Oeffnet|Ungultig|ausgewahlt|zusammengefugt|Aufraumen|Update-Prufung|\blauft\b/); + }); + + test('provides localized System Check failure copy to the settings renderer', () => { + const german = readFileSync(join(__dirname, 'renderer-locale-de.ts'), 'utf8'); + const english = readFileSync(join(__dirname, 'renderer-locale-en.ts'), 'utf8'); + + expect(german).toContain("preflightError: 'System-Check fehlgeschlagen.'"); + expect(english).toContain("preflightError: 'System check failed.'"); + }); +}); diff --git a/src/cutter-workspace-styles.production-path.test.ts b/src/cutter-workspace-styles.production-path.test.ts index 12100e2..187bd3f 100644 --- a/src/cutter-workspace-styles.production-path.test.ts +++ b/src/cutter-workspace-styles.production-path.test.ts @@ -2,8 +2,12 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, test } from 'vitest'; -const styles = readFileSync(join(__dirname, 'styles.css'), 'utf8'); -const workspaceStyles = readFileSync(join(__dirname, 'workspace.css'), 'utf8'); +const styles = ['styles.css', 'styles-workflows.css', 'styles-overlays.css'] + .map((fileName) => readFileSync(join(__dirname, fileName), 'utf8')) + .join(''); +const workspaceStyles = ['workspace.css', 'workspace-refinements.css'] + .map((fileName) => readFileSync(join(__dirname, fileName), 'utf8')) + .join(''); describe('cutter workspace style production paths', () => { test('keeps loaded-source visibility independent from the large-window media query', () => { diff --git a/src/german-source-text.production-path.test.ts b/src/german-source-text.production-path.test.ts new file mode 100644 index 0000000..d192e55 --- /dev/null +++ b/src/german-source-text.production-path.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +function source(fileName: string): string { + return readFileSync(join(__dirname, fileName), 'utf8'); +} + +describe('German production text', () => { + it('keeps visible HTML and archive fallbacks free of replacement spellings', () => { + expect(source('index.html')).not.toMatch(/>OffnenSpater Twitch VOD Manager + + +
- +
@@ -294,7 +297,7 @@
@@ -484,31 +487,31 @@
- + - + - +
Gesamtauswahl
- +
- +
@@ -553,7 +556,7 @@
- 00:00:00/00:00:00 + 00:00:00:00/00:00:00:00 - +
@@ -840,7 +843,7 @@
- +
@@ -865,7 +868,7 @@
@@ -942,7 +945,7 @@ diff --git a/src/main-runtime.production-path.test.ts b/src/main-runtime.production-path.test.ts new file mode 100644 index 0000000..6a679e4 --- /dev/null +++ b/src/main-runtime.production-path.test.ts @@ -0,0 +1,239 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +function mainSource(): string { + return readFileSync(join(__dirname, 'main.ts'), 'utf8'); +} + +describe('main runtime safety production paths', () => { + it('isolates startup secret reads from the persistent application state', () => { + const source = mainSource(); + expect(source).toContain("readSecretSafely(appSecretStore, 'twitch_client_secret'"); + expect(source).toContain("readSecretSafely(appSecretStore, 'discord_webhook_url'"); + expect(source).toContain("entry.source !== 'legacy-config-scrub'"); + }); + + it('rejects oversized config files before reading them', () => { + const source = mainSource(); + const handler = source.slice(source.indexOf("ipcMain.handle('import-config'"), source.indexOf('function isTrustedRendererEvent')); + expect(handler.indexOf('fs.statSync(importPath)')).toBeGreaterThan(-1); + expect(handler.indexOf('fs.readFileSync(importPath')).toBeGreaterThan(handler.indexOf('fs.statSync(importPath)')); + expect(handler).toContain('MAX_CONFIG_IMPORT_BYTES'); + }); + + it('tracks both cleanup timers and guards their callbacks during shutdown', () => { + const source = mainSource(); + expect(source).toContain('let autoCleanupStartupTimer: NodeJS.Timeout | null = null;'); + expect(source).toContain('clearTimeout(autoCleanupStartupTimer)'); + expect(source).toMatch(/autoCleanupStartupTimer = setTimeout\([\s\S]*?appShutdownStarted/); + expect(source).toMatch(/function restartAutoCleanupTimer\(\): void \{\s*stopAutoCleanupTimer\(\);\s*if \(appShutdownStarted\) return;/); + }); + + it('cancels the deferred updater setup and refuses to initialize after shutdown', () => { + const source = mainSource(); + expect(source).toContain('let autoUpdaterSetupTimer: NodeJS.Timeout | null = null;'); + expect(source).toContain('clearTimeout(autoUpdaterSetupTimer)'); + expect(source).toMatch(/autoUpdaterSetupTimer = setTimeout\([\s\S]*?!appShutdownStarted[\s\S]*?setupAutoUpdater/); + expect(source).toMatch(/function setupAutoUpdater\(\) \{\s*if \(appShutdownStarted\) return;/); + }); + + it('tracks frame extraction processes and cleans them after waiting during shutdown', () => { + const source = mainSource(); + const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'")); + expect(source).toContain('currentCutterFrameProcesses.add(proc)'); + expect(source).toContain('currentCutterFrameFiles.add(tempFile)'); + expect(source).toMatch(/currentCutterFrameProcesses[\s\S]*?waitForChildProcessExit/); + expect(source).toMatch(/currentCutterFrameFiles[\s\S]*?fs\.rmSync/); + expect(shutdown).toContain('runResilientSteps(frameFiles.map'); + expect(shutdown).toContain('if (!frameProcessesExited) return;'); + }); + + it('waits for standalone clip processes before discarding partial output', () => { + const source = mainSource(); + const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'")); + const wait = shutdown.indexOf('waitForChildProcessExit(tracking.process)'); + const discard = shutdown.indexOf('partialDownloadRegistry.discard(tracking.partialFilename)'); + expect(wait).toBeGreaterThan(-1); + expect(discard).toBeGreaterThan(wait); + const clipCleanup = shutdown.slice(shutdown.indexOf("['clip-processes'"), shutdown.indexOf("['editor-process'")); + expect(clipCleanup).toContain('Promise.allSettled'); + expect(clipCleanup).not.toContain("['clip-wait'"); + expect(clipCleanup).toMatch(/await waitForChildProcessExit\(tracking\.process\)[\s\S]*?tracking\.output\.cancel\(\)[\s\S]*?partialDownloadRegistry\.discard/); + }); + + it('cannot start or publish a standalone clip after shutdown begins', () => { + const source = mainSource(); + const handler = source.slice(source.indexOf("registerTrustedIpcHandler(ipcMain, 'download-clip'"), source.indexOf("registerTrustedIpcHandler(ipcMain, 'run-preflight'")); + const request = handler.indexOf('await getClipInfo(clipId)'); + expect(handler.indexOf('if (appShutdownStarted)', 0)).toBeGreaterThan(-1); + expect(handler.indexOf('if (appShutdownStarted)', request)).toBeGreaterThan(request); + const partial = handler.indexOf('const partialFilename = partialDownloadRegistry.begin(filename)'); + const spawn = handler.indexOf('const proc = spawn('); + const finalGuard = handler.indexOf('if (appShutdownStarted)', partial); + expect(finalGuard).toBeGreaterThan(partial); + expect(finalGuard).toBeLessThan(spawn); + expect(handler.slice(finalGuard, spawn)).toContain('partialDownloadRegistry.discard(partialFilename)'); + expect(handler).toContain('activeClipProcesses.add(tracking)'); + expect(handler).toContain('activeClipProcesses.delete(tracking)'); + const finishHandler = handler.slice(handler.indexOf('const finish ='), handler.indexOf('activeClipProcesses.add(tracking)')); + expect(finishHandler).toContain('activeClipProcesses.delete(tracking)'); + const closeHandler = handler.slice(handler.indexOf("proc.on('close'"), handler.indexOf("proc.on('error'")); + expect(closeHandler).not.toContain('activeClipProcesses.delete(tracking)'); + expect(closeHandler.indexOf('if (appShutdownStarted)')).toBeGreaterThan(closeHandler.indexOf('await outputFinished')); + expect(closeHandler.indexOf('partialDownloadRegistry.commit')).toBeGreaterThan(closeHandler.indexOf('if (appShutdownStarted)')); + expect(closeHandler.indexOf('finish({ success: true, filename })')).toBeGreaterThan(closeHandler.indexOf('partialDownloadRegistry.commit')); + }); + + it('guards and tracks every standalone cut and merge process across shutdown', () => { + const source = mainSource(); + const cut = source.slice(source.indexOf('async function cutVideo('), source.indexOf('async function mergeVideos(')); + const merge = source.slice(source.indexOf('async function mergeVideos('), source.indexOf('async function splitMergedFile(')); + const handlers = source.slice(source.indexOf("ipcMain.handle('cut-video'"), source.indexOf("ipcMain.handle('select-multiple-videos'")); + const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'")); + + expect(cut).toMatch(/await ensureFfmpegInstalled\(\);\s*if \(appShutdownStarted\) return false;/); + expect(cut).toMatch(/const runCutAttempt[\s\S]*?if \(appShutdownStarted\) return false;[\s\S]*?const proc = spawn[\s\S]*?currentEditorProcesses\.add\(proc\)/); + expect(cut).toMatch(/const copySuccess = await runCutAttempt\(true\);\s*if \(appShutdownStarted\) return false;/); + expect(merge).toMatch(/await ensureFfmpegInstalled\(\);\s*if \(appShutdownStarted\) return false;/); + expect(merge).toMatch(/const runMergeAttempt[\s\S]*?if \(appShutdownStarted\) return false;[\s\S]*?const proc = spawn[\s\S]*?currentEditorProcesses\.add\(proc\)/); + expect(merge).toMatch(/const copySuccess = await runMergeAttempt\(true\);\s*if \(appShutdownStarted\) return false;/); + expect(handlers).toContain('const success = completed && !appShutdownStarted'); + expect(handlers).toContain('return produced && !appShutdownStarted'); + expect(shutdown).toContain('const editorProcesses = [...currentEditorProcesses]'); + expect(shutdown).toMatch(/\['editor-processes'[\s\S]*?process\.kill\(\)[\s\S]*?waitForAllChildProcessesExit\(editorProcesses\)/); + }); + + it('runs shutdown poller and cache stops inside the resilient cleanup sequence', () => { + const source = mainSource(); + const shutdown = source.slice(source.indexOf('async function shutdownCleanup'), source.indexOf("app.on('window-all-closed'")); + const resilientStart = shutdown.indexOf('await runResilientSteps(['); + for (const operation of [ + 'stopMetadataCacheCleanup()', + "cleanupMetadataCaches('shutdown')", + 'stopAutoUpdatePolling()', + 'stopAutoRecordPoller()', + 'stopAutoVodPoller()', + 'stopLiveStatusPoller()', + 'stopAutoCleanupTimer()', + ]) { + expect(shutdown.indexOf(operation)).toBeGreaterThan(resilientStart); + } + }); + + it('uses a safe archive-open allowlist', () => { + const source = mainSource(); + expect(source).toContain('SAFE_ARCHIVE_OPEN_EXTENSIONS'); + expect(source).toContain("'.mp4'"); + expect(source).not.toMatch(/SAFE_ARCHIVE_OPEN_EXTENSIONS[^;]+\.url/); + expect(source).not.toMatch(/SAFE_ARCHIVE_OPEN_EXTENSIONS[^;]+\.chm/); + }); + + it('routes auto VOD additions through the same atomic live duplicate check', () => { + const source = mainSource(); + const poller = source.slice(source.indexOf('async function runAutoVodPoll'), source.indexOf('// ==========================================\n// LIVE RECORDING')); + expect(poller).toContain('commitQueueItemWithResult(queueItem, false)'); + expect(poller).not.toContain('const queuedUrls'); + expect(poller).not.toContain('downloadQueue.push(queueItem)'); + }); + + it('uses the built Twitch provider request and fallback orchestration in production refreshes', () => { + const source = mainSource(); + const publicRequest = source.slice(source.indexOf('async function fetchPublicTwitchGqlOutcome'), source.indexOf('async function fetchPublicTwitchGql<')); + expect(publicRequest).toContain('requestPublicTwitchGraphql('); + const users = source.slice(source.indexOf('async function getUserId'), source.indexOf('async function getVODs')); + expect(users).toContain('requestTwitchHelixUsers(axios'); + const vods = source.slice(source.indexOf('async function getVODs'), source.indexOf('interface LiveStreamInfo')); + expect(vods).toContain('refreshTwitchProviderData('); + expect(vods).toContain('requestTwitchHelixVideos(axios'); + expect(vods).toContain('vodListLastGood.get(cacheKey)'); + expect(vods).toContain("refreshed.source === 'last-good'"); + }); + + it('regenerates invalid or duplicate persisted queue ids before renderer exposure', () => { + const source = mainSource(); + const queueLoad = source.slice(source.indexOf('function sanitizeQueueItem'), source.indexOf('let queueSaveTimer')); + expect(queueLoad).toContain('isValidPersistedQueueId(raw.id) ? raw.id : generateQueueItemId()'); + expect(queueLoad).toContain('loadedIds.has(sanitized.id)'); + expect(queueLoad).toContain('sanitized.id = generateQueueItemId()'); + expect(queueLoad).toContain("raw.status === 'downloading' && isPlainObject(raw.mergeGroup)"); + expect(queueLoad).not.toContain('interruptedMergeItemIds.has(rawId)'); + }); + + it('clears transfer metrics on resume, retry, completion, and error transitions', () => { + const source = mainSource(); + const phaseBoundary = source.slice(source.indexOf('async function waitForQueuePhaseBoundary'), source.indexOf('// userId -> login reverse map')); + const resumed = phaseBoundary.slice(phaseBoundary.indexOf('onResumed:'), phaseBoundary.indexOf(' });', phaseBoundary.indexOf('onResumed:'))); + expect(resumed).toContain('delete item.speed'); + expect(resumed).toContain('delete item.eta'); + expect(resumed).toContain('delete item.progressStatus'); + expect(source).toContain("clearQueueTransferState(item, 'pending', 0)"); + expect(source).toContain("clearQueueTransferState(candidate, 'pending', 0)"); + expect(source).toContain("clearQueueTransferState(item, 'downloading', item.progress)"); + expect(source).toContain("finalResult.success ? 'completed' : 'error'"); + expect(source).toContain('const retryProgress = prepareQueueRetryProgress('); + expect(source).toContain('recordDownloadProgress(retryProgress)'); + const retryBlock = source.slice(source.indexOf('const retryProgress = prepareQueueRetryProgress('), source.indexOf('queueProcessRegistry.whenCancelled(item.id)', source.indexOf('const retryProgress = prepareQueueRetryProgress('))); + expect(retryBlock).toContain("if (!queuePaused) mainWindow?.webContents.send('download-progress', retryProgress)"); + }); + + it('does not restore transfer byte counters into inactive persisted queue states', () => { + const source = mainSource(); + const sanitizer = source.slice(source.indexOf('function sanitizeQueueItem'), source.indexOf('interface QueueLoadResult')); + expect(sanitizer).toMatch(/if \(finalStatus === 'paused'\) \{[\s\S]*?raw\.downloadedBytes[\s\S]*?raw\.totalBytes[\s\S]*?\}/); + }); + + it('persists live recording health in main state and queue fingerprints', () => { + const source = mainSource(); + const progress = source.slice(source.indexOf('function getQueueBroadcastFingerprint'), source.indexOf('function clearDownloadProgress')); + expect(progress).toContain("item.recordingHealth || ''"); + expect(progress).toContain('mergeQueueProgressState(item, progress, false)'); + }); + + it('keeps merge cleanup recoverable when an artifact cannot be removed', () => { + const source = mainSource(); + const cleanup = source.slice(source.indexOf("mg.mergePhase = 'cleanup'"), source.indexOf('async function processOneQueueItem')); + expect(cleanup).toContain('if (failedCleanup.size > 0)'); + expect(cleanup).toContain('item.mergeRecoveryBlocked = true'); + expect(cleanup).toMatch(/catch\s*\{\s*failedCleanup\.add\(filePath\);\s*\}/); + expect(cleanup).toMatch(/catch\s*\{\s*failedCleanup\.add\(mg\.mergedFile\);\s*\}/); + expect(cleanup.indexOf("mg.mergePhase = 'done'")).toBeGreaterThan(cleanup.indexOf('if (failedCleanup.size > 0)')); + const startup = source.slice(source.indexOf('const queueLoad ='), source.indexOf('lastPersistedQueueSnapshot = cloneQueue(downloadQueue)')); + expect(startup).toContain('item.mergeRecoveryBlocked'); + expect(startup).toContain('queueLoad.interruptedMergeItemIds.add(item.id)'); + const removal = source.slice(source.indexOf("registerTrustedIpcHandler(ipcMain, 'remove-from-queue'"), source.indexOf("ipcMain.handle('clear-completed'")); + expect(removal).toContain('recoverInterruptedMergeArtifacts([removedItem]'); + expect(removal).toContain('if (recovery.failedFiles.length > 0)'); + }); + + it('pins every merge phase to the persisted canonical artifact root', () => { + const source = mainSource(); + const mergePipeline = source.slice(source.indexOf('async function processDownloadMergeGroup'), source.indexOf('async function processOneQueueItem')); + expect(mergePipeline).toContain('resolveMergeArtifactRoot(item, config.download_path)'); + expect(mergePipeline).toContain('item.artifactRoot = artifactRoot'); + expect(mergePipeline).not.toContain('path.join(config.download_path'); + expect(mergePipeline.match(/path\.join\(artifactRoot/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it('exposes actual managed-tool execution counters only through the trusted cutter E2E gate', () => { + const source = mainSource(); + const handler = source.slice(source.indexOf("ipcMain.handle('get-managed-tool-execution-diagnostics'"), source.indexOf("ipcMain.handle('repair-managed-tools'")); + expect(handler).toContain('isTrustedRendererEvent(event)'); + expect(source).toContain('createManagedToolExecutionTracker(Boolean(process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT))'); + expect(handler).toContain('managedToolExecutionTracker.snapshot()'); + expect(source).toContain("recordManagedToolExecution('ffmpeg'"); + expect(source).toContain("recordManagedToolExecution('ffprobe'"); + expect(source).toContain("recordManagedToolExecution('streamlink'"); + }); + + it('rejects merge, split, and concat work when shutdown rejects registration', () => { + const source = mainSource(); + const registrations = [...source.matchAll(/const registration = itemId[\s\S]*?queueProcessRegistry\.register\([\s\S]*?\n\s*: null;/g)]; + expect(registrations).toHaveLength(3); + for (const registration of registrations) { + const tail = source.slice((registration.index ?? 0) + registration[0].length, (registration.index ?? 0) + registration[0].length + 220); + expect(tail).toContain('if (registration && !registration.accepted)'); + expect(tail).toContain('resolve(false)'); + } + }); +}); diff --git a/src/main-shutdown.production-path.test.ts b/src/main-shutdown.production-path.test.ts new file mode 100644 index 0000000..4ce9bb0 --- /dev/null +++ b/src/main-shutdown.production-path.test.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from 'node:events'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, test, vi } from 'vitest'; + +function mainSource(): string { + return readFileSync(join(__dirname, 'main.ts'), 'utf8'); +} + +function sourceFragment(start: string, end: string): string { + const source = mainSource(); + const from = source.indexOf(start); + const to = source.indexOf(end, from); + if (from < 0 || to < 0) throw new Error('Missing main production fragment'); + return source.slice(from, to); +} + +describe('main shutdown production paths', () => { + test('does not spawn a cutter probe after shutdown starts', async () => { + const spawn = vi.fn(() => { + const child = Object.assign(new EventEmitter(), { + stderr: { resume: () => undefined }, + stdout: new EventEmitter(), + kill: () => true, + }); + queueMicrotask(() => child.emit('close', 0)); + return child; + }); + const context: Record = { + appShutdownStarted: true, + spawn, + getFFmpegPath: () => 'ffmpeg.exe', + currentCutterProbeProcesses: new Set(), + setTimeout, + clearTimeout, + globalThis: null, + }; + context.globalThis = context; + const fragment = sourceFragment('async function runCutterFfmpegProbe', 'async function getCutterHardwareEncoders'); + const compiled = transpileModule(`${fragment}\nglobalThis.__runCutterFfmpegProbe = runCutterFfmpegProbe;`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }, + }).outputText; + runInNewContext(compiled, context); + + const result = await (context.__runCutterFfmpegProbe as (args: string[], capture: boolean) => Promise<{ success: boolean; output: string }>)([], false); + + expect(result).toEqual({ success: false, output: '' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + test('applies imported configuration through the shared transition before returning success', () => { + const handler = sourceFragment("ipcMain.handle('import-config'", 'function isTrustedRendererEvent'); + const transition = sourceFragment('function applyConfigTransition', "ipcMain.handle('save-config'"); + const appliedTransition = handler.indexOf('applyConfigTransition(previousConfig, merged);'); + const returned = handler.indexOf('return { success: true'); + const persisted = transition.indexOf('config = persistStateChange'); + const appliedTheme = transition.indexOf('nativeTheme.themeSource = resolveNativeThemeSource(config.theme)'); + + expect(persisted).toBeGreaterThan(-1); + expect(appliedTheme).toBeGreaterThan(persisted); + expect(appliedTransition).toBeGreaterThan(-1); + expect(returned).toBeGreaterThan(appliedTransition); + }); +}); diff --git a/src/main.ts b/src/main.ts index 8f3abe4..f05c512 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,13 +7,11 @@ import { pathToFileURL } from 'node:url'; import type { Transform } from 'node:stream'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; -import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './main/domain/update-version-utils'; -import { createUpdateCheckCoordinator } from './main/domain/update-check-operation'; +import { compareUpdateVersions, createUpdateCheckCoordinator, normalizeUpdateVersion, UpdateLifecycle } from './main/updates'; import { writeFileAtomicSync } from './main/infra/fs-atomic'; import { parseDuration, formatDuration, formatDurationDashed } from './main/infra/duration'; import { sanitizeFilenamePart, - formatTwitchDurationFromSeconds, formatDateWithPattern, getMergeGroupPhaseText as getMergeGroupPhaseTextCore, } from './main/infra/format-helpers'; @@ -21,10 +19,24 @@ import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/ import { watchRendererChanges } from './main/dev-reload'; import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output'; import { createTokenBucketBudget, createTokenBucketTransform } from './main/domain/token-bucket-transform'; -import { decideDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy'; +import { decideDownloadStart, decideStandaloneDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy'; import { PartialDownloadRegistry } from './main/domain/partial-download'; -import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry'; -import { openDatabase, type DbHandle } from './main/infra/db'; +import { + applyQueueSnapshotPreservingActiveItems, + commitQueueAddition, + commitQueueMutation, + createPhaseBoundaryProcessResource, + createRendererQueueItem, + getMergeGroupCleanupPaths, + persistStateChange, + QueueProcessRegistry, + QueueRunLifecycle, + recoverInterruptedMergeArtifacts, + resolveMergeArtifactRoot, + waitForChildProcessExit, + waitForPhaseBoundary, + type QueueAdditionResult, +} from './main/queue'; import { normalizeLogin, normalizeAutoRecordPollSeconds, @@ -40,21 +52,40 @@ import { type PerformanceMode, } from './main/domain/config-normalize'; import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress, DownloadResult } from './types'; -import { buildVodPreviewFrameUrls } from './main/domain/vod-preview'; -import { createWindowsTaskbarDetails, getWindowsAppIdentity, resolveWindowsAppIconPath } from './main/domain/app-identity'; -import { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange, type EditorCut } from './main/domain/video-editor'; import { + buildVodPreviewFrameUrls, + parseGraphqlUser, + refreshTwitchProviderData, + requestPublicTwitchGraphql, + requestPublicTwitchVodsByLogin, + requestTwitchAppAccessToken, + requestTwitchHelixUsers, + requestTwitchHelixVideos, + TwitchAppTokenService, + type RefreshOutcome, + type TwitchHelixUser, + type TwitchVod, +} from './main/twitch'; +import { createWindowsTaskbarDetails, getWindowsAppIdentity, resolveWindowsAppIconPath } from './main/domain/app-identity'; +import { + addCutAt, calculateCutterExportProgress, createCutterExportPlan, + createCutterProjectAutosaveStore, + createVideoEditorState, CUTTER_EXPORT_PROFILES, + getPlayableSegments, getCutterExportProfile, parseCutterHardwareEncoders, probeCutterHardwareEncoders, + setTrimRange, type CutterExportEncoder, type CutterExportProfile, type CutterHardwareEncoder, -} from './main/domain/cutter-export'; -import { createCutterProjectAutosaveStore, type CutterProject, type CutterProjectSource } from './main/domain/cutter-project'; + type CutterProject, + type CutterProjectSource, + type EditorCut, +} from './main/cutter'; import { CUTTER_SESSION_CAPABILITY_TTL_MS, FileCapabilityStore, @@ -64,24 +95,43 @@ import { type FileCapabilityReference, } from './main/domain/file-capability'; import { registerTrustedIpcHandler } from './main/domain/privileged-ipc'; -import { createRendererQueueItem, getMergeGroupCleanupPaths } from './main/domain/renderer-queue-input'; -import { createAppStateStore, type AppStateStore } from './main/domain/app-state-store'; -import { createExportableConfig } from './main/domain/config-export'; -import { commitQueueMutation, persistStateChange } from './main/domain/persistence-commit'; -import { resolveSecretInputUpdate } from './main/domain/secret-input'; -import { createSecretStore, type SecretStore } from './main/domain/secret-store'; -import { migrateJsonToSqlite } from './main/domain/migrator'; -import { createElectronSecureStorage } from './main/infra/secure-storage'; +import { + createAppStateStore, + createElectronSecureStorage, + createExportableConfig, + createSecretStore, + migrateJsonToSqlite, + normalizeStreamerLogins, + openDatabase, + resolveSecretInputUpdate, + sanitizeConfigInput, + sanitizeImportedConfig, + type AppStateStore, + type DbHandle, + type SecretStore, +} from './main/storage'; +import { projectExternalError, sanitizeLogDetails } from './main/domain/external-error'; +import { LastGoodCache } from './main/domain/last-good-cache'; import { readChatFile } from './main/domain/chat-reader'; +import { + canonicalQueueItemIdentity, + applyQueueTransferState, + clearQueueTransferState, + getQueueCreatedAtMs, + isValidPersistedQueueId, + mergeQueueProgressState, + prepareQueueRetryProgress, +} from './main/domain/queue-runtime'; +import { createManagedToolExecutionTracker, readSecretSafely, runResilientSteps, secureImportedConfigTransition, type ManagedToolExecutionKind } from './main/domain/runtime-safety'; import { setDebugLogFn, initToolDirs, - getStreamlinkPath, getStreamlinkCommand, getFFmpegPath, getFFprobePath, + getStreamlinkCommand, getFFmpegPath, getFFprobePath, refreshBundledToolPaths, ensureStreamlinkInstalled, ensureFfmpegInstalled, getManagedToolStatuses, repairManagedTools, resetManagedTools, canExecute, canExecuteCommand, cacheVerifiedStreamlinkCommand, isVerifiedStreamlinkCommand, cacheVerifiedFfmpegCommands, isVerifiedFfmpegCommands, - invalidateVerifiedToolCaches + invalidateVerifiedToolCaches, setManagedToolExecutionObserver } from './tools'; // ========================================== @@ -136,12 +186,30 @@ const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000; const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096; const MAX_VOD_LIST_CACHE_ENTRIES = 512; const MAX_CLIP_INFO_CACHE_ENTRIES = 4096; +const MAX_CONFIG_IMPORT_BYTES = 1024 * 1024; // Timeouts const API_TIMEOUT = 10000; const DEFAULT_RETRY_DELAY_SECONDS = 5; const MIN_FILE_BYTES = 256 * 1024; -const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; +const managedToolExecutionTracker = createManagedToolExecutionTracker(Boolean(process.env.TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT)); + +function managedToolKindFromCommand(command: string): ManagedToolExecutionKind | null { + const executable = path.basename(command).toLowerCase(); + if (executable.startsWith('ffmpeg')) return 'ffmpeg'; + if (executable.startsWith('ffprobe')) return 'ffprobe'; + if (executable.startsWith('streamlink')) return 'streamlink'; + return null; +} + +function recordManagedToolExecution(kind: ManagedToolExecutionKind, command: string): void { + managedToolExecutionTracker.record(kind, command); +} + +setManagedToolExecutionObserver((command) => { + const kind = managedToolKindFromCommand(command); + if (kind) recordManagedToolExecution(kind, command); +}); type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown'; type UpdateCheckSource = 'startup' | 'interval' | 'manual'; @@ -270,16 +338,7 @@ interface CacheEntry { expiresAt: number; } -interface VOD { - id: string; - title: string; - created_at: string; - duration: string; - thumbnail_url: string; - url: string; - view_count: number; - stream_id: string; -} +type VOD = TwitchVod; interface PreflightChecks { internet: boolean; @@ -455,6 +514,7 @@ function normalizeConfigTemplates(input: Config): Config { return { ...input, + streamers: normalizeStreamerLogins(input.streamers) ?? [], streamer_display_names: displayNames, filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD), filename_template_parts: normalizeFilenameTemplate(input.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), @@ -586,6 +646,7 @@ function sanitizeMergeGroup(raw: unknown): MergeGroup | undefined { downloadedFiles, mergedFile: typeof raw.mergedFile === 'string' ? raw.mergedFile : undefined, splitFiles: Array.isArray(raw.splitFiles) ? raw.splitFiles.filter((f): f is string => typeof f === 'string') : undefined, + splitTempFiles: Array.isArray(raw.splitTempFiles) ? raw.splitTempFiles.filter((f): f is string => typeof f === 'string') : undefined, totalDurationSec: typeof raw.totalDurationSec === 'number' && Number.isFinite(raw.totalDurationSec) ? raw.totalDurationSec : undefined }; } @@ -611,7 +672,6 @@ function sanitizeCustomClip(raw: unknown): CustomClip | undefined { function sanitizeQueueItem(raw: unknown): QueueItem | null { if (!isPlainObject(raw)) return null; - if (typeof raw.id !== 'string' || !raw.id) return null; if (typeof raw.url !== 'string' || !raw.url) return null; if (!isValidQueueStatus(raw.status)) return null; @@ -623,8 +683,14 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null { const progressNum = Number(raw.progress); const safeProgress = Number.isFinite(progressNum) ? Math.max(0, Math.min(100, progressNum)) : 0; + const id = isValidPersistedQueueId(raw.id) ? raw.id : generateQueueItemId(); + const createdAtMs = getQueueCreatedAtMs({ + id, + createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : undefined, + }, Date.now()); const item: QueueItem = { - id: raw.id, + id, + createdAt: new Date(createdAtMs).toISOString(), url: raw.url, title: typeof raw.title === 'string' ? raw.title : '', date: typeof raw.date === 'string' ? raw.date : '', @@ -636,12 +702,19 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null { if (typeof raw.currentPart === 'number' && Number.isFinite(raw.currentPart)) item.currentPart = raw.currentPart; if (typeof raw.totalParts === 'number' && Number.isFinite(raw.totalParts)) item.totalParts = raw.totalParts; - if (typeof raw.speed === 'string') item.speed = raw.speed; - if (typeof raw.eta === 'string') item.eta = raw.eta; - if (typeof raw.progressStatus === 'string') item.progressStatus = raw.progressStatus; + if (finalStatus === 'downloading' || finalStatus === 'paused') { + if (typeof raw.speed === 'string') item.speed = raw.speed; + if (typeof raw.eta === 'string') item.eta = raw.eta; + if (typeof raw.progressStatus === 'string') item.progressStatus = raw.progressStatus; + if (raw.recordingHealth === 'ok' || raw.recordingHealth === 'stale' || raw.recordingHealth === 'unknown') { + item.recordingHealth = raw.recordingHealth; + } + } if (typeof raw.last_error === 'string') item.last_error = raw.last_error; - if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; - if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; + if (finalStatus === 'paused') { + if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; + if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; + } if (Array.isArray(raw.outputFiles)) { const files = raw.outputFiles.filter((f): f is string => typeof f === 'string' && f.length > 0); @@ -657,32 +730,52 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null { const mergeGroup = sanitizeMergeGroup(raw.mergeGroup); if (mergeGroup) item.mergeGroup = mergeGroup; + if (raw.mergeRecoveryBlocked === true) item.mergeRecoveryBlocked = true; + if (typeof raw.artifactRoot === 'string' && path.isAbsolute(raw.artifactRoot)) item.artifactRoot = path.resolve(raw.artifactRoot); return item; } -function loadQueue(): QueueItem[] { +interface QueueLoadResult { + queue: QueueItem[]; + interruptedMergeItemIds: Set; +} + +function loadQueue(): QueueLoadResult { if (config.persist_queue_on_restart === false) { - return []; + return { queue: [], interruptedMergeItemIds: new Set() }; } try { - const parsed = appStateStore?.loadQueue() ?? []; + const parsed = appStateStore?.loadQueue>() ?? []; + const interruptedMergeItemIds = new Set(); const items: QueueItem[] = []; + const loadedIds = new Set(); let droppedCount = 0; for (const raw of parsed) { + const wasInterruptedMerge = isPlainObject(raw) && raw.status === 'downloading' && isPlainObject(raw.mergeGroup); const sanitized = sanitizeQueueItem(raw); - if (sanitized) items.push(sanitized); + if (sanitized) { + if (loadedIds.has(sanitized.id)) { + do { + sanitized.id = generateQueueItemId(); + } while (loadedIds.has(sanitized.id)); + sanitized.createdAt = new Date().toISOString(); + } + loadedIds.add(sanitized.id); + if (wasInterruptedMerge) interruptedMergeItemIds.add(sanitized.id); + items.push(sanitized); + } else droppedCount++; } if (droppedCount > 0) { console.error(`loadQueue: dropped ${droppedCount} invalid queue item(s)`); } - return items; + return { queue: items, interruptedMergeItemIds }; } catch (e) { console.error('Error loading queue:', e); } - return []; + return { queue: [], interruptedMergeItemIds: new Set() }; } let queueSaveTimer: NodeJS.Timeout | null = null; @@ -768,18 +861,17 @@ let config = normalizeConfigTemplates(defaultConfig); let lastPersistedConfig = cloneConfig(config); let twitchClientSecret = ''; let discordWebhookUrl = ''; -let accessToken: string | null = null; +const twitchAppTokenService = new TwitchAppTokenService( + (credentials) => requestTwitchAppAccessToken(axios, credentials, API_TIMEOUT), + (error) => console.error('Login error:', error), +); let downloadQueue: QueueItem[] = []; let lastPersistedQueueSnapshot: QueueItem[] = []; let queueIdCounter = 0; let lastQueueBroadcastFingerprint = ''; let isDownloading = false; let queuePaused = false; -// Process handle for the standalone video editor pipeline (cutter / merger / -// splitter). Queue downloads track their own children via activeDownloads, -// and clip downloads via activeClipProcesses. Keeping these separate -// prevents cancel-download from killing an unrelated cutter ffmpeg. -let currentEditorProcess: ChildProcess | null = null; +const currentEditorProcesses = new Set(); let currentCutterProcess: ChildProcess | null = null; let currentCutterPartialFile: string | null = null; let cutterExportActive = false; @@ -806,6 +898,8 @@ const currentCutterProbeProcesses = new Set(); const currentCutterInfoProcesses = new Set(); const currentCutterExportProcesses = new Set(); const currentCutterPreviewProcesses = new Set(); +const currentCutterFrameProcesses = new Set(); +const currentCutterFrameFiles = new Set(); // Per-item cancellation lives in `cancelledItemIds`. The previous global // `currentDownloadCancelled` flag was redundant once pause/cancel/remove // started iterating activeDownloads and adding each item to that Set; it @@ -835,6 +929,33 @@ function registerQueuePartialFile(itemId: string, filePath: string): void { cleanup: () => { try { fs.rmSync(filePath, { force: true }); } catch { } }, }); } + +async function waitForQueuePhaseBoundary(itemId: string | null): Promise { + return await waitForPhaseBoundary(itemId, queueProcessRegistry, { + onPaused: () => { + if (!itemId) return; + const item = downloadQueue.find((candidate) => candidate.id === itemId); + if (!item) return; + item.status = 'paused'; + item.speed = ''; + item.eta = ''; + item.progressStatus = tBackend('downloadPaused'); + saveQueue(downloadQueue); + emitQueueUpdated(true); + }, + onResumed: () => { + if (!itemId) return; + const item = downloadQueue.find((candidate) => candidate.id === itemId); + if (!item) return; + item.status = 'downloading'; + delete item.speed; + delete item.eta; + delete item.progressStatus; + saveQueue(downloadQueue); + emitQueueUpdated(true); + }, + }); +} // userId -> login reverse map. Bounded via Map insertion-order eviction so // a long-running session doesn't grow it unbounded across thousands of // streamer lookups. Values are short (~20 char each) but accumulate. @@ -854,6 +975,7 @@ function setUserIdLogin(userId: string, login: string): void { } const loginToUserIdCache = new Map>(); const vodListCache = new Map>(); +const vodListLastGood = new LastGoodCache(MAX_VOD_LIST_CACHE_ENTRIES); const clipInfoCache = new Map>(); const inFlightUserIdRequests = new Map>(); const inFlightVodRequests = new Map>(); @@ -882,15 +1004,12 @@ let pendingDebugLogLines: string[] = []; let autoUpdaterInitialized = false; let autoUpdateCheckTimer: NodeJS.Timeout | null = null; let autoUpdateStartupTimer: NodeJS.Timeout | null = null; -let autoUpdateCheckInProgress = false; +let autoUpdaterSetupTimer: NodeJS.Timeout | null = null; const autoUpdateCheckCoordinator = createUpdateCheckCoordinator(); -let autoUpdateReadyToInstall = false; -let autoUpdateDownloadInProgress = false; +const autoUpdateLifecycle = new UpdateLifecycle(); let lastAutoUpdateCheckAt = 0; let latestKnownUpdateVersion: string | null = null; -let downloadedUpdateVersion: string | null = null; let latestReleaseUpdateInfo: ReleaseUpdateInfo | null = null; -let twitchLoginInFlight: Promise | null = null; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -1095,9 +1214,10 @@ function readDebugLog(lines = 200): string { function appendDebugLog(message: string, details?: unknown): void { try { const ts = new Date().toISOString(); + const sanitizedDetails = sanitizeLogDetails(details); const payload = details === undefined ? '' - : ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`; + : ` | ${typeof sanitizedDetails === 'string' ? sanitizedDetails : JSON.stringify(sanitizedDetails)}`; pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`); @@ -1594,7 +1714,8 @@ function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): s item.totalParts || 0, item.speed || '', item.eta || '', - item.last_error || '' + item.last_error || '', + item.recordingHealth || '' ].join(':')).join('|'); } @@ -1618,18 +1739,10 @@ function emitQueueUpdated(force = false): void { const activeDownloadProgress = new Map(); function recordDownloadProgress(progress: DownloadProgress): void { + if (queuePaused) return; const p = Number(progress.progress); const item = downloadQueue.find((candidate) => candidate.id === progress.id); - if (item) { - if (Number.isFinite(p) && p > 0 && p <= 100) item.progress = Math.max(item.progress, p); - item.speed = progress.speed || ''; - item.eta = progress.eta || ''; - item.progressStatus = progress.status; - if (typeof progress.currentPart === 'number') item.currentPart = progress.currentPart; - if (typeof progress.totalParts === 'number') item.totalParts = progress.totalParts; - if (typeof progress.downloadedBytes === 'number') item.downloadedBytes = progress.downloadedBytes; - if (typeof progress.totalBytes === 'number') item.totalBytes = progress.totalBytes; - } + if (item) mergeQueueProgressState(item, progress, false); const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3; activeDownloadProgress.set(progress.id, fraction); updateTaskbarProgress(); @@ -1679,29 +1792,8 @@ function getRuntimeMetricsSnapshot(): RuntimeMetricsSnapshot { }; } -function normalizeQueueUrlForFingerprint(url: string): string { - return (url || '').trim().toLowerCase().replace(/^https?:\/\/(www\.)?/, ''); -} - function getQueueItemFingerprint(item: Pick): string { - const clip = item.customClip; - const clipFingerprint = clip - ? [ - 'clip', - clip.startSec, - clip.durationSec, - clip.startPart, - clip.filenameFormat, - (clip.filenameTemplate || '').trim().toLowerCase() - ].join(':') - : 'vod'; - - return [ - normalizeQueueUrlForFingerprint(item.url), - (item.streamer || '').trim().toLowerCase(), - (item.date || '').trim(), - clipFingerprint - ].join('|'); + return canonicalQueueItemIdentity(item); } function isQueueItemActive(item: QueueItem): boolean { @@ -1719,7 +1811,7 @@ function hasActiveDuplicate(candidate: Pick { - if (!config.client_id || !twitchClientSecret) { - return false; - } - - try { - const response = await axios.post('https://id.twitch.tv/oauth2/token', null, { - params: { - client_id: config.client_id, - client_secret: twitchClientSecret, - grant_type: 'client_credentials' - }, - timeout: API_TIMEOUT - }); - accessToken = response.data.access_token; - return true; - } catch (e) { - console.error('Login error:', e); - return false; - } -} - -function requestTwitchLogin(): Promise { - if (twitchLoginInFlight) { - return twitchLoginInFlight; - } - - const loginPromise: Promise = twitchLogin().finally(() => { - if (twitchLoginInFlight === loginPromise) { - twitchLoginInFlight = null; - } - }); - - twitchLoginInFlight = loginPromise; - return loginPromise; -} - -async function ensureTwitchAuth(forceRefresh = false): Promise { - if (!config.client_id || !twitchClientSecret) { - accessToken = null; - return false; - } - - if (!forceRefresh && accessToken) { - return true; - } - - return await requestTwitchLogin(); -} - -// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit). -// 4xx (other than 408/429) are application errors and not retried. -function isTransientAxiosError(err: unknown): boolean { - if (!axios.isAxiosError(err)) { - // Non-axios errors thrown from axios.post are typically network-layer - // failures (DNS, ECONNRESET, socket hangup) — retry those too. - return true; - } - const status = err.response?.status; - if (status === undefined) { - // No response means the request never reached / never returned — - // treat as transient (network blip, timeout). - return true; - } - return status === 408 || status === 429 || (status >= 500 && status < 600); +async function ensureTwitchAuth(forceRefresh = false): Promise { + return await twitchAppTokenService.ensure({ + clientId: config.client_id, + clientSecret: twitchClientSecret, + }, forceRefresh); } const TWITCH_GQL_RETRY_ATTEMPTS = 3; -const TWITCH_GQL_RETRY_BASE_DELAY_MS = 400; + +async function fetchPublicTwitchGqlOutcome(query: string, variables: Record): Promise> { + const outcome = await requestPublicTwitchGraphql( + axios, + query, + variables, + API_TIMEOUT, + TWITCH_GQL_RETRY_ATTEMPTS, + ); + if (outcome.status === 'unavailable') appendDebugLog('public-gql-failed'); + return outcome; +} async function fetchPublicTwitchGql(query: string, variables: Record): Promise { - let lastError: unknown = null; - - for (let attempt = 1; attempt <= TWITCH_GQL_RETRY_ATTEMPTS; attempt++) { - try { - const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>( - 'https://gql.twitch.tv/gql', - { query, variables }, - { - headers: { - 'Client-ID': TWITCH_WEB_CLIENT_ID, - 'Content-Type': 'application/json' - }, - timeout: API_TIMEOUT - } - ); - - // GraphQL errors (in `errors[]`) are application-level and not - // retried — the query itself is rejected. - if (response.data.errors?.length) { - const messages = response.data.errors.map((err) => err.message).join('; '); - appendDebugLog('public-gql-errors', { messages, attempt }); - console.error('Public Twitch GQL errors:', messages); - return null; - } - - if (attempt > 1) { - appendDebugLog('public-gql-recovered', { attempt }); - } - return response.data.data || null; - } catch (e) { - lastError = e; - const transient = isTransientAxiosError(e); - const willRetry = transient && attempt < TWITCH_GQL_RETRY_ATTEMPTS; - appendDebugLog('public-gql-failed', { - attempt, - maxAttempts: TWITCH_GQL_RETRY_ATTEMPTS, - transient, - willRetry, - error: String(e) - }); - if (!willRetry) { - break; - } - // Exponential backoff with jitter - const delay = TWITCH_GQL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250); - await sleep(delay); - } - } - - console.error('Public Twitch GQL request failed:', lastError); - return null; + const outcome = await fetchPublicTwitchGqlOutcome(query, variables); + return outcome.status === 'success' ? outcome.value : null; } async function getPublicUserId(username: string): Promise { @@ -1985,51 +1980,10 @@ async function getPublicUserId(username: string): Promise { return user.id; } -async function getPublicVODsByLogin(loginName: string): Promise { +async function getPublicVODsByLogin(loginName: string): Promise> { const login = normalizeLogin(loginName); - if (!login) return []; - - type VideoNode = { - id: string; - title: string; - publishedAt: string; - lengthSeconds: number; - viewCount: number; - previewThumbnailURL: string; - }; - - type VodsQueryResult = { - user: { - videos: { - edges: Array<{ node: VideoNode }>; - }; - } | null; - }; - - const data = await fetchPublicTwitchGql( - 'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }', - { login, first: 100 } - ); - - const edges = data?.user?.videos?.edges || []; - - return edges - .map(({ node }) => { - const id = node?.id; - if (!id) return null; - - return { - id, - title: node.title || 'Untitled VOD', - created_at: node.publishedAt || new Date(0).toISOString(), - duration: formatTwitchDurationFromSeconds(node.lengthSeconds || 0), - thumbnail_url: node.previewThumbnailURL || '', - url: `https://www.twitch.tv/videos/${id}`, - view_count: node.viewCount || 0, - stream_id: '' - } as VOD; - }) - .filter((vod): vod is VOD => Boolean(vod)); + if (!login) return { status: 'not-found' }; + return await requestPublicTwitchVodsByLogin(axios, login, 100, API_TIMEOUT, TWITCH_GQL_RETRY_ATTEMPTS); } async function getUserId(username: string): Promise { @@ -2055,46 +2009,26 @@ async function getUserId(username: string): Promise { return await getPublicUserId(login); }; - if (!(await ensureTwitchAuth())) return await getUserViaPublicApi(); - - const fetchUser = async () => { - return await axios.get('https://api.twitch.tv/helix/users', { - params: { login }, - headers: { - 'Client-ID': config.client_id, - 'Authorization': `Bearer ${accessToken}` - }, - timeout: API_TIMEOUT - }); - }; - - try { - const response = await fetchUser(); - const user = response.data.data[0]; - if (!user?.id) return await getUserViaPublicApi(); - - setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); - setUserIdLogin(user.id, user.login || login); - return user.id; - } catch (e) { - if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { - try { - const retryResponse = await fetchUser(); - const user = retryResponse.data.data[0]; - if (!user?.id) return await getUserViaPublicApi(); - - setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); - setUserIdLogin(user.id, user.login || login); - return user.id; - } catch (retryError) { - console.error('Error getting user after relogin:', retryError); - return await getUserViaPublicApi(); - } + let twitchAccessToken = await ensureTwitchAuth(); + if (!twitchAccessToken) return await getUserViaPublicApi(); + let outcome = await requestTwitchHelixUsers(axios, login, { + clientId: config.client_id, + accessToken: twitchAccessToken, + }, API_TIMEOUT); + if (outcome.status === 'unauthorized') { + twitchAccessToken = await ensureTwitchAuth(true); + if (twitchAccessToken) { + outcome = await requestTwitchHelixUsers(axios, login, { + clientId: config.client_id, + accessToken: twitchAccessToken, + }, API_TIMEOUT); } - - console.error('Error getting user:', e); - return await getUserViaPublicApi(); } + if (outcome.status !== 'success' || !outcome.value[0]) return await getUserViaPublicApi(); + const user = outcome.value[0]; + setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES); + setUserIdLogin(user.id, user.login || login); + return user.id; }); } @@ -2108,8 +2042,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise { } } - const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`; - return await withInFlightDedup(inFlightVodRequests, requestKey, async () => { + return await withInFlightDedup(inFlightVodRequests, cacheKey, async () => { if (!forceRefresh) { const refreshedCachedVods = getCachedValue(vodListCache, cacheKey); if (refreshedCachedVods !== undefined) { @@ -2119,81 +2052,43 @@ async function getVODs(userId: string, forceRefresh = false): Promise { } runtimeMetrics.cacheMisses += 1; - - const getVodsViaPublicApi = async () => { - const login = userIdLoginCache.get(userId); - if (!login) return []; - - const vods = await getPublicVODsByLogin(login); - setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); - return vods; - }; - - if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi(); - - const MAX_VOD_PAGES = 50; // 50 pages x 100 per page = 5000 VODs max - - const fetchVodsPage = async (cursor?: string) => { - const params: Record = { - user_id: userId, - type: 'archive', - first: 100 - }; - if (cursor) params.after = cursor; - - return await axios.get('https://api.twitch.tv/helix/videos', { - params, - headers: { - 'Client-ID': config.client_id, - 'Authorization': `Bearer ${accessToken}` + let twitchAccessToken = await ensureTwitchAuth(); + const refreshed = await refreshTwitchProviderData( + userId, + vodListLastGood.get(cacheKey), + { + requestHelix: async () => { + if (!twitchAccessToken) return { status: 'unavailable' }; + return await requestTwitchHelixVideos(axios, userId, { + clientId: config.client_id, + accessToken: twitchAccessToken, + }, API_TIMEOUT); }, - timeout: API_TIMEOUT - }); - }; - - const fetchAllVodPages = async (): Promise => { - const allVods: VOD[] = []; - let cursor: string | undefined; - let pageCount = 0; - - do { - const response = await fetchVodsPage(cursor); - const pageVods = response.data.data || []; - allVods.push(...pageVods); - - if (pageCount === 0) { - const login = pageVods[0]?.user_login; - if (login) { - setUserIdLogin(userId, normalizeLogin(login)); - } - } - - cursor = response.data.pagination?.cursor; - pageCount++; - } while (cursor && pageCount < MAX_VOD_PAGES); - - return allVods; - }; - - try { - const vods = await fetchAllVodPages(); + refreshToken: async () => { + twitchAccessToken = await ensureTwitchAuth(true); + return twitchAccessToken !== null; + }, + requestPublic: async () => { + const login = userIdLoginCache.get(userId); + return login ? await getPublicVODsByLogin(login) : { status: 'unavailable' }; + }, + }, + ); + if (refreshed.source === 'helix' || refreshed.source === 'public') { + const vods = refreshed.value ?? []; + const login = vods[0]?.user_login; + if (login) setUserIdLogin(userId, normalizeLogin(login)); + vodListLastGood.set(cacheKey, vods); setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); return vods; - } catch (e) { - if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { - try { - const vods = await fetchAllVodPages(); - setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES); - return vods; - } catch (retryError) { - console.error('Error getting VODs after relogin:', retryError); - return await getVodsViaPublicApi(); - } - } - - console.error('Error getting VODs:', e); - return await getVodsViaPublicApi(); } + if (refreshed.source === 'not-found') { + vodListLastGood.delete(cacheKey); + vodListCache.delete(cacheKey); + return []; + } + if (refreshed.source === 'last-good') appendDebugLog('vod-refresh-kept-last-good', { userId }); + return refreshed.value ?? []; }); } @@ -2211,13 +2106,14 @@ async function getLiveStreamInfo(login: string): Promise const normalized = normalizeLogin(login); if (!normalized) return null; - if (await ensureTwitchAuth()) { + const twitchAccessToken = await ensureTwitchAuth(); + if (twitchAccessToken) { try { const response = await axios.get('https://api.twitch.tv/helix/streams', { params: { user_login: normalized, first: 1 }, headers: { 'Client-ID': config.client_id, - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${twitchAccessToken}` }, timeout: API_TIMEOUT }); @@ -2230,7 +2126,7 @@ async function getLiveStreamInfo(login: string): Promise gameName: typeof e.game_name === 'string' ? e.game_name : undefined }; } catch (e) { - appendDebugLog('helix-streams-failed', { login: normalized, error: String(e) }); + appendDebugLog('helix-streams-failed', { login: normalized, error: projectExternalError('twitch-helix-streams', e) }); // fall through to public GQL } } @@ -2283,6 +2179,7 @@ interface StreamerProfile { const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512; const streamerProfileCache = new Map>(); +const streamerProfileLastGood = new LastGoodCache(MAX_STREAMER_PROFILE_CACHE_ENTRIES); const inFlightProfileRequests = new Map>(); // Avatar bytes get embedded as data URLs in the profile so the renderer @@ -2323,33 +2220,26 @@ async function fetchAvatarAsDataUrl(url: string): Promise { } } -interface HelixUser { - id: string; - login: string; - display_name: string; - description: string; - profile_image_url: string; - broadcaster_type: string; -} +type HelixUser = TwitchHelixUser; -async function fetchHelixUserInfo(login: string): Promise { - if (!(await ensureTwitchAuth())) return null; - try { - const response = await axios.get('https://api.twitch.tv/helix/users', { - params: { login }, - headers: { - 'Client-ID': config.client_id, - 'Authorization': `Bearer ${accessToken}` - }, - timeout: API_TIMEOUT - }); - const u = response.data?.data?.[0]; - if (!u?.id) return null; - return u as HelixUser; - } catch (e) { - appendDebugLog('helix-user-info-failed', { login, error: String(e) }); - return null; +async function fetchHelixUserInfo(login: string): Promise> { + let twitchAccessToken = await ensureTwitchAuth(); + if (!twitchAccessToken) return { status: 'unavailable' }; + let outcome = await requestTwitchHelixUsers(axios, login, { + clientId: config.client_id, + accessToken: twitchAccessToken, + }, API_TIMEOUT); + if (outcome.status === 'unauthorized') { + twitchAccessToken = await ensureTwitchAuth(true); + if (!twitchAccessToken) return { status: 'unavailable' }; + outcome = await requestTwitchHelixUsers(axios, login, { + clientId: config.client_id, + accessToken: twitchAccessToken, + }, API_TIMEOUT); } + if (outcome.status === 'unauthorized' || outcome.status === 'unavailable') return { status: 'unavailable' }; + if (outcome.status === 'not-found') return outcome; + return { status: 'success', value: outcome.value[0] }; } interface PublicProfileQueryResult { @@ -2481,10 +2371,10 @@ interface PublicStreamInfo { game: string | null; } -async function fetchPublicStreamerProfile(login: string): Promise { +async function fetchPublicStreamerProfile(login: string): Promise> { // Same query also pulls bannerImageURL and the current stream's // preview + viewer count when live — saves a separate roundtrip. - const data = await fetchPublicTwitchGql( + const outcome = await fetchPublicTwitchGqlOutcome( `query($login: String!) { user(login: $login) { id @@ -2507,32 +2397,44 @@ async function fetchPublicStreamerProfile(login: string): Promise { const normalized = normalizeLogin(login); if (!normalized) return null; + const previousProfile = streamerProfileLastGood.get(normalized); if (!forceRefresh) { const cached = getCachedValue(streamerProfileCache, normalized); @@ -2551,27 +2453,52 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< // stream preview in one shot, and skipping it would mean two // extra roundtrips. Helix takes precedence for displayName / // description (those fields are sometimes richer there). - let displayName = normalized; - let avatarUrl = ''; - let bannerUrl = ''; - let description = ''; - let broadcasterType: '' | 'partner' | 'affiliate' = ''; - let streamFromPublic: PublicStreamInfo | null = null; - let followerCountFromPublic: number | null = null; + let displayName = previousProfile?.displayName ?? normalized; + let avatarUrl = previousProfile?.avatarUrl ?? ''; + let bannerUrl = previousProfile?.bannerUrl ?? ''; + let description = previousProfile?.description ?? ''; + let broadcasterType: '' | 'partner' | 'affiliate' = previousProfile?.broadcasterType ?? ''; + let followerCount = previousProfile?.followerCount ?? null; + let vodCount = previousProfile?.vodCount ?? 0; + let lastStreamAt = previousProfile?.lastStreamAt ?? null; + let isLive = previousProfile?.isLive ?? false; + let currentTitle = previousProfile?.currentTitle ?? null; + let currentGame = previousProfile?.currentGame ?? null; + let currentStreamPreviewRemoteUrl = previousProfile?.currentStreamPreviewUrl ?? ''; + let currentStreamViewers = previousProfile?.currentStreamViewers ?? null; + let publicLiveResolved = false; + let hasFreshSource = false; - const publicProfile = await fetchPublicStreamerProfile(normalized); - if (publicProfile) { + const publicOutcome = await fetchPublicStreamerProfile(normalized); + if (publicOutcome.status === 'success') { + const publicProfile = publicOutcome.value; + hasFreshSource = true; displayName = publicProfile.displayName; avatarUrl = publicProfile.avatarUrl; bannerUrl = publicProfile.bannerUrl; description = publicProfile.description; broadcasterType = publicProfile.broadcasterType; - followerCountFromPublic = publicProfile.followerCount; - streamFromPublic = publicProfile.stream; + followerCount = publicProfile.followerCount; + publicLiveResolved = true; + if (publicProfile.stream) { + isLive = true; + currentTitle = publicProfile.stream.title; + currentGame = publicProfile.stream.game; + currentStreamPreviewRemoteUrl = publicProfile.stream.previewUrl; + currentStreamViewers = publicProfile.stream.viewers; + } else { + isLive = false; + currentTitle = null; + currentGame = null; + currentStreamPreviewRemoteUrl = ''; + currentStreamViewers = null; + } } - const helixUser = await fetchHelixUserInfo(normalized); - if (helixUser) { + const helixOutcome = await fetchHelixUserInfo(normalized); + if (helixOutcome.status === 'success') { + const helixUser = helixOutcome.value; + hasFreshSource = true; displayName = helixUser.display_name || displayName; if (helixUser.profile_image_url) avatarUrl = helixUser.profile_image_url; if (helixUser.description) description = helixUser.description; @@ -2579,14 +2506,20 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< if (bt === 'partner' || bt === 'affiliate') broadcasterType = bt; } - // followerCountFromPublic comes from the public profile query - // above — no separate follower roundtrip needed. - const followerCount = followerCountFromPublic; + if (!hasFreshSource) { + if (publicOutcome.status === 'unavailable' && helixOutcome.status === 'unavailable' && previousProfile) { + appendDebugLog('profile-refresh-kept-last-good', { login: normalized }); + return previousProfile; + } + if (publicOutcome.status === 'not-found' || helixOutcome.status === 'not-found') { + streamerProfileCache.delete(normalized); + streamerProfileLastGood.delete(normalized); + } + return null; + } // Derive vod count + last stream from the already-cached VOD list // when we have an id. No extra network hit. - let vodCount = 0; - let lastStreamAt: string | null = null; const userId = await getUserId(normalized); if (userId) { try { @@ -2600,22 +2533,7 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< } } - let isLive = false; - let currentTitle: string | null = null; - let currentGame: string | null = null; - let currentStreamPreviewRemoteUrl = ''; - let currentStreamViewers: number | null = null; - - if (streamFromPublic) { - // Public-GQL already told us this user is live and gave us a - // preview frame URL + viewer count + game/title. Don't double- - // call getLiveStreamInfo when we already have a fresh answer. - isLive = true; - currentTitle = streamFromPublic.title; - currentGame = streamFromPublic.game; - currentStreamPreviewRemoteUrl = streamFromPublic.previewUrl; - currentStreamViewers = streamFromPublic.viewers; - } else { + if (!publicLiveResolved) { try { const live = await getLiveStreamInfo(normalized); if (live) { @@ -2636,9 +2554,9 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< ? `${currentStreamPreviewRemoteUrl}${currentStreamPreviewRemoteUrl.includes('?') ? '&' : '?'}_=${Date.now()}` : ''; const [avatarDataUrl, bannerDataUrl, livePreviewDataUrl] = await Promise.all([ - avatarUrl ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''), - bannerUrl ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''), - livePreviewUrlForFetch ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('') + /^https?:\/\//i.test(avatarUrl) ? fetchAvatarAsDataUrl(avatarUrl) : Promise.resolve(''), + /^https?:\/\//i.test(bannerUrl) ? fetchAvatarAsDataUrl(bannerUrl) : Promise.resolve(''), + /^https?:\/\//i.test(livePreviewUrlForFetch) ? fetchAvatarAsDataUrl(livePreviewUrlForFetch) : Promise.resolve('') ]); const profile: StreamerProfile = { @@ -2661,6 +2579,7 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< }; setCachedValue(streamerProfileCache, normalized, profile, MAX_STREAMER_PROFILE_CACHE_ENTRIES); + streamerProfileLastGood.set(normalized, profile); return profile; }); } @@ -2816,14 +2735,15 @@ async function getClipInfo(clipId: string): Promise { runtimeMetrics.cacheMisses += 1; - if (!(await ensureTwitchAuth())) return null; + let twitchAccessToken = await ensureTwitchAuth(); + if (!twitchAccessToken) return null; const fetchClip = async () => { return await axios.get('https://api.twitch.tv/helix/clips', { params: { id: clipId }, headers: { 'Client-ID': config.client_id, - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${twitchAccessToken}` }, timeout: API_TIMEOUT }); @@ -2837,7 +2757,11 @@ async function getClipInfo(clipId: string): Promise { } return clip; } catch (e) { - if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { + const refreshedToken = axios.isAxiosError(e) && e.response?.status === 401 + ? await ensureTwitchAuth(true) + : null; + if (refreshedToken) { + twitchAccessToken = refreshedToken; try { const retryResponse = await fetchClip(); const clip = retryResponse.data.data[0] || null; @@ -2846,12 +2770,12 @@ async function getClipInfo(clipId: string): Promise { } return clip; } catch (retryError) { - console.error('Error getting clip after relogin:', retryError); + console.error('Error getting clip after relogin:', projectExternalError('twitch-helix-clips', retryError)); return null; } } - console.error('Error getting clip:', e); + console.error('Error getting clip:', projectExternalError('twitch-helix-clips', e)); return null; } }); @@ -2896,6 +2820,7 @@ async function getVideoInfo(filePath: string, trackedProcesses?: Set { + if (appShutdownStarted) return { success: false, output: '' }; return await new Promise((resolve) => { - const proc = spawn(getFFmpegPath(), args, { windowsHide: true }); + const ffmpegPath = getFFmpegPath(); + recordManagedToolExecution('ffmpeg', ffmpegPath); + const proc = spawn(ffmpegPath, args, { windowsHide: true }); currentCutterProbeProcesses.add(proc); proc.stderr?.resume(); let output = ''; @@ -3056,7 +2984,9 @@ function runEditorMediaProcess(args: string[], runGeneration: number): Promise 24000 || cutterExportCancelled) return false; currentCutterPartialFile = partialFile; const runPlan = async (activePlan: ReturnType): Promise => await new Promise((resolve) => { - const proc = spawn(getFFmpegPath(), activePlan.ffmpegArgs, { windowsHide: true }); + const ffmpegPath = getFFmpegPath(); + recordManagedToolExecution('ffmpeg', ffmpegPath); + const proc = spawn(ffmpegPath, activePlan.ffmpegArgs, { windowsHide: true }); currentCutterProcess = proc; currentCutterExportProcesses.add(proc); proc.stderr?.resume(); @@ -3519,15 +3455,16 @@ async function exportVideoEdit(request: VideoEditExportRequest, onProgress: (per // VIDEO CUTTER // ========================================== async function extractFrame(filePath: string, timeSeconds: number): Promise { + if (appShutdownStarted) return null; const ffmpegReady = await ensureFfmpegInstalled(); - if (!ffmpegReady) { + if (!ffmpegReady || appShutdownStarted) { appendDebugLog('extract-frame-missing-ffmpeg'); return null; } return new Promise((resolve) => { const ffmpeg = getFFmpegPath(); - const tempFile = path.join(app.getPath('temp'), `frame_${Date.now()}.jpg`); + const tempFile = path.join(app.getPath('temp'), `frame_${process.pid}_${Date.now()}.jpg`); const args = [ '-ss', timeSeconds.toString(), @@ -3538,21 +3475,36 @@ async function extractFrame(filePath: string, timeSeconds: number): Promise { - if (code === 0 && fs.existsSync(tempFile)) { - const imageData = fs.readFileSync(tempFile); - const base64 = `data:image/jpeg;base64,${imageData.toString('base64')}`; - fs.unlinkSync(tempFile); - resolve(base64); - } else { - resolve(null); - } - }); + const finish = (code: number | null): void => { + if (settled) return; + settled = true; + currentCutterFrameProcesses.delete(proc); + currentCutterFrameFiles.delete(tempFile); + let frame: string | null = null; + try { + if (code === 0 && !appShutdownStarted && fs.existsSync(tempFile)) { + frame = `data:image/jpeg;base64,${fs.readFileSync(tempFile).toString('base64')}`; + } + } catch { } + try { fs.rmSync(tempFile, { force: true }); } catch { } + resolve(frame); + }; - proc.on('error', () => resolve(null)); + proc.once('close', finish); + proc.once('error', () => finish(null)); }); } @@ -3597,28 +3549,29 @@ async function concatVideoFiles(inputFiles: string[], outputFile: string, itemId ]; try { - while (true) { - const success = await new Promise((resolve) => { + const success = await new Promise((resolve) => { + recordManagedToolExecution('ffmpeg', ffmpeg); const proc = spawn(ffmpeg, args, { windowsHide: true }); const registration = itemId - ? queueProcessRegistry.register(itemId, 'post-processing', { - kill: () => proc.kill(), - wait: () => waitForChildProcessExit(proc), - pause: async () => { - try { proc.kill(); } catch { } - await waitForChildProcessExit(proc); - }, - cleanup: () => { + ? queueProcessRegistry.register(itemId, 'post-processing', createPhaseBoundaryProcessResource( + proc, + () => waitForChildProcessExit(proc), + () => { try { fs.rmSync(outputFile, { force: true }); } catch { } try { fs.rmSync(listFile, { force: true }); } catch { } }, - }) + )) : null; + if (registration && !registration.accepted) { + proc.once('error', () => undefined); + resolve(false); + return; + } let stderrBuf = ''; proc.stderr?.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); }); proc.on('close', (code) => { registration?.release(); - if (code === 0 && (!itemId || (!queueProcessRegistry.isCancelled(itemId) && !queueProcessRegistry.isPaused(itemId))) && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) { + if (code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(outputFile) && fs.statSync(outputFile).size > 0) { appendDebugLog('concat-ok', { output: outputFile, parts: inputFiles.length }); resolve(true); } else { @@ -3632,12 +3585,17 @@ async function concatVideoFiles(inputFiles: string[], outputFile: string, itemId appendDebugLog('concat-spawn-error', String(err)); resolve(false); }); - }); - if (success) return true; - if (!itemId || !queueProcessRegistry.isPaused(itemId)) return false; - await queueProcessRegistry.whenResumed(itemId); - if (queueProcessRegistry.isCancelled(itemId)) return false; + }); + if (itemId && queueProcessRegistry.isPaused(itemId) && !(await waitForQueuePhaseBoundary(itemId))) { + try { fs.rmSync(outputFile, { force: true }); } catch { } + return false; } + if (!success) return false; + if (!(await waitForQueuePhaseBoundary(itemId))) { + try { fs.rmSync(outputFile, { force: true }); } catch { } + return false; + } + return true; } finally { try { fs.rmSync(listFile, { force: true }); } catch { } } @@ -3650,7 +3608,9 @@ async function cutVideo( endTime: number, onProgress: (percent: number) => void ): Promise { + if (appShutdownStarted) return false; const ffmpegReady = await ensureFfmpegInstalled(); + if (appShutdownStarted) return false; if (!ffmpegReady) { appendDebugLog('cut-video-missing-ffmpeg'); return false; @@ -3677,6 +3637,7 @@ async function cutVideo( } const runCutAttempt = async (copyMode: boolean): Promise => { + if (appShutdownStarted) return false; const args = [ '-ss', formatDuration(startTime), '-i', inputFile, @@ -3701,8 +3662,9 @@ async function cutVideo( appendDebugLog('cut-video-attempt', { copyMode, args }); return await new Promise((resolve) => { + recordManagedToolExecution('ffmpeg', ffmpeg); const proc = spawn(ffmpeg, args, { windowsHide: true }); - currentEditorProcess = proc; + currentEditorProcesses.add(proc); proc.stdout?.on('data', (data) => { const line = data.toString(); @@ -3715,8 +3677,8 @@ async function cutVideo( }); proc.on('close', (code) => { - currentEditorProcess = null; - if (code === 0 && fs.existsSync(outputFile)) { + currentEditorProcesses.delete(proc); + if (!appShutdownStarted && code === 0 && fs.existsSync(outputFile)) { const stats = fs.statSync(outputFile); if (stats.size <= 256) { appendDebugLog('cut-video-empty-output', { outputFile, bytes: stats.size }); @@ -3730,13 +3692,14 @@ async function cutVideo( }); proc.on('error', () => { - currentEditorProcess = null; + currentEditorProcesses.delete(proc); resolve(false); }); }); }; const copySuccess = await runCutAttempt(true); + if (appShutdownStarted) return false; if (copySuccess) { return true; } @@ -3759,7 +3722,9 @@ async function mergeVideos( totalDurationSec?: number, itemId: string | null = null ): Promise { + if (appShutdownStarted) return false; const ffmpegReady = await ensureFfmpegInstalled(); + if (appShutdownStarted) return false; if (!ffmpegReady) { appendDebugLog('merge-videos-missing-ffmpeg'); return false; @@ -3806,6 +3771,7 @@ async function mergeVideos( const ffprobe = getFFprobePath(); for (const filePath of inputFiles) { try { + recordManagedToolExecution('ffprobe', ffprobe); const result = execSync( `"${ffprobe}" -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`, { timeout: 10000, windowsHide: true } @@ -3821,6 +3787,7 @@ async function mergeVideos( } const runMergeAttempt = async (copyMode: boolean): Promise => { + if (appShutdownStarted) return false; const args = [ '-f', 'concat', '-safe', '0', @@ -3844,22 +3811,24 @@ async function mergeVideos( appendDebugLog('merge-video-attempt', { copyMode, argsCount: args.length }); return await new Promise((resolve) => { + recordManagedToolExecution('ffmpeg', ffmpeg); const proc = spawn(ffmpeg, args, { windowsHide: true }); const registration = itemId - ? queueProcessRegistry.register(itemId, 'merge', { - kill: () => proc.kill(), - wait: () => waitForChildProcessExit(proc), - pause: async () => { - try { proc.kill(); } catch { } - await waitForChildProcessExit(proc); - }, - cleanup: () => { + ? queueProcessRegistry.register(itemId, 'merge', createPhaseBoundaryProcessResource( + proc, + () => waitForChildProcessExit(proc), + () => { try { fs.rmSync(outputFile, { force: true }); } catch { } try { fs.rmSync(concatFile, { force: true }); } catch { } }, - }) + )) : null; - if (!itemId) currentEditorProcess = proc; + if (registration && !registration.accepted) { + proc.once('error', () => undefined); + resolve(false); + return; + } + if (!itemId) currentEditorProcesses.add(proc); proc.stdout?.on('data', (data) => { const line = data.toString(); @@ -3876,8 +3845,8 @@ async function mergeVideos( proc.on('close', (code) => { registration?.release(); - if (!itemId && currentEditorProcess === proc) currentEditorProcess = null; - const success = code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(outputFile); + if (!itemId) currentEditorProcesses.delete(proc); + const success = !appShutdownStarted && code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(outputFile); if (success) { onProgress(100); } @@ -3886,7 +3855,7 @@ async function mergeVideos( proc.on('error', () => { registration?.release(); - if (!itemId && currentEditorProcess === proc) currentEditorProcess = null; + if (!itemId) currentEditorProcesses.delete(proc); resolve(false); }); }); @@ -3894,11 +3863,18 @@ async function mergeVideos( try { const copySuccess = await runMergeAttempt(true); + if (appShutdownStarted) return false; if (copySuccess) { return true; } - if (itemId && (queueProcessRegistry.isCancelled(itemId) || queueProcessRegistry.isPaused(itemId))) { + if (itemId && queueProcessRegistry.isCancelled(itemId)) { + try { fs.rmSync(outputFile, { force: true }); } catch { } + return false; + } + + const boundaryReady = await waitForQueuePhaseBoundary(itemId); + if (appShutdownStarted || !boundaryReady) { try { fs.rmSync(outputFile, { force: true }); } catch { } return false; } @@ -3909,6 +3885,7 @@ async function mergeVideos( } catch { } const reencodeSuccess = await runMergeAttempt(false); + if (appShutdownStarted) return false; if (!reencodeSuccess) { try { fs.rmSync(outputFile, { force: true }); } catch { } } @@ -3930,9 +3907,12 @@ async function splitMergedFile( totalDurationSec: number, filenameGenerator: (partNum: number) => string, onProgress: (currentPart: number, totalParts: number) => void, + onPartState: (partIndex: number, outputFile: string, temporaryFile: string | null) => void, itemId: string | null = null ): Promise<{ success: boolean; files: string[] }> { + if (appShutdownStarted) return { success: false, files: [] }; const ffmpegReady = await ensureFfmpegInstalled(); + if (appShutdownStarted) return { success: false, files: [] }; if (!ffmpegReady) { appendDebugLog('split-merged-missing-ffmpeg'); return { success: false, files: [] }; @@ -3950,7 +3930,9 @@ async function splitMergedFile( const startSec = i * partDurationSec; const thisDuration = Math.min(partDurationSec, totalDurationSec - startSec); const outputFile = ensureUniqueFilename(path.join(outputFolder, filenameGenerator(i + 1)), itemId); + const temporaryFile = ensureUniqueFilename(path.join(outputFolder, `.merge_split_${Date.now()}_${process.pid}_${i}.mp4`), itemId); + onPartState(i, outputFile, temporaryFile); onProgress(i + 1, numParts); const args = [ @@ -3958,47 +3940,61 @@ async function splitMergedFile( '-i', inputFile, '-t', formatDuration(thisDuration), '-c', 'copy', - '-y', outputFile + '-y', temporaryFile ]; appendDebugLog('split-merged-part', { part: i + 1, total: numParts, startSec, duration: thisDuration }); const success = await new Promise((resolve) => { + recordManagedToolExecution('ffmpeg', ffmpeg); const proc = spawn(ffmpeg, args, { windowsHide: true }); const registration = itemId - ? queueProcessRegistry.register(itemId, 'split', { - kill: () => proc.kill(), - wait: () => waitForChildProcessExit(proc), - pause: async () => { - try { proc.kill(); } catch { } - await waitForChildProcessExit(proc); - }, - cleanup: () => { try { fs.rmSync(outputFile, { force: true }); } catch { } }, - }) + ? queueProcessRegistry.register(itemId, 'split', createPhaseBoundaryProcessResource( + proc, + () => waitForChildProcessExit(proc), + () => { try { fs.rmSync(temporaryFile, { force: true }); } catch { } }, + )) : null; - if (!itemId) currentEditorProcess = proc; + if (registration && !registration.accepted) { + proc.once('error', () => undefined); + resolve(false); + return; + } + if (!itemId) currentEditorProcesses.add(proc); proc.on('close', (code) => { registration?.release(); - if (!itemId && currentEditorProcess === proc) currentEditorProcess = null; - resolve(code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(outputFile)); + if (!itemId) currentEditorProcesses.delete(proc); + resolve(!appShutdownStarted && code === 0 && (!itemId || !queueProcessRegistry.isCancelled(itemId)) && fs.existsSync(temporaryFile)); }); proc.on('error', () => { registration?.release(); - if (!itemId && currentEditorProcess === proc) currentEditorProcess = null; + if (!itemId) currentEditorProcesses.delete(proc); resolve(false); }); }); if (!success) { appendDebugLog('split-merged-part-failed', { part: i + 1, outputFile }); - try { fs.rmSync(outputFile, { force: true }); } catch { } + try { fs.rmSync(temporaryFile, { force: true }); } catch { } + if (!fs.existsSync(temporaryFile)) onPartState(i, '', null); return { success: false, files: splitFiles }; } + try { + fs.renameSync(temporaryFile, outputFile); + } catch { + try { fs.rmSync(temporaryFile, { force: true }); } catch { } + if (!fs.existsSync(temporaryFile)) onPartState(i, '', null); + return { success: false, files: splitFiles }; + } + onPartState(i, outputFile, null); splitFiles.push(outputFile); if (itemId) registerQueuePartialFile(itemId, outputFile); + if (!(await waitForQueuePhaseBoundary(itemId))) { + return { success: false, files: splitFiles }; + } } return { success: true, files: splitFiles }; @@ -4058,6 +4054,7 @@ function downloadVODPart( appendDebugLog('download-part-start', { itemId, command: streamlinkCmd.command, filename, args }); const partialFilename = partialDownloadRegistry.begin(filename); + recordManagedToolExecution('streamlink', streamlinkCmd.command); const proc = spawn(streamlinkCmd.command, args, { windowsHide: true }); const outputStream = fs.createWriteStream(partialFilename, { flags: 'w' }); if (!proc.stdout) { @@ -4081,8 +4078,6 @@ function downloadVODPart( cleanup: () => partialDownloadRegistry.discard(partialFilename), }); - // Register in per-item tracking map for parallel downloads - // (no longer mirrored on a global — currentEditorProcess is editor-only) const itemTracking: ActiveDownloadTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0, output, partialFilename }; activeDownloads.set(itemId, itemTracking); if (queuePaused) output.pause(); @@ -4313,6 +4308,7 @@ function downloadVODPart( // auto-record enabled. const autoRecordLastLiveState = new Map(); let autoRecordPollTimer: NodeJS.Timeout | null = null; +let autoRecordStartupTimer: NodeJS.Timeout | null = null; let autoRecordPollInFlight = false; let autoRecordLastRunAt = 0; let autoRecordNextRunAt = 0; @@ -4323,6 +4319,10 @@ function stopAutoRecordPoller(): void { clearInterval(autoRecordPollTimer); autoRecordPollTimer = null; } + if (autoRecordStartupTimer) { + clearTimeout(autoRecordStartupTimer); + autoRecordStartupTimer = null; + } } function restartAutoRecordPoller(): void { @@ -4339,11 +4339,14 @@ function restartAutoRecordPoller(): void { autoRecordNextRunAt = Date.now() + seconds * 1000; // Kick off an immediate first poll so a freshly-enabled streamer that's // already live gets picked up without waiting a full interval. - setTimeout(() => { void runAutoRecordPoll(); }, 1500); + autoRecordStartupTimer = setTimeout(() => { + autoRecordStartupTimer = null; + if (!appShutdownStarted) void runAutoRecordPoll(); + }, 1500); } async function runAutoRecordPoll(): Promise { - if (autoRecordPollInFlight) return 0; + if (appShutdownStarted || autoRecordPollInFlight) return 0; autoRecordPollInFlight = true; let triggered = 0; try { @@ -4379,6 +4382,7 @@ async function runAutoRecordPoll(): Promise { const liveItem: QueueItem = { id: generateQueueItemId(), + createdAt: new Date().toISOString(), title: info.title || `${streamer} (LIVE)`, url: `https://www.twitch.tv/${streamer}`, date: new Date().toISOString(), @@ -4388,9 +4392,11 @@ async function runAutoRecordPoll(): Promise { progress: 0, isLive: true }; - downloadQueue.push(liveItem); - saveQueue(downloadQueue); - emitQueueUpdated(); + const addition = commitQueueItemWithResult(liveItem, false); + if (!addition.accepted) { + if (addition.reason === 'shutting-down' || addition.reason === 'persistence-failed') return triggered; + continue; + } triggered++; appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title }); @@ -4420,6 +4426,7 @@ async function runAutoRecordPoll(): Promise { // live-status check, and new VODs only appear after a stream ends, so // minute-level lag is fine. let autoVodPollTimer: NodeJS.Timeout | null = null; +let autoVodStartupTimer: NodeJS.Timeout | null = null; let autoVodPollInFlight = false; let autoVodLastRunAt = 0; let autoVodNextRunAt = 0; @@ -4430,6 +4437,10 @@ function stopAutoVodPoller(): void { clearInterval(autoVodPollTimer); autoVodPollTimer = null; } + if (autoVodStartupTimer) { + clearTimeout(autoVodStartupTimer); + autoVodStartupTimer = null; + } } function restartAutoVodPoller(): void { @@ -4448,11 +4459,14 @@ function restartAutoVodPoller(): void { autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000); autoVodPollTimer.unref?.(); autoVodNextRunAt = Date.now() + minutes * 60 * 1000; - setTimeout(() => { void runAutoVodPoll(); }, 5000); + autoVodStartupTimer = setTimeout(() => { + autoVodStartupTimer = null; + if (!appShutdownStarted) void runAutoVodPoll(); + }, 5000); } async function runAutoVodPoll(): Promise { - if (autoVodPollInFlight) return 0; + if (appShutdownStarted || autoVodPollInFlight) return 0; autoVodPollInFlight = true; let queuedCount = 0; try { @@ -4467,8 +4481,6 @@ async function runAutoVodPoll(): Promise { const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000; const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []); - const queuedUrls = new Set(downloadQueue.map((it) => it.url)); - for (const streamer of list) { if (!config.auto_vod_download_streamers.includes(streamer)) continue; @@ -4490,13 +4502,13 @@ async function runAutoVodPoll(): Promise { for (const vod of vods) { if (!vod || !vod.id || !vod.url) continue; if (downloadedSet.has(vod.id)) continue; - if (queuedUrls.has(vod.url)) continue; const createdMs = Date.parse(vod.created_at || ''); if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue; const queueItem: QueueItem = { id: generateQueueItemId(), + createdAt: new Date().toISOString(), title: vod.title || `${streamer} VOD ${vod.id}`, url: vod.url, date: vod.created_at, @@ -4505,8 +4517,11 @@ async function runAutoVodPoll(): Promise { status: 'pending', progress: 0 }; - downloadQueue.push(queueItem); - queuedUrls.add(vod.url); + const addition = commitQueueItemWithResult(queueItem, false); + if (!addition.accepted) { + if (addition.reason === 'shutting-down' || addition.reason === 'persistence-failed') return queuedCount; + continue; + } queuedCount++; appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title }); @@ -4527,9 +4542,6 @@ async function runAutoVodPoll(): Promise { } } - saveQueue(downloadQueue); - emitQueueUpdated(); - if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) { scheduleQueueProcessing(); } @@ -4956,6 +4968,7 @@ function runStorageCleanup(opts: { dryRun: boolean }): CleanupReport { } let autoCleanupTimer: NodeJS.Timeout | null = null; +let autoCleanupStartupTimer: NodeJS.Timeout | null = null; let lastAutoCleanupAt = 0; function stopAutoCleanupTimer(): void { @@ -4963,16 +4976,22 @@ function stopAutoCleanupTimer(): void { clearInterval(autoCleanupTimer); autoCleanupTimer = null; } + if (autoCleanupStartupTimer) { + clearTimeout(autoCleanupStartupTimer); + autoCleanupStartupTimer = null; + } } function restartAutoCleanupTimer(): void { stopAutoCleanupTimer(); + if (appShutdownStarted) return; if (!config.auto_cleanup_enabled) return; // Run every 6 hours while the app is running. Skip the first cycle if // the previous run was less than 6h ago to avoid hammering on every // settings save. const SIX_HOURS_MS = 6 * 60 * 60 * 1000; autoCleanupTimer = setInterval(() => { + if (appShutdownStarted) return; if (Date.now() - lastAutoCleanupAt < SIX_HOURS_MS) return; lastAutoCleanupAt = Date.now(); try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } @@ -4980,8 +4999,9 @@ function restartAutoCleanupTimer(): void { autoCleanupTimer.unref?.(); // First run is delayed 60s so it doesn't compete with startup IO. - setTimeout(() => { - if (!config.auto_cleanup_enabled) return; + autoCleanupStartupTimer = setTimeout(() => { + autoCleanupStartupTimer = null; + if (appShutdownStarted || !config.auto_cleanup_enabled) return; if (Date.now() - lastAutoCleanupAt < 60 * 1000) return; lastAutoCleanupAt = Date.now(); try { runStorageCleanup({ dryRun: false }); } catch (e) { appendDebugLog('auto-cleanup-failed', String(e)); } @@ -6432,6 +6452,18 @@ async function processDownloadMergeGroup( onProgress: (progress: DownloadProgress) => void ): Promise { const mg = item.mergeGroup!; + const artifactRootResolution = resolveMergeArtifactRoot(item, config.download_path); + if (!artifactRootResolution.artifactRoot) { + item.mergeRecoveryBlocked = true; + item.last_error = tBackend('mergeRecoveryBlocked'); + saveQueue(downloadQueue); + return { success: false, error: item.last_error }; + } + const artifactRoot = artifactRootResolution.artifactRoot; + if (item.artifactRoot !== artifactRoot) { + item.artifactRoot = artifactRoot; + saveQueue(downloadQueue); + } const totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0); mg.totalDurationSec = totalDurationSec; @@ -6450,7 +6482,7 @@ async function processDownloadMergeGroup( const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(mg.items[0].date); const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; - const folder = path.join(config.download_path, streamer, dateStr); + const folder = path.join(artifactRoot, streamer, dateStr); fs.mkdirSync(folder, { recursive: true }); // Disk space pre-check: 3x total estimated size @@ -6563,14 +6595,18 @@ async function processDownloadMergeGroup( const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(mg.items[0].date); const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; - const folder = path.join(config.download_path, streamer, dateStr); - const mergedFilePath = path.join(folder, `merged_${Date.now()}.mp4`); + const folder = path.join(artifactRoot, streamer, dateStr); + const mergedFilePath = ensureUniqueFilename(path.join(folder, `.merge_output_${Date.now()}_${process.pid}.mp4`), item.id); // Get files in correct order (explicit sort by index — do NOT rely on Object.values ordering) const sortedFiles = Object.keys(mg.downloadedFiles) .sort((a, b) => Number(a) - Number(b)) .map(k => mg.downloadedFiles[Number(k)]); + mg.mergedFile = mergedFilePath; + saveQueue(downloadQueue); + registerQueuePartialFile(item.id, mergedFilePath); + const mergeSuccess = await mergeVideos( sortedFiles, mergedFilePath, @@ -6591,12 +6627,22 @@ async function processDownloadMergeGroup( ); if (!mergeSuccess) { + try { fs.rmSync(mergedFilePath, { force: true }); } catch { } + if (fs.existsSync(mergedFilePath)) { + item.mergeRecoveryBlocked = true; + item.last_error = tBackend('mergeRecoveryBlocked'); + saveQueue(downloadQueue); + return { success: false, error: item.last_error }; + } + delete mg.mergedFile; + saveQueue(downloadQueue); return { success: false, error: tBackend('ffmpegMergeFailed') }; } - mg.mergedFile = mergedFilePath; - registerQueuePartialFile(item.id, mergedFilePath); saveQueue(downloadQueue); + if (!(await waitForQueuePhaseBoundary(item.id))) { + return { success: false, error: tBackend('downloadCancelled') }; + } } // ---- PHASE 3: SPLITTING ---- @@ -6612,9 +6658,10 @@ async function processDownloadMergeGroup( const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); const date = new Date(mg.items[0].date); const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; - const folder = path.join(config.download_path, streamer, dateStr); + const folder = path.join(artifactRoot, streamer, dateStr); const vodId = parseVodId(mg.items[0].url) || 'merged'; + const splitTempFilesByPart = new Map(); const splitResult = await splitMergedFile( mg.mergedFile!, folder, @@ -6649,39 +6696,86 @@ async function processDownloadMergeGroup( totalParts }); }, + (partIndex, outputFile, temporaryFile) => { + const nextSplitFiles = [...(mg.splitFiles ?? [])]; + if (outputFile) nextSplitFiles[partIndex] = outputFile; + else nextSplitFiles.splice(partIndex, 1); + if (temporaryFile) splitTempFilesByPart.set(partIndex, temporaryFile); + else splitTempFilesByPart.delete(partIndex); + if (nextSplitFiles.length > 0) mg.splitFiles = nextSplitFiles; + else delete mg.splitFiles; + const nextTemporaryFiles = [...splitTempFilesByPart.values()]; + if (nextTemporaryFiles.length > 0) mg.splitTempFiles = nextTemporaryFiles; + else delete mg.splitTempFiles; + saveQueue(downloadQueue); + }, item.id ); if (!splitResult.success) { - // Clean up any partial split files - for (const partFile of splitResult.files) { + const failedCleanup = new Set(); + const cleanupCandidates = new Set([ + ...splitResult.files, + ...(mg.splitFiles ?? []), + ...(mg.splitTempFiles ?? []), + ]); + for (const partFile of cleanupCandidates) { try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { } + if (fs.existsSync(partFile)) failedCleanup.add(partFile); } + if (failedCleanup.size > 0) { + mg.splitFiles = (mg.splitFiles ?? []).filter((filePath) => failedCleanup.has(filePath)); + mg.splitTempFiles = (mg.splitTempFiles ?? []).filter((filePath) => failedCleanup.has(filePath)); + item.mergeRecoveryBlocked = true; + item.last_error = tBackend('mergeRecoveryBlocked'); + saveQueue(downloadQueue); + return { success: false, error: item.last_error }; + } + delete mg.splitFiles; + delete mg.splitTempFiles; + saveQueue(downloadQueue); return { success: false, error: tBackend('ffmpegSplitFailed') }; } mg.splitFiles = splitResult.files; + delete mg.splitTempFiles; // ---- PHASE 4: CLEANUP ---- mg.mergePhase = 'cleanup'; saveQueue(downloadQueue); - // Delete individual downloads + const failedCleanup = new Set(); for (const key of Object.keys(mg.downloadedFiles)) { const filePath = mg.downloadedFiles[Number(key)]; try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch { } + if (fs.existsSync(filePath)) failedCleanup.add(filePath); + } catch { + failedCleanup.add(filePath); + } } - // Delete merged file if (mg.mergedFile) { try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); - } catch { } + if (fs.existsSync(mg.mergedFile)) failedCleanup.add(mg.mergedFile); + } catch { + failedCleanup.add(mg.mergedFile); + } } + if (failedCleanup.size > 0) { + item.mergeRecoveryBlocked = true; + item.last_error = tBackend('mergeRecoveryBlocked'); + saveQueue(downloadQueue); + appendDebugLog('merge-group-cleanup-failed', { itemId: item.id, files: failedCleanup.size }); + return { success: false, error: item.last_error }; + } + + mg.downloadedFiles = {}; + delete mg.mergedFile; mg.mergePhase = 'done'; + saveQueue(downloadQueue); appendDebugLog('merge-group-complete', { itemId: item.id, parts: splitResult.files.length, @@ -6717,7 +6811,7 @@ async function processOneQueueItem(item: QueueItem): Promise { activeQueueItemId = item.id; cancelledItemIds.delete(item.id); - item.status = 'downloading'; + applyQueueTransferState(item, 'downloading', item.progress); saveQueue(downloadQueue); emitQueueUpdated(); @@ -6751,6 +6845,8 @@ async function processOneQueueItem(item: QueueItem): Promise { finalResult = result; + if (item.mergeRecoveryBlocked) break; + if (queueProcessRegistry.isPaused(item.id)) { await queueProcessRegistry.whenResumed(item.id); if (!isDownloading || cancelledItemIds.has(item.id) || queueProcessRegistry.isCancelled(item.id)) { @@ -6784,15 +6880,12 @@ async function processOneQueueItem(item: QueueItem): Promise { runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds; item.last_error = tBackend('attemptFailed', { attempt, max: maxAttempts, errorClass, error: result.error || tBackend('unknownDownloadError') }); - mainWindow?.webContents.send('download-progress', { - id: item.id, - progress: -1, - speed: '', - eta: '', - status: tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }), - currentPart: item.currentPart, - totalParts: item.totalParts - } as DownloadProgress); + const retryProgress = prepareQueueRetryProgress( + item, + tBackend('retryingIn', { seconds: retryDelaySeconds, errorClass }), + ); + recordDownloadProgress(retryProgress); + if (!queuePaused) mainWindow?.webContents.send('download-progress', retryProgress); saveQueue(downloadQueue); emitQueueUpdated(); await Promise.race([ @@ -6809,8 +6902,11 @@ async function processOneQueueItem(item: QueueItem): Promise { return; } - item.status = finalResult.success ? 'completed' : 'error'; - item.progress = finalResult.success ? 100 : item.progress; + applyQueueTransferState( + item, + finalResult.success ? 'completed' : 'error', + finalResult.success ? 100 : item.progress, + ); item.last_error = finalResult.success ? '' : (finalResult.error || tBackend('unknownDownloadError')); if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) { @@ -7137,8 +7233,9 @@ function createWindow(): void { mainWindow?.webContents.send('download-started'); } - if (autoUpdateReadyToInstall && downloadedUpdateVersion) { - mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion)); + const updateLifecycleSnapshot = autoUpdateLifecycle.snapshot; + if (updateLifecycleSnapshot.phase === 'ready') { + mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(updateLifecycleSnapshot.version)); } // Auto-resume: if the user opted in AND the persisted queue has @@ -7149,7 +7246,7 @@ function createWindow(): void { if (hasPending) { appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length }); setTimeout(() => { - if (config.auto_resume_queue_on_startup && !isDownloading + if (!appShutdownStarted && config.auto_resume_queue_on_startup && !isDownloading && downloadQueue.some((it) => it.status === 'pending')) { scheduleQueueProcessing(); } @@ -7163,22 +7260,15 @@ function createWindow(): void { }); // Setup auto-updater after window is ready - setTimeout(() => { - setupAutoUpdater(); + autoUpdaterSetupTimer = setTimeout(() => { + autoUpdaterSetupTimer = null; + if (!appShutdownStarted) setupAutoUpdater(); }, 3000); } // ========================================== // AUTO-UPDATER (electron-updater) // ========================================== -function hasNewerKnownUpdateThanDownloaded(): boolean { - if (!latestKnownUpdateVersion || !downloadedUpdateVersion) { - return false; - } - - return isNewerUpdateVersion(latestKnownUpdateVersion, downloadedUpdateVersion); -} - function normalizeReleaseVersionCandidate(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; @@ -7236,6 +7326,7 @@ function buildUpdateInfoPayload(version: string, releaseDate?: string): { } async function requestUpdateCheck(source: UpdateCheckSource, force = false): Promise<{ started: boolean; reason?: string }> { + if (appShutdownStarted) return { started: false, reason: 'shutting-down' }; if (autoUpdateCheckCoordinator.inProgress) { return { started: false, reason: 'in-progress' }; } @@ -7245,78 +7336,83 @@ async function requestUpdateCheck(source: UpdateCheckSource, force = false): Pro return { started: false, reason: 'throttled' }; } + const lifecycleStart = autoUpdateLifecycle.beginCheck(); + if (!lifecycleStart.started) return lifecycleStart; + const result = await autoUpdateCheckCoordinator.run(async () => { - autoUpdateCheckInProgress = true; lastAutoUpdateCheckAt = now; appendDebugLog('update-check-start', { source }); try { - try { - const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, { - timeout: 5000, - headers: { - 'Accept': 'application/json', - 'User-Agent': 'Twitch-VOD-Manager' - } - }); - cacheLatestReleaseUpdateInfo(githubReleaseResponse.data); - const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name; - if (tagName) { - autoUpdater.setFeedURL({ - provider: 'generic', - url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}` - }); - appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME }); + const githubReleaseResponse = await axios.get(GITHUB_RELEASES_API_LATEST_URL, { + timeout: 5000, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Twitch-VOD-Manager' } - } catch (apiErr) { - appendDebugLog('github-api-failed', String(apiErr)); + }); + cacheLatestReleaseUpdateInfo(githubReleaseResponse.data); + const tagName = latestReleaseUpdateInfo?.tagName || githubReleaseResponse.data?.tag_name; + if (tagName) { + autoUpdater.setFeedURL({ + provider: 'generic', + url: `${GITHUB_RELEASES_DOWNLOAD_BASE_URL}/${tagName}` + }); + appendDebugLog('github-feed-url-set', { tagName, owner: GITHUB_REPO_OWNER, repo: GITHUB_REPO_NAME }); } - await autoUpdater.checkForUpdates(); - } finally { - autoUpdateCheckInProgress = false; + } catch (apiErr) { + appendDebugLog('github-api-failed', String(apiErr)); } + await autoUpdater.checkForUpdates(); }, AUTO_UPDATE_CHECK_TIMEOUT_MS); if (result.state === 'completed') { return { started: true }; } if (result.state === 'timed-out') { - appendDebugLog('update-check-ui-timeout', { source, timeoutMs: AUTO_UPDATE_CHECK_TIMEOUT_MS }); + const failed = autoUpdateLifecycle.failCheck(); + appendDebugLog('update-check-ui-timeout', { source, timeoutMs: AUTO_UPDATE_CHECK_TIMEOUT_MS, stateChanged: failed }); return { started: false, reason: 'timed-out' }; } if (result.state === 'in-progress') { return { started: false, reason: 'in-progress' }; } - appendDebugLog('update-check-failed', { source, error: String(result.error) }); + const failed = autoUpdateLifecycle.failCheck(); + appendDebugLog('update-check-failed', { source, error: String(result.error), stateChanged: failed }); + if (failed) { + mainWindow?.webContents.send('update-error', { message: String(result.error), kind: 'check' }); + } console.error('Update check failed:', result.error); return { started: false, reason: 'error' }; } async function requestUpdateDownload(source: UpdateDownloadSource): Promise<{ started: boolean; reason?: string }> { - if (autoUpdateReadyToInstall && !hasNewerKnownUpdateThanDownloaded()) { - return { started: false, reason: 'ready-to-install' }; - } - - if (autoUpdateDownloadInProgress) { - return { started: false, reason: 'in-progress' }; - } - - autoUpdateDownloadInProgress = true; - appendDebugLog('update-download-start', { source }); + if (appShutdownStarted) return { started: false, reason: 'shutting-down' }; + const lifecycleSnapshot = autoUpdateLifecycle.snapshot; + const version = lifecycleSnapshot.phase === 'available' ? lifecycleSnapshot.version : latestKnownUpdateVersion || ''; + const lifecycleStart = autoUpdateLifecycle.beginDownload(version); + if (!lifecycleStart.started) return lifecycleStart; + appendDebugLog('update-download-start', { source, version }); try { await autoUpdater.downloadUpdate(); return { started: true }; } catch (err) { - appendDebugLog('update-download-failed', { source, error: String(err) }); + const failed = autoUpdateLifecycle.failDownload(version); + appendDebugLog('update-download-failed', { source, version, error: String(err), stateChanged: failed }); + if (failed) { + mainWindow?.webContents.send('update-error', { message: String(err), kind: 'download', version }); + } console.error('Download failed:', err); return { started: false, reason: 'error' }; - } finally { - autoUpdateDownloadInProgress = false; } } function stopAutoUpdatePolling(): void { + if (autoUpdaterSetupTimer) { + clearTimeout(autoUpdaterSetupTimer); + autoUpdaterSetupTimer = null; + } if (autoUpdateCheckTimer) { clearInterval(autoUpdateCheckTimer); autoUpdateCheckTimer = null; @@ -7329,6 +7425,7 @@ function stopAutoUpdatePolling(): void { } function startAutoUpdatePolling(): void { + if (appShutdownStarted) return; if (!autoUpdateCheckTimer) { autoUpdateCheckTimer = setInterval(() => { void requestUpdateCheck('interval'); @@ -7349,6 +7446,7 @@ function startAutoUpdatePolling(): void { } function setupAutoUpdater() { + if (appShutdownStarted) return; if (autoUpdaterInitialized) { startAutoUpdatePolling(); return; @@ -7360,6 +7458,10 @@ function setupAutoUpdater() { autoUpdater.autoRunAppAfterInstall = true; autoUpdater.on('checking-for-update', () => { + if (autoUpdateLifecycle.snapshot.phase !== 'checking') { + appendDebugLog('auto-updater-checking-ignored'); + return; + } appendDebugLog('auto-updater-checking'); mainWindow?.webContents.send('update-checking'); }); @@ -7369,6 +7471,7 @@ function setupAutoUpdater() { const displayVersion = incomingVersion || info.version; if (latestKnownUpdateVersion && compareUpdateVersions(incomingVersion, latestKnownUpdateVersion) < 0) { + autoUpdateLifecycle.failCheck(); appendDebugLog('update-available-ignored-older', { incomingVersion: displayVersion, knownVersion: latestKnownUpdateVersion @@ -7376,28 +7479,18 @@ function setupAutoUpdater() { return; } - latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion; - - const hasAlreadyDownloadedThisVersion = Boolean( - autoUpdateReadyToInstall && - downloadedUpdateVersion && - compareUpdateVersions(downloadedUpdateVersion, incomingVersion) === 0 - ); - - appendDebugLog('auto-updater-update-available', { version: displayVersion }); - if (!hasAlreadyDownloadedThisVersion) { - autoUpdateReadyToInstall = false; - } - - autoUpdateDownloadInProgress = false; - - if (hasAlreadyDownloadedThisVersion) { - if (mainWindow) { - mainWindow.webContents.send('update-downloaded', buildUpdateInfoPayload(displayVersion, info.releaseDate)); - } + if (!autoUpdateLifecycle.completeCheckAvailable(incomingVersion)) { + appendDebugLog('update-available-ignored-state', { + incomingVersion: displayVersion, + phase: autoUpdateLifecycle.snapshot.phase, + }); return; } + latestKnownUpdateVersion = incomingVersion || latestKnownUpdateVersion; + + appendDebugLog('auto-updater-update-available', { version: displayVersion }); + if (mainWindow) { mainWindow.webContents.send('update-available', buildUpdateInfoPayload(displayVersion, info.releaseDate)); } @@ -7408,6 +7501,10 @@ function setupAutoUpdater() { }); autoUpdater.on('update-not-available', () => { + if (!autoUpdateLifecycle.completeCheckNotAvailable()) { + appendDebugLog('auto-updater-update-not-available-ignored', { phase: autoUpdateLifecycle.snapshot.phase }); + return; + } appendDebugLog('auto-updater-update-not-available'); mainWindow?.webContents.send('update-not-available'); }); @@ -7416,6 +7513,10 @@ function setupAutoUpdater() { // No per-tick stdout — the autoUpdater fires this ~10x/sec during // an in-flight download. The renderer banner is the user-visible // surface; appendDebugLog already captures phase transitions. + if (autoUpdateLifecycle.snapshot.phase !== 'downloading') { + appendDebugLog('auto-updater-download-progress-ignored', { phase: autoUpdateLifecycle.snapshot.phase }); + return; + } if (mainWindow) { mainWindow.webContents.send('update-download-progress', { percent: progress.percent, @@ -7428,10 +7529,14 @@ function setupAutoUpdater() { autoUpdater.on('update-downloaded', (info) => { const downloadedVersion = normalizeUpdateVersion(info.version) || info.version; + if (!autoUpdateLifecycle.completeDownload(downloadedVersion)) { + appendDebugLog('auto-updater-update-downloaded-ignored', { + version: downloadedVersion, + phase: autoUpdateLifecycle.snapshot.phase, + }); + return; + } appendDebugLog('auto-updater-update-downloaded', { version: downloadedVersion }); - autoUpdateReadyToInstall = true; - autoUpdateDownloadInProgress = false; - downloadedUpdateVersion = downloadedVersion; if (!latestKnownUpdateVersion || compareUpdateVersions(downloadedVersion, latestKnownUpdateVersion) > 0) { latestKnownUpdateVersion = downloadedVersion; } @@ -7441,10 +7546,19 @@ function setupAutoUpdater() { }); autoUpdater.on('error', (err) => { - autoUpdateDownloadInProgress = false; const message = String(err); - appendDebugLog('auto-updater-error', message); - mainWindow?.webContents.send('update-error', { message }); + const lifecycleSnapshot = autoUpdateLifecycle.snapshot; + if (lifecycleSnapshot.phase === 'checking') { + autoUpdateLifecycle.failCheck(); + appendDebugLog('auto-updater-error', { message, kind: 'check' }); + mainWindow?.webContents.send('update-error', { message, kind: 'check' }); + } else if (lifecycleSnapshot.phase === 'downloading') { + autoUpdateLifecycle.failDownload(lifecycleSnapshot.version); + appendDebugLog('auto-updater-error', { message, kind: 'download', version: lifecycleSnapshot.version }); + mainWindow?.webContents.send('update-error', { message, kind: 'download', version: lifecycleSnapshot.version }); + } else { + appendDebugLog('auto-updater-error-ignored', { message, phase: lifecycleSnapshot.phase }); + } console.error('Auto-updater error:', err); }); @@ -7474,8 +7588,7 @@ ipcMain.handle('set-client-secret', (event, value: string) => { if (update.action !== 'set') return appSecretStore.status(); appSecretStore.set('twitch_client_secret', update.value); twitchClientSecret = update.value; - accessToken = null; - twitchLoginInFlight = null; + twitchAppTokenService.clear(); return appSecretStore.status(); }); @@ -7483,8 +7596,7 @@ ipcMain.handle('clear-client-secret', (event) => { if (!isTrustedRendererEvent(event) || !appSecretStore) return appSecretStore?.status() ?? null; appSecretStore.clear('twitch_client_secret'); twitchClientSecret = ''; - accessToken = null; - twitchLoginInFlight = null; + twitchAppTokenService.clear(); return appSecretStore.status(); }); @@ -7533,32 +7645,18 @@ ipcMain.handle('trigger-auto-vod-scan', async (event) => { return { queuedCount }; }); -ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability?: string) => { - if (!isTrustedRendererEvent(event)) return config; - const previousClientId = config.client_id; - const previousCacheMinutes = config.metadata_cache_minutes; - const previousPersistQueueOnRestart = config.persist_queue_on_restart; - const previousTheme = config.theme; - const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []); - const previousAutoRecordSeconds = config.auto_record_poll_seconds; - const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); - const previousAutoVodMinutes = config.auto_vod_download_poll_minutes; - const previousStreamerList = JSON.stringify(config.streamers || []); - const previousDownloadPolicy = JSON.stringify(config.download_policy); - - const acceptedConfig = { ...newConfig }; - delete (acceptedConfig as Record).client_secret; - delete (acceptedConfig as Record).discord_webhook_url; - if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) { - const selectedPath = typeof fileCapability === 'string' - ? resolveFileCapability(event, fileCapability, 'selected-folder') - : null; - if (!selectedPath || normalizeComparablePath(selectedPath) !== normalizeComparablePath(acceptedConfig.download_path)) { - delete acceptedConfig.download_path; - } - } - const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig }); - config = persistStateChange(config, () => nextConfig, saveConfig); +function applyConfigTransition(previousConfig: Config, nextConfig: Config): Config { + const previousClientId = previousConfig.client_id; + const previousCacheMinutes = previousConfig.metadata_cache_minutes; + const previousPersistQueueOnRestart = previousConfig.persist_queue_on_restart; + const previousTheme = previousConfig.theme; + const previousAutoRecordList = JSON.stringify(previousConfig.auto_record_streamers || []); + const previousAutoRecordSeconds = previousConfig.auto_record_poll_seconds; + const previousAutoVodList = JSON.stringify(previousConfig.auto_vod_download_streamers || []); + const previousAutoVodMinutes = previousConfig.auto_vod_download_poll_minutes; + const previousStreamerList = JSON.stringify(previousConfig.streamers || []); + const previousDownloadPolicy = JSON.stringify(previousConfig.download_policy); + config = persistStateChange(previousConfig, () => nextConfig, saveConfig); downloadThrottleBudget.setMaxBytesPerSecond(config.download_policy.throttle?.maxBytesPerSecond ?? null); if (JSON.stringify(config.download_policy) !== previousDownloadPolicy && !isDownloading && downloadQueue.some((item) => item.status === 'pending')) { scheduleQueueProcessing(); @@ -7567,8 +7665,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability } if (config.client_id !== previousClientId) { - accessToken = null; - twitchLoginInFlight = null; + twitchAppTokenService.clear(); } if (config.metadata_cache_minutes !== previousCacheMinutes) { @@ -7590,10 +7687,6 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability saveQueue(downloadQueue, true); } - // Restart auto-record poller if its inputs changed (added/removed - // streamers or interval changed). Drop transition state for any - // streamer no longer being watched so re-enabling them later doesn't - // suppress an immediate first-poll trigger. const newAutoRecordList = JSON.stringify(config.auto_record_streamers || []); if (newAutoRecordList !== previousAutoRecordList || config.auto_record_poll_seconds !== previousAutoRecordSeconds) { const watched = new Set(config.auto_record_streamers || []); @@ -7603,31 +7696,39 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability restartAutoRecordPoller(); } - // Same dance for the auto-VOD poller — independent cadence from - // auto-record because VOD listings are heavier to fetch. const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []); if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) { restartAutoVodPoller(); } - // Live-status batch poller — fire an immediate refresh when the - // streamer list itself changes (added/removed) so the sidebar dots - // update instantly instead of waiting for the next 60s tick. const newStreamerList = JSON.stringify(config.streamers || []); if (newStreamerList !== previousStreamerList) { restartLiveStatusPoller(); } - // Restart cleanup timer when the toggle flips; harmless to call when - // unchanged because restartAutoCleanupTimer just resets the interval. restartAutoCleanupTimer(); - return config; +} + +ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability?: string) => { + if (!isTrustedRendererEvent(event)) return config; + const acceptedConfig = sanitizeConfigInput(newConfig) as Partial; + if (typeof acceptedConfig.download_path === 'string' && acceptedConfig.download_path !== config.download_path) { + const selectedPath = typeof fileCapability === 'string' + ? resolveFileCapability(event, fileCapability, 'selected-folder') + : null; + if (!selectedPath || normalizeComparablePath(selectedPath) !== normalizeComparablePath(acceptedConfig.download_path)) { + delete acceptedConfig.download_path; + } + } + const previousConfig = config; + const nextConfig = normalizeConfigTemplates({ ...previousConfig, ...acceptedConfig }); + return applyConfigTransition(previousConfig, nextConfig); }); ipcMain.handle('login', async (event) => { if (!isTrustedRendererEvent(event)) return false; - return await twitchLogin(); + return (await ensureTwitchAuth(true)) !== null; }); ipcMain.handle('get-user-id', async (_, username: string) => { @@ -7645,7 +7746,7 @@ ipcMain.handle('get-queue', (event) => { }); ipcMain.handle('start-live-recording', async (event, streamerName: string) => { - if (!isTrustedRendererEvent(event)) return { success: false, error: 'Access denied' }; + if (!isTrustedRendererEvent(event) || appShutdownStarted) return { success: false, error: 'Access denied' }; if (typeof streamerName !== 'string' || !streamerName) { return { success: false, error: 'Invalid streamer name' }; } @@ -7653,6 +7754,7 @@ ipcMain.handle('start-live-recording', async (event, streamerName: string) => { if (!login) return { success: false, error: 'Invalid streamer name' }; const liveInfo = await getLiveStreamInfo(login); + if (appShutdownStarted) return { success: false, error: 'Access denied' }; if (liveInfo === null) { return { success: false, error: 'Could not check live status. Try again.' }; } @@ -7663,6 +7765,7 @@ ipcMain.handle('start-live-recording', async (event, streamerName: string) => { const channelUrl = `https://www.twitch.tv/${login}`; const liveItem: QueueItem = { id: generateQueueItemId(), + createdAt: new Date().toISOString(), title: liveInfo.title || `${login} (LIVE)`, url: channelUrl, date: new Date().toISOString(), @@ -7681,34 +7784,65 @@ ipcMain.handle('start-live-recording', async (event, streamerName: string) => { return { success: false, error: 'ALREADY_RECORDING', streamer: login }; } - downloadQueue = persistStateChange(downloadQueue, (current) => [...current, liveItem], saveQueue); - emitQueueUpdated(); + const addition = commitQueueItemWithResult(liveItem, false); + if (!addition.accepted) { + return { success: false, error: addition.reason === 'duplicate' ? 'ALREADY_RECORDING' : addition.reason, streamer: login }; + } if (!isDownloading) scheduleQueueProcessing(); appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title }); return { success: true, streamer: login, title: liveInfo.title || login }; }); -registerTrustedIpcHandler(ipcMain, 'add-to-queue', isTrustedRendererEvent, () => downloadQueue, (_, input: unknown) => { - const item = createRendererQueueItem(input, generateQueueItemId()); - if (!item) return downloadQueue; - if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { +function commitQueueItemWithResult(item: QueueItem | null, notifyDuplicate: boolean): QueueAdditionResult { + if (appShutdownStarted) { + return { queue: downloadQueue, accepted: false, reason: 'shutting-down' }; + } + let duplicate = false; + const result = commitQueueAddition( + downloadQueue, + item, + (candidate) => { + duplicate = config.prevent_duplicate_downloads && hasActiveDuplicate(candidate); + return duplicate; + }, + saveQueue, + ); + if (duplicate && item) { runtimeMetrics.duplicateSkips += 1; - mainWindow?.webContents.send('queue-duplicate-skipped', { - title: item.title, - streamer: item.streamer, - url: item.url - }); + if (notifyDuplicate) { + mainWindow?.webContents.send('queue-duplicate-skipped', { + title: item.title, + streamer: item.streamer, + url: item.url + }); + } appendDebugLog('queue-item-duplicate-skipped', { title: item.title, url: item.url, streamer: item.streamer }); - return downloadQueue; } + if (result.accepted) { + downloadQueue = result.queue; + emitQueueUpdated(); + } else if (result.reason === 'persistence-failed') { + appendDebugLog('queue-item-persist-failed', { title: item?.title || '', url: item?.url || '' }); + } + return result; +} - downloadQueue = persistStateChange(downloadQueue, (current) => [...current, item], saveQueue); - emitQueueUpdated(); - return downloadQueue; +function addRendererQueueItemWithResult(input: unknown, notifyDuplicate: boolean): QueueAdditionResult { + const item = createRendererQueueItem(input, generateQueueItemId()); + if (item) item.createdAt = new Date().toISOString(); + return commitQueueItemWithResult(item, notifyDuplicate); +} + +registerTrustedIpcHandler(ipcMain, 'add-to-queue', isTrustedRendererEvent, () => [], (_, input: unknown) => { + return addRendererQueueItemWithResult(input, true).queue; +}); + +registerTrustedIpcHandler(ipcMain, 'add-to-queue-with-result', isTrustedRendererEvent, () => ({ queue: [], accepted: false, reason: 'access-denied' as const }), (_, input: unknown) => { + return addRendererQueueItemWithResult(input, false); }); registerTrustedIpcHandler(ipcMain, 'remove-from-queue', isTrustedRendererEvent, () => Promise.resolve(downloadQueue), async (_, id: string) => { @@ -7716,6 +7850,18 @@ registerTrustedIpcHandler(ipcMain, 'remove-from-queue', isTrustedRendererEvent, const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id) || queueProcessRegistry.activeItemIds().includes(id); const removedItem = downloadQueue.find((item) => item.id === id); + if (removedItem?.mergeRecoveryBlocked) { + const recovery = recoverInterruptedMergeArtifacts([removedItem], config.download_path, new Set([removedItem.id])); + const recoveredItem = recovery.queue[0]; + if (recovery.failedFiles.length > 0) { + recoveredItem.last_error = tBackend('mergeRecoveryBlocked'); + downloadQueue = persistStateChange(downloadQueue, (current) => current.map((item) => item.id === id ? recoveredItem : item), saveQueue); + emitQueueUpdated(); + return downloadQueue; + } + downloadQueue = persistStateChange(downloadQueue, (current) => current.map((item) => item.id === id ? recoveredItem : item), saveQueue); + } + await commitQueueMutation( downloadQueue, (current) => current.filter((item) => item.id !== id), @@ -7764,18 +7910,13 @@ ipcMain.handle('reorder-queue', (event, orderIds: string[]) => { ipcMain.handle('retry-failed-downloads', async (event) => { if (!isTrustedRendererEvent(event)) return downloadQueue; - const failedIds = downloadQueue.filter((item) => item.status === 'error').map((item) => item.id); + const failedIds = downloadQueue.filter((item) => item.status === 'error' && !item.mergeRecoveryBlocked).map((item) => item.id); await Promise.all(failedIds.map((id) => queueProcessRegistry.cancelItem(id))); for (const id of failedIds) queueProcessRegistry.resetItem(id); const nextQueue: QueueItem[] = downloadQueue.map((item) => { - if (item.status !== 'error') return item; + if (item.status !== 'error' || item.mergeRecoveryBlocked) return item; - return { - ...item, - status: 'pending' as const, - progress: 0, - last_error: '' - }; + return { ...clearQueueTransferState(item, 'pending', 0), last_error: '' }; }); downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue); @@ -7794,17 +7935,14 @@ ipcMain.handle('retry-queue-item', async (event, id: string) => { const idx = downloadQueue.findIndex((it) => it.id === id); if (idx < 0) return downloadQueue; const item = downloadQueue[idx]; - if (item.status !== 'error') return downloadQueue; + if (item.status !== 'error' || item.mergeRecoveryBlocked) return downloadQueue; await queueProcessRegistry.cancelItem(id); queueProcessRegistry.resetItem(id); - downloadQueue = persistStateChange(downloadQueue, (current) => current.map((candidate) => candidate.id === id ? { - ...candidate, - status: 'pending', - progress: 0, - last_error: '' - } : candidate), saveQueue); + downloadQueue = persistStateChange(downloadQueue, (current) => current.map((candidate) => candidate.id === id + ? { ...clearQueueTransferState(candidate, 'pending', 0), last_error: '' } + : candidate), saveQueue); emitQueueUpdated(); appendDebugLog('queue-item-retry-single', { id, title: item.title }); @@ -7816,7 +7954,7 @@ ipcMain.handle('retry-queue-item', async (event, id: string) => { }); ipcMain.handle('create-merge-group', (event, itemIds: string[]) => { - if (!isTrustedRendererEvent(event) || !Array.isArray(itemIds)) return downloadQueue; + if (!isTrustedRendererEvent(event) || appShutdownStarted || !Array.isArray(itemIds)) return downloadQueue; const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id)); if (selectedItems.length < 2) { @@ -7871,6 +8009,7 @@ ipcMain.handle('create-merge-group', (event, itemIds: string[]) => { // Create merged queue item const mergedItem: QueueItem = { id: generateQueueItemId(), + createdAt: new Date().toISOString(), title, url: first.url, date: first.date, @@ -7895,8 +8034,11 @@ ipcMain.handle('create-merge-group', (event, itemIds: string[]) => { ipcMain.handle('start-download', async (event, manualOverride: unknown = false) => { if (!isTrustedRendererEvent(event)) return false; if (isDownloading && queuePaused) { - const nextQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'downloading' as const } : item); - downloadQueue = persistStateChange(downloadQueue, () => nextQueue, saveQueue); + const nextQueue = downloadQueue.map((item) => item.status === 'paused' + ? clearQueueTransferState(item, 'downloading', item.progress) + : item); + saveQueue(nextQueue); + downloadQueue = applyQueueSnapshotPreservingActiveItems(downloadQueue, nextQueue, new Set(queueProcessRegistry.activeItemIds())); queuePaused = false; await Promise.all(queueProcessRegistry.activeItemIds().map((id) => queueProcessRegistry.resumeItem(id))); emitQueueUpdated(true); @@ -7904,7 +8046,9 @@ ipcMain.handle('start-download', async (event, manualOverride: unknown = false) return true; } - const nextQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' as const } : item); + const nextQueue = downloadQueue.map((item) => item.status === 'paused' + ? clearQueueTransferState(item, 'pending', 0) + : item); const hasPendingItems = nextQueue.some(item => item.status === 'pending'); if (!hasPendingItems) { @@ -7925,19 +8069,24 @@ ipcMain.handle('pause-download', async (event) => { if (!isTrustedRendererEvent(event)) return false; if (!isDownloading || queuePaused) return false; - const nextQueue = downloadQueue.map((item) => item.status === 'downloading' ? { - ...item, - status: 'paused' as const, - speed: '', - eta: '', - progressStatus: tBackend('downloadPaused') - } : item); + const nextQueue = downloadQueue.map((item) => { + if (item.status !== 'downloading') return item; + const waitsForBoundary = Boolean(item.mergeGroup && (item.mergeGroup.mergePhase === 'merging' || item.mergeGroup.mergePhase === 'splitting')); + return { + ...item, + status: waitsForBoundary ? 'downloading' as const : 'paused' as const, + speed: '', + eta: '', + progressStatus: waitsForBoundary ? tBackend('downloadPausePending') : tBackend('downloadPaused'), + recordingHealth: undefined, + }; + }); await commitQueueMutation( downloadQueue, () => nextQueue, saveQueue, (candidate) => { - downloadQueue = candidate; + downloadQueue = applyQueueSnapshotPreservingActiveItems(downloadQueue, candidate, new Set(queueProcessRegistry.activeItemIds())); queuePaused = true; }, async () => { @@ -8060,21 +8209,18 @@ ipcMain.handle('open-folder', async (event, capability: string) => { if (folderPath) await shell.openPath(folderPath); }); -// Extensions that shell.openPath would happily execute via the system -// default. Calc.exe via XSS smuggling is the canonical example; this -// list blocks the obvious vectors. Media/text/image extensions are -// still fine — shell.openPath opens them in the OS's default viewer. -const OPEN_FILE_BLOCKED_EXTENSIONS = new Set([ - '.exe', '.bat', '.cmd', '.com', '.ps1', '.vbs', '.vbe', - '.js', '.jse', '.wsf', '.wsh', '.scr', '.msi', '.msp', - '.lnk', '.cpl', '.reg', '.hta', '.jar', '.application' +const SAFE_ARCHIVE_OPEN_EXTENSIONS = new Set([ + '.mp4', '.m4v', '.mov', '.webm', '.mkv', '.ts', '.avi', + '.aac', '.m4a', '.mp3', '.wav', '.ogg', + '.json', '.jsonl', '.txt', '.srt', '.vtt', + '.jpg', '.jpeg', '.png', '.webp' ]); ipcMain.handle('open-file', async (event, capability: string): Promise => { const filePath = resolveFileCapability(event, capability, 'open-file', true); if (!filePath) return false; const ext = path.extname(filePath).toLowerCase(); - if (OPEN_FILE_BLOCKED_EXTENSIONS.has(ext)) { + if (!SAFE_ARCHIVE_OPEN_EXTENSIONS.has(ext)) { appendDebugLog('open-file-rejected-extension', { ext, path: filePath.slice(0, 200) }); return false; } @@ -8159,9 +8305,18 @@ interface ActiveClipDownloadTracking { output: PausableOutput; partialFilename: string; } -const activeClipProcesses = new Map(); +const activeClipProcesses = new Set(); registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () => Promise.resolve({ success: false, error: 'File access denied' }), async (_, clipUrl: string) => { + if (appShutdownStarted) return { success: false, error: 'shutting-down' }; + const policyDecision = decideStandaloneDownloadStart(config.download_policy, new Date()); + if (!policyDecision.allowed) { + const nextStart = policyDecision.nextStart + ? `${policyDecision.nextStart.getHours().toString().padStart(2, '0')}:${policyDecision.nextStart.getMinutes().toString().padStart(2, '0')}` + : '--:--'; + return { success: false, error: tBackend('downloadOutsideWindow', { nextStart }) }; + } + let clipId = ''; const match1 = clipUrl.match(/clips\.twitch\.tv\/([A-Za-z0-9_-]+)/); const match2 = clipUrl.match(/twitch\.tv\/[^/]+\/clip\/([A-Za-z0-9_-]+)/); @@ -8171,6 +8326,7 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = else return { success: false, error: tBackend('invalidClipUrl') }; const clipInfo = await getClipInfo(clipId); + if (appShutdownStarted) return { success: false, error: 'shutting-down' }; if (!clipInfo) return { success: false, error: tBackend('clipNotFound') }; // Sanitize broadcaster_name for path safety — Twitch returns the display @@ -8198,6 +8354,13 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = return new Promise<{ success: boolean; error?: string; filename?: string }>((resolve) => { const streamlinkCmd = getStreamlinkCommand(); const partialFilename = partialDownloadRegistry.begin(filename); + if (appShutdownStarted) { + partialDownloadRegistry.discard(partialFilename); + releaseClaimedFilenamesForItem(clipId); + resolve({ success: false, error: 'shutting-down' }); + return; + } + recordManagedToolExecution('streamlink', streamlinkCmd.command); const proc = spawn(streamlinkCmd.command, [ ...streamlinkCmd.prefixArgs, `https://clips.twitch.tv/${clipId}`, @@ -8216,19 +8379,33 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = createDownloadThrottleTransform(), ); const outputFinished = output.finished.then(() => null, (error) => error); + const tracking = { process: proc, output, partialFilename }; + let settled = false; + const finish = (result: { success: boolean; error?: string; filename?: string }): void => { + if (settled) return; + settled = true; + activeClipProcesses.delete(tracking); + releaseClaimedFilenamesForItem(clipId); + resolve(result); + }; - activeClipProcesses.set(clipId, { process: proc, output, partialFilename }); + activeClipProcesses.add(tracking); appendDebugLog('clip-download-start', { clipId, broadcaster: safeBroadcaster, filename }); proc.on('close', async (code) => { - activeClipProcesses.delete(clipId); - releaseClaimedFilenamesForItem(clipId); const outputError = await outputFinished; + if (settled) return; + + if (appShutdownStarted) { + partialDownloadRegistry.discard(partialFilename); + finish({ success: false, error: 'shutting-down' }); + return; + } if (outputError || code !== 0 || !fs.existsSync(partialFilename)) { partialDownloadRegistry.discard(partialFilename); appendDebugLog('clip-download-failed', { clipId, code }); - resolve({ success: false, error: outputError ? String(outputError) : tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) }); + finish({ success: false, error: outputError ? String(outputError) : tBackend('downloadFailedExitCode', { code: String(code ?? -1) }) }); return; } @@ -8239,7 +8416,7 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = if (stats.size < 16 * 1024) { partialDownloadRegistry.discard(partialFilename); appendDebugLog('clip-download-too-small', { clipId, bytes: stats.size }); - resolve({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) }); + finish({ success: false, error: tBackend('clipFileTooSmall', { bytes: String(stats.size) }) }); return; } @@ -8247,7 +8424,7 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = if (!integrity.success) { partialDownloadRegistry.discard(partialFilename); appendDebugLog('clip-download-integrity-failed', { clipId, error: integrity.error }); - resolve({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') }); + finish({ success: false, error: integrity.error || tBackend('integrityFailedGeneric') }); return; } @@ -8255,19 +8432,18 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () = partialDownloadRegistry.commit(partialFilename, filename); } catch (error) { partialDownloadRegistry.discard(partialFilename); - resolve({ success: false, error: String(error) }); + finish({ success: false, error: String(error) }); return; } appendDebugLog('clip-download-success', { clipId, bytes: stats.size, filename }); - resolve({ success: true, filename }); + finish({ success: true, filename }); }); proc.on('error', async () => { await output.cancel(); + if (settled) return; partialDownloadRegistry.discard(partialFilename); - activeClipProcesses.delete(clipId); - releaseClaimedFilenamesForItem(clipId); - resolve({ success: false, error: tBackend('streamlinkNotFound') }); + finish({ success: false, error: tBackend('streamlinkNotFound') }); }); }); }); @@ -8281,6 +8457,11 @@ ipcMain.handle('get-managed-tool-status', async (event) => { return await getManagedToolStatuses(); }); +ipcMain.handle('get-managed-tool-execution-diagnostics', (event) => { + if (!isTrustedRendererEvent(event)) return null; + return managedToolExecutionTracker.snapshot(); +}); + ipcMain.handle('repair-managed-tools', async (event) => { if (!isTrustedRendererEvent(event)) return null; return await repairManagedTools(); @@ -8512,23 +8693,20 @@ ipcMain.handle('import-config', async (event) => { const importCapability = issueFileCapability(event, 'config-import', dialogResult.filePaths[0], 'input-file', ['json']); const importPath = resolveFileCapability(event, importCapability.token, 'config-import', true); if (!importPath) return { success: false, error: 'File access denied' }; + const importStats = fs.statSync(importPath); + if (!importStats.isFile() || importStats.size > MAX_CONFIG_IMPORT_BYTES) { + return { success: false, error: 'Imported file is too large or invalid.' }; + } const raw = fs.readFileSync(importPath, 'utf-8'); const parsed = JSON.parse(raw); if (!isPlainObject(parsed)) { return { success: false, error: 'Imported file is not a JSON object.' }; } - // Merge over current config so unknown / missing keys keep their - // existing values. Then run normalizeConfigTemplates so any - // out-of-range field falls back to defaults. - const imported = { ...parsed } as Record; - delete imported.client_secret; - delete imported.discord_webhook_url; - delete imported.__exportVersion; - delete imported.__exportedAt; - const merged = normalizeConfigTemplates({ ...config, ...imported } as Config); - - config = persistStateChange(config, () => merged, saveConfig); + const previousConfig = config; + const imported = secureImportedConfigTransition(previousConfig, sanitizeImportedConfig(parsed)); + const merged = normalizeConfigTemplates({ ...previousConfig, ...imported } as Config); + applyConfigTransition(previousConfig, merged); appendDebugLog('config-import-applied', { source: importPath }); return { success: true, filePath: importPath }; } catch (e) { @@ -8695,11 +8873,10 @@ ipcMain.handle('cut-video', async (event, inputCapability: string, startTime: nu const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(11, 19); const outputFile = path.join(dir, `${baseName}_cut_${timestamp}.mp4`); - let lastProgress = 0; - const success = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => { - lastProgress = percent; + const completed = await cutVideo(inputFile, outputFile, startTime, endTime, (percent) => { mainWindow?.webContents.send('cut-progress', percent); }); + const success = completed && !appShutdownStarted; return { success, outputName: success ? path.basename(outputFile) : null }; }); @@ -8714,9 +8891,12 @@ ipcMain.handle('merge-videos', async (event, inputCapabilities: string[], output if (!resolveFileCapability(event, capability, 'merge-input', true)) return { success: false, outputName: null }; } if (!resolveFileCapability(event, outputCapability, 'merge-output', true, inputFiles as string[])) return { success: false, outputName: null }; - const success = await publishCapabilityOutput(outputFile, async (partialFile) => await mergeVideos(inputFiles as string[], partialFile, (percent) => { - mainWindow?.webContents.send('merge-progress', percent); - })); + const success = await publishCapabilityOutput(outputFile, async (partialFile) => { + const produced = await mergeVideos(inputFiles as string[], partialFile, (percent) => { + mainWindow?.webContents.send('merge-progress', percent); + }); + return produced && !appShutdownStarted; + }); return { success, outputName: success ? path.basename(outputFile) : null }; }); @@ -8802,15 +8982,40 @@ app.whenReady().then(() => { requireEncryption: true, }); appendDebugLog('sqlite-migrator', result); - if (result.errors.length > 0) throw new Error(result.errors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; ')); + const fatalMigrationErrors = result.errors.filter((entry: { source: string }) => entry.source !== 'legacy-config-scrub'); + if (fatalMigrationErrors.length > 0) { + throw new Error(fatalMigrationErrors.map((entry: { source: string; message: string }) => `${entry.source}: ${entry.message}`).join('; ')); + } appStateStore = createAppStateStore(database); config = loadConfig(); lastPersistedConfig = cloneConfig(config); - downloadQueue = config.persist_queue_on_restart === false ? [] : loadQueue(); + const queueLoad = config.persist_queue_on_restart === false + ? { queue: [] as QueueItem[], interruptedMergeItemIds: new Set() } + : loadQueue(); + downloadQueue = queueLoad.queue; + for (const item of downloadQueue) { + if (item.mergeRecoveryBlocked) queueLoad.interruptedMergeItemIds.add(item.id); + } + const mergeRecovery = recoverInterruptedMergeArtifacts(downloadQueue, config.download_path, queueLoad.interruptedMergeItemIds); + if (mergeRecovery.changed) { + downloadQueue = mergeRecovery.queue; + for (const item of downloadQueue) { + if (item.mergeRecoveryBlocked) item.last_error = tBackend('mergeRecoveryBlocked'); + } + appStateStore.saveQueue(downloadQueue); + appendDebugLog('merge-recovery-reset', { + removedFiles: mergeRecovery.removedFiles.length, + failedFiles: mergeRecovery.failedFiles.length, + }); + } if (config.persist_queue_on_restart === false) appStateStore.saveQueue([]); lastPersistedQueueSnapshot = cloneQueue(downloadQueue); - twitchClientSecret = appSecretStore.get('twitch_client_secret') ?? ''; - discordWebhookUrl = appSecretStore.get('discord_webhook_url') ?? ''; + twitchClientSecret = readSecretSafely(appSecretStore, 'twitch_client_secret', (error) => { + appendDebugLog('secret-load-failed', { key: 'twitch_client_secret', error: String(error) }); + }); + discordWebhookUrl = readSecretSafely(appSecretStore, 'discord_webhook_url', (error) => { + appendDebugLog('secret-load-failed', { key: 'discord_webhook_url', error: String(error) }); + }); } catch (e) { appendDebugLog('sqlite-open-failed', { error: e instanceof Error ? e.message : String(e), @@ -8851,6 +9056,12 @@ let shutdownCleanupDone = false; let quitAfterCleanup = false; let shutdownPromise: Promise | null = null; +async function waitForAllChildProcessesExit(processes: ChildProcess[]): Promise { + const results = await Promise.allSettled(processes.map((process) => waitForChildProcessExit(process))); + const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failed) throw failed.reason; +} + async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Promise { if (shutdownCleanupDone) return; shutdownCleanupDone = true; @@ -8864,77 +9075,126 @@ async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Pro appendDebugLog('shutdown-cleanup', { reason }); - stopMetadataCacheCleanup(); - cleanupMetadataCaches('shutdown'); - stopAutoUpdatePolling(); - stopAutoRecordPoller(); - stopAutoVodPoller(); - stopLiveStatusPoller(); - stopAutoCleanupTimer(); - - // Kill all active children: queue downloads, standalone clip downloads, - // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to - // skip this entirely; window-all-closed did it but only via direct - // kill() (no try/catch around the queue process kill). - await queueRunLifecycle.shutdown( - () => { - isDownloading = false; - queuePaused = false; - for (const id of queueProcessRegistry.activeItemIds()) cancelledItemIds.add(id); - }, - () => { - saveConfig(config); - flushQueueSave(); - }, - ); - activeDownloads.clear(); - - await Promise.all([...activeClipProcesses.values()].map(async (tracking) => { - try { tracking.process.kill(); } catch { } - try { await tracking.output.cancel(); } catch { } - try { partialDownloadRegistry.discard(tracking.partialFilename); } catch { } - })); - activeClipProcesses.clear(); - - if (currentEditorProcess) { - const editorProcess = currentEditorProcess; - try { editorProcess.kill(); } catch { /* already exited */ } - await waitForChildProcessExit(editorProcess); - currentEditorProcess = null; - } - - if (cutterExportActive) cutterExportCancelled = true; + const cleanupError = (step: string, error: unknown): void => { + appendDebugLog('shutdown-step-failed', { step, error: String(error) }); + }; + const clipProcesses = [...activeClipProcesses]; + const editorProcesses = [...currentEditorProcesses]; const exportProcesses = [...currentCutterExportProcesses]; - for (const process of exportProcesses) { - try { process.kill(); } catch { } - } - await Promise.all(exportProcesses.map((process) => waitForChildProcessExit(process))); - if (currentCutterProcess && exportProcesses.includes(currentCutterProcess)) currentCutterProcess = null; const mediaProcesses = [...currentCutterMediaProcesses, ...currentCutterWaveformProcesses, ...currentCutterProbeProcesses, ...currentCutterInfoProcesses, ...currentCutterPreviewProcesses]; - cancelCutterMediaPreparation(); - cancelCutterWaveformPreparation(); - cancelCutterMetadataPreparation(); - cancelCutterPreviewPreparation(); - for (const process of currentCutterInfoProcesses) { - try { process.kill(); } catch { } - } - await Promise.all(mediaProcesses.map((process) => waitForChildProcessExit(process))); - removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null); - if (currentCutterPartialFile) { - try { fs.rmSync(currentCutterPartialFile, { force: true }); } catch { } - currentCutterPartialFile = null; - } + const frameProcesses = [...currentCutterFrameProcesses]; + const frameFiles = [...currentCutterFrameFiles]; + let exportProcessesExited = exportProcesses.length === 0; + let mediaProcessesExited = mediaProcesses.length === 0; + let frameProcessesExited = frameProcesses.length === 0; - // SQLite-Handle schliessen, falls geoeffnet — WAL-Checkpoint passiert beim - // close, sodass beim naechsten Start keine .wal/.shm orphans bleiben. - if (appDb) { - try { appDb.close(); } catch { /* already closed */ } - appDb = null; - } - - // Flush debug log AFTER persisting state so any errors saving config / - // queue land in the log before the timer is gone. - stopDebugLogFlushTimer(true); + await runResilientSteps([ + ['metadata-cache-timer', () => stopMetadataCacheCleanup()], + ['metadata-cache-files', () => cleanupMetadataCaches('shutdown')], + ['auto-update-poller', () => stopAutoUpdatePolling()], + ['auto-record-poller', () => stopAutoRecordPoller()], + ['auto-vod-poller', () => stopAutoVodPoller()], + ['live-status-poller', () => stopLiveStatusPoller()], + ['auto-cleanup-timer', () => stopAutoCleanupTimer()], + ['queue-lifecycle', async () => { + await queueRunLifecycle.shutdown( + () => { + isDownloading = false; + queuePaused = false; + for (const id of queueProcessRegistry.activeItemIds()) cancelledItemIds.add(id); + }, + () => runResilientSteps([ + ['persist-config', () => saveConfig(config)], + ['persist-queue', () => flushQueueSave()], + ], cleanupError), + (error) => cleanupError('queue-persist', error), + (error) => cleanupError('queue-run-exit', error), + ); + }], + ['queue-tracking', () => activeDownloads.clear()], + ['clip-processes', async () => { + const results = await Promise.allSettled(clipProcesses.map(async (tracking) => { + try { tracking.process.kill(); } catch { } + await waitForChildProcessExit(tracking.process); + await runResilientSteps([ + ['clip-output', () => tracking.output.cancel()], + ['clip-partial', () => partialDownloadRegistry.discard(tracking.partialFilename)], + ], cleanupError); + activeClipProcesses.delete(tracking); + })); + for (const result of results) { + if (result.status === 'rejected') cleanupError('clip-process-exit', result.reason); + } + }], + ['editor-processes', async () => { + for (const process of editorProcesses) { + try { process.kill(); } catch { } + } + await waitForAllChildProcessesExit(editorProcesses); + for (const process of editorProcesses) currentEditorProcesses.delete(process); + }], + ['cutter-export-cancel', () => { + if (cutterExportActive) cutterExportCancelled = true; + for (const process of exportProcesses) { + try { process.kill(); } catch { } + } + }], + ['cutter-export-wait', async () => { + await waitForAllChildProcessesExit(exportProcesses); + exportProcessesExited = true; + }], + ['cutter-export-release', () => { + if (!exportProcessesExited) return; + if (currentCutterProcess && exportProcesses.includes(currentCutterProcess)) currentCutterProcess = null; + }], + ['cutter-media-cancel', () => cancelCutterMediaPreparation()], + ['cutter-waveform-cancel', () => cancelCutterWaveformPreparation()], + ['cutter-metadata-cancel', () => cancelCutterMetadataPreparation()], + ['cutter-preview-cancel', () => cancelCutterPreviewPreparation()], + ['cutter-media-kill', () => { + for (const process of mediaProcesses) { + try { process.kill(); } catch { } + } + }], + ['cutter-media-wait', async () => { + await waitForAllChildProcessesExit(mediaProcesses); + mediaProcessesExited = true; + }], + ['cutter-frame-kill', () => { + for (const process of frameProcesses) { + try { process.kill(); } catch { } + } + }], + ['cutter-frame-wait', async () => { + await waitForAllChildProcessesExit(frameProcesses); + frameProcessesExited = true; + }], + ['cutter-frame-release', () => { + if (frameProcessesExited) currentCutterFrameProcesses.clear(); + }], + ['cutter-frame-files', async () => { + if (!frameProcessesExited) return; + await runResilientSteps(frameFiles.map((filePath, index) => [ + `cutter-frame-file-${index}`, + () => fs.rmSync(filePath, { force: true }), + ] as const), cleanupError); + currentCutterFrameFiles.clear(); + }], + ['cutter-preview-directory', () => { + if (mediaProcessesExited) removeCutterPreviewDirectory(cutterMediaJob?.previewDirectory || null); + }], + ['cutter-partial', () => { + if (!exportProcessesExited || !currentCutterPartialFile) return; + fs.rmSync(currentCutterPartialFile, { force: true }); + currentCutterPartialFile = null; + }], + ['database-close', () => { + const database = appDb; + appDb = null; + database?.close(); + }], + ['debug-log-flush', () => stopDebugLogFlushTimer(true)], + ], cleanupError); } app.on('window-all-closed', () => { diff --git a/src/main/cutter/index.ts b/src/main/cutter/index.ts new file mode 100644 index 0000000..4fac389 --- /dev/null +++ b/src/main/cutter/index.ts @@ -0,0 +1,13 @@ +export { addCutAt, createVideoEditorState, getPlayableSegments, setTrimRange } from '../domain/video-editor'; +export type { EditorCut } from '../domain/video-editor'; +export { + calculateCutterExportProgress, + createCutterExportPlan, + CUTTER_EXPORT_PROFILES, + getCutterExportProfile, + parseCutterHardwareEncoders, + probeCutterHardwareEncoders, +} from '../domain/cutter-export'; +export type { CutterExportEncoder, CutterExportProfile, CutterHardwareEncoder } from '../domain/cutter-export'; +export { createCutterProjectAutosaveStore } from '../domain/cutter-project'; +export type { CutterProject, CutterProjectSource } from '../domain/cutter-project'; diff --git a/src/main/dev-reload.test.ts b/src/main/dev-reload.test.ts index 08722ad..2833aec 100644 --- a/src/main/dev-reload.test.ts +++ b/src/main/dev-reload.test.ts @@ -5,13 +5,20 @@ describe('isRendererReloadTarget', () => { test('reloads renderer output and static renderer assets', () => { expect(isRendererReloadTarget('renderer.js')).toBe(true); expect(isRendererReloadTarget('renderer-settings.js')).toBe(true); + expect(isRendererReloadTarget('renderer.workspace.js')).toBe(true); expect(isRendererReloadTarget('index.html')).toBe(true); expect(isRendererReloadTarget('styles.css')).toBe(true); + expect(isRendererReloadTarget('styles-workflows.css')).toBe(true); + expect(isRendererReloadTarget('styles-overlays.css')).toBe(true); + expect(isRendererReloadTarget('workspace.css')).toBe(true); + expect(isRendererReloadTarget('workspace-refinements.css')).toBe(true); }); test('does not reload for main-process output', () => { expect(isRendererReloadTarget('main.js')).toBe(false); expect(isRendererReloadTarget('preload.js')).toBe(false); + expect(isRendererReloadTarget('rendererworker.js')).toBe(false); + expect(isRendererReloadTarget('renderer-.js')).toBe(false); expect(isRendererReloadTarget('main/domain/config.js')).toBe(false); }); }); diff --git a/src/main/dev-reload.ts b/src/main/dev-reload.ts index 67bd4c9..5361b73 100644 --- a/src/main/dev-reload.ts +++ b/src/main/dev-reload.ts @@ -1,11 +1,16 @@ import { watch, type FSWatcher } from 'node:fs'; -const staticRendererAssets = new Set(['index.html', 'styles.css', 'workspace.css']); +const staticRendererAssets = new Set(['index.html']); export function isRendererReloadTarget(fileName: string): boolean { const normalized = fileName.replaceAll('\\', '/'); const baseName = normalized.split('/').at(-1) ?? ''; - return staticRendererAssets.has(baseName) || /^renderer(?:[-.].+)?\.js$/.test(baseName); + const rendererSuffix = baseName.slice('renderer'.length, -'.js'.length); + const isRendererScript = baseName === 'renderer.js' + || (baseName.endsWith('.js') + && rendererSuffix.length > 1 + && (rendererSuffix.startsWith('-') || rendererSuffix.startsWith('.'))); + return staticRendererAssets.has(baseName) || baseName.endsWith('.css') || isRendererScript; } export function watchRendererChanges( diff --git a/src/main/domain/app-state-store.test.ts b/src/main/domain/app-state-store.test.ts index 40e730e..fc9123a 100644 --- a/src/main/domain/app-state-store.test.ts +++ b/src/main/domain/app-state-store.test.ts @@ -65,4 +65,42 @@ describe('createAppStateStore', () => { { id: 'q1', queue_position: 1 }, ]); }); + + it('persists only known valid config fields and normalizes streamer logins', () => { + const store = createAppStateStore(db); + store.saveConfig({ + language: 'de', + theme: 'twitch', + streamers: [' Alice ', '@ALICE', 'bad/name', 42], + auto_record_streamers: [' Bob ', 'bad login'], + accessToken: 'camel-access-token', + refresh_token: 'snake-refresh-token', + clientSecret: 'camel-client-secret', + unknown_setting: 'must-not-persist', + parallel_downloads: 99, + }); + + const recovered = store.loadConfig(); + const persisted = JSON.stringify(db.all('SELECT key, value FROM config_kv')); + + expect(recovered).toEqual({ + language: 'de', + theme: 'twitch', + streamers: ['alice'], + auto_record_streamers: ['bob'], + }); + for (const forbidden of ['camel-access-token', 'snake-refresh-token', 'camel-client-secret', 'must-not-persist', 'unknown_setting', 'parallel_downloads']) { + expect(persisted).not.toContain(forbidden); + } + }); + + it('sanitizes and scrubs pre-existing config rows when loading', () => { + db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['language', JSON.stringify('de')]); + db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['accessToken', JSON.stringify('legacy-token')]); + db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['parallel_downloads', JSON.stringify(99)]); + db.run("INSERT INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))", ['unknown_setting', JSON.stringify(true)]); + + expect(createAppStateStore(db).loadConfig()).toEqual({ language: 'de' }); + expect(db.all<{ key: string }>('SELECT key FROM config_kv ORDER BY key')).toEqual([{ key: 'language' }]); + }); }); diff --git a/src/main/domain/app-state-store.ts b/src/main/domain/app-state-store.ts index f3faa26..b60c44e 100644 --- a/src/main/domain/app-state-store.ts +++ b/src/main/domain/app-state-store.ts @@ -1,5 +1,6 @@ import type { DbHandle } from '../infra/db'; import { normalizeLogin } from './config-normalize'; +import { sanitizeConfigInput } from './config-input'; export interface AppStateStore { loadConfig(): Record; @@ -8,67 +9,52 @@ export interface AppStateStore { saveQueue(queue: T[]): void; } -const SECRET_CONFIG_KEYS = new Set(['client_secret', 'discord_webhook_url']); - -function normalizedLogins(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return [...new Set(value - .filter((entry): entry is string => typeof entry === 'string') - .map(normalizeLogin) - .filter(Boolean))]; -} - -function stringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))]; -} - function normalizeConfig(config: object): Record { - const source = config as Record; - const normalized = Object.fromEntries( - Object.entries(source).filter(([key]) => !SECRET_CONFIG_KEYS.has(key)) - ); - normalized.downloaded_vod_ids = stringArray(source.downloaded_vod_ids); - normalized.auto_record_streamers = normalizedLogins(source.auto_record_streamers); - normalized.auto_vod_download_streamers = normalizedLogins(source.auto_vod_download_streamers); - return normalized; + return sanitizeConfigInput(config); +} + +function replaceConfig(db: DbHandle, normalized: Record): void { + db.transaction(() => { + db.run('DELETE FROM config_kv'); + for (const [key, value] of Object.entries(normalized)) { + db.run( + `INSERT INTO config_kv(key, value, updated_at) + VALUES (?, ?, strftime('%s','now'))`, + [key, JSON.stringify(value)] + ); + } + db.run('DELETE FROM downloaded_vods'); + for (const vodId of (normalized.downloaded_vod_ids as string[] | undefined) ?? []) { + db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]); + } + db.run('DELETE FROM streamers'); + for (const login of (normalized.auto_record_streamers as string[] | undefined) ?? []) { + db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]); + } + for (const login of (normalized.auto_vod_download_streamers as string[] | undefined) ?? []) { + db.run( + `INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) + ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`, + [login] + ); + } + }); } export function createAppStateStore(db: DbHandle): AppStateStore { return { loadConfig() { - return Object.fromEntries( + const stored = Object.fromEntries( db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv') .map((row) => [row.key, JSON.parse(row.value)]) ); + const normalized = normalizeConfig(stored); + if (JSON.stringify(stored) !== JSON.stringify(normalized)) replaceConfig(db, normalized); + return normalized; }, saveConfig(config) { const normalized = normalizeConfig(config); - db.transaction(() => { - db.run('DELETE FROM config_kv'); - for (const [key, value] of Object.entries(normalized)) { - db.run( - `INSERT INTO config_kv(key, value, updated_at) - VALUES (?, ?, strftime('%s','now'))`, - [key, JSON.stringify(value)] - ); - } - db.run('DELETE FROM downloaded_vods'); - for (const vodId of normalized.downloaded_vod_ids as string[]) { - db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]); - } - db.run('DELETE FROM streamers'); - for (const login of normalized.auto_record_streamers as string[]) { - db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]); - } - for (const login of normalized.auto_vod_download_streamers as string[]) { - db.run( - `INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) - ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`, - [login] - ); - } - }); + replaceConfig(db, normalized); }, loadQueue() { return db.all<{ payload_json: string }>( diff --git a/src/main/domain/config-export.test.ts b/src/main/domain/config-export.test.ts index 92bd83d..fa718e4 100644 --- a/src/main/domain/config-export.test.ts +++ b/src/main/domain/config-export.test.ts @@ -21,4 +21,25 @@ describe('createExportableConfig', () => { expect(serialized).not.toContain(forbidden); } }); + + it('removes camelCase and separator variants of secret fields', () => { + const exported = createExportableConfig({ + accessToken: 'camel-access', + refreshToken: 'camel-refresh', + clientSecret: 'camel-secret', + 'client-secret': 'dash-secret', + 'AUTH TOKEN': 'spaced-token', + discordWebhookUrl: 'https://discord.com/api/webhooks/camel', + nested: { + AuthorizationHeader: 'Bearer nested-token', + safeValue: 'keep-me', + }, + }); + const serialized = JSON.stringify(exported); + + expect(exported).toMatchObject({ nested: { safeValue: 'keep-me' } }); + for (const forbidden of ['camel-access', 'camel-refresh', 'camel-secret', 'dash-secret', 'spaced-token', 'nested-token', '/webhooks/camel']) { + expect(serialized).not.toContain(forbidden); + } + }); }); diff --git a/src/main/domain/config-export.ts b/src/main/domain/config-export.ts index d108eec..afbe239 100644 --- a/src/main/domain/config-export.ts +++ b/src/main/domain/config-export.ts @@ -1,11 +1,16 @@ -const SECRET_KEYS = /(^|_)(authorization|cookie|password|secret|token)($|_)/i; +const SECRET_TERMS = ['authorization', 'cookie', 'password', 'secret', 'token']; + +export function isSecretBearingKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase(); + return SECRET_TERMS.some((term) => normalized.includes(term)) || normalized === 'discordwebhookurl'; +} function redact(value: unknown): unknown { if (Array.isArray(value)) return value.map(redact); if (!value || typeof value !== 'object') return value; const result: Record = {}; for (const [key, entry] of Object.entries(value)) { - if (SECRET_KEYS.test(key) || key.toLowerCase() === 'discord_webhook_url') continue; + if (isSecretBearingKey(key)) continue; result[key] = redact(entry); } return result; diff --git a/src/main/domain/config-import.production-path.test.ts b/src/main/domain/config-import.production-path.test.ts new file mode 100644 index 0000000..4dd17ce --- /dev/null +++ b/src/main/domain/config-import.production-path.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('config import production path', () => { + it('uses the import sanitizer and the same runtime transition as normal saves', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const start = source.indexOf("ipcMain.handle('import-config'"); + const end = source.indexOf('function isTrustedRendererEvent', start); + const handler = source.slice(start, end); + + expect(handler).toContain('sanitizeImportedConfig(parsed)'); + expect(handler).toContain('applyConfigTransition(previousConfig, merged)'); + expect(handler).not.toContain('sanitizeConfigInput(parsed)'); + }); +}); diff --git a/src/main/domain/config-input.test.ts b/src/main/domain/config-input.test.ts new file mode 100644 index 0000000..f9dfc8f --- /dev/null +++ b/src/main/domain/config-input.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeConfigInput, sanitizeImportedConfig } from './config-input'; + +describe('sanitizeConfigInput', () => { + it('keeps valid import fields while dropping unknown, invalid, and secret fields', () => { + expect(sanitizeConfigInput({ + language: 'en', + theme: 'system', + download_mode: 'parts', + part_minutes: 60, + parallel_downloads: 2, + streamers: [' Alice ', '@alice', 'bob_1', 'bad/name', '', 42], + download_policy: { + throttle: { maxBytesPerSecond: 500_000 }, + windows: [{ start: '22:00', end: '06:00' }], + injected: true, + }, + clientSecret: 'secret', + refresh_token: 'refresh', + unknown_setting: true, + })).toEqual({ + language: 'en', + theme: 'system', + download_mode: 'parts', + part_minutes: 60, + parallel_downloads: 2, + streamers: ['alice', 'bob_1'], + download_policy: { + throttle: { maxBytesPerSecond: 500_000 }, + windows: [{ start: '22:00', end: '06:00' }], + }, + }); + }); + + it('omits malformed known fields instead of replacing current settings with coerced values', () => { + expect(sanitizeConfigInput({ + language: 'fr', + theme: 'neon', + download_mode: 'archive', + part_minutes: '60', + parallel_downloads: 3, + streamers: 'alice', + streamer_display_names: { alice: ' Alice ', 'bad/name': 'Bad', bob: 42 }, + })).toEqual({ + streamer_display_names: { alice: 'Alice' }, + }); + }); + + it('normalizes bounded strings and rejects oversized or malformed persisted values', () => { + expect(sanitizeConfigInput({ + client_id: ' abc_123 ', + download_path: `C:\\${'a'.repeat(32767)}`, + filename_template_vod: '{title}.mp4', + filename_template_parts: 'x'.repeat(4097), + downloaded_vod_ids: ['123', 'valid-id', 'bad/id', '', 'x'.repeat(129)], + })).toEqual({ + client_id: 'abc_123', + filename_template_vod: '{title}.mp4', + downloaded_vod_ids: ['123', 'valid-id'], + }); + }); + + it('keeps the globally newest downloaded VOD ids when the history exceeds its limit', () => { + const downloadedVodIds = Array.from({ length: 9000 }, (_, index) => `vod-${index}`); + + const sanitized = sanitizeConfigInput({ downloaded_vod_ids: downloadedVodIds }); + + expect(sanitized.downloaded_vod_ids).toEqual(downloadedVodIds.slice(4904)); + }); + + it('omits malformed policies but accepts an explicit unrestricted reset', () => { + expect(sanitizeConfigInput({ + download_policy: { windows: 'invalid', throttle: null }, + })).toEqual({}); + expect(sanitizeConfigInput({ + download_policy: { throttle: null, windows: [] }, + })).toEqual({ + download_policy: { throttle: null, windows: [] }, + }); + }); + + it('never imports a download path without a separately granted folder capability', () => { + expect(sanitizeImportedConfig({ + download_path: 'C:\\', + language: 'de', + })).toEqual({ language: 'de' }); + }); +}); diff --git a/src/main/domain/config-input.ts b/src/main/domain/config-input.ts new file mode 100644 index 0000000..ad91493 --- /dev/null +++ b/src/main/domain/config-input.ts @@ -0,0 +1,224 @@ +import { + normalizeDownloadPolicy, +} from './download-policy'; +import { + isPlainObject, + normalizeLogin, + VALID_STREAMLINK_QUALITIES, +} from './config-normalize'; +import { isSecretBearingKey } from './config-export'; + +const TEMPLATE_KEYS = new Set(['filename_template_vod', 'filename_template_parts', 'filename_template_clip']); +const MAX_STREAMER_ENTRIES = 4096; +const MAX_TEMPLATE_LENGTH = 4096; +const MAX_WINDOWS_PATH_LENGTH = 32767; + +const BOOLEAN_KEYS = new Set([ + 'sidebar_split_view', + 'smart_queue_scheduler', + 'prevent_duplicate_downloads', + 'persist_queue_on_restart', + 'auto_resume_queue_on_startup', + 'notify_on_each_completion', + 'streamlink_disable_ads', + 'download_chat_replay', + 'capture_live_chat', + 'discord_notify_live_start', + 'discord_notify_live_end', + 'discord_notify_vod_complete', + 'discord_notify_vod_auto_queued', + 'auto_cleanup_enabled', + 'log_stream_events', + 'auto_resume_live_recording', + 'auto_merge_resumed_parts', + 'delete_parts_after_merge', +]); + +const INTEGER_RANGES: Record = { + part_minutes: [10, 480], + metadata_cache_minutes: [1, 120], + parallel_downloads: [1, 2], + auto_record_poll_seconds: [30, 1800], + auto_cleanup_days: [1, 3650], + auto_vod_download_poll_minutes: [5, 360], + auto_vod_max_age_hours: [1, 720], +}; + +export function normalizeStreamerLogins(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const streamers: string[] = []; + const seen = new Set(); + for (const entry of value.slice(0, MAX_STREAMER_ENTRIES)) { + if (typeof entry !== 'string') continue; + const login = normalizeLogin(entry); + if (!/^[a-z0-9_]{1,25}$/.test(login) || seen.has(login)) continue; + seen.add(login); + streamers.push(login); + } + return streamers; +} + +function normalizedStringArray(value: unknown, limit: number, isValid: (value: string) => boolean): string[] | null { + if (!Array.isArray(value)) return null; + const values: string[] = []; + const seen = new Set(); + for (let index = value.length - 1; index >= 0 && values.length < limit; index -= 1) { + const entry = value[index]; + if (typeof entry !== 'string') continue; + const normalized = entry.trim(); + if (!isValid(normalized) || seen.has(normalized)) continue; + seen.add(normalized); + values.push(normalized); + } + return values.reverse(); +} + +function normalizedDisplayNames(value: unknown): Record | null { + if (!isPlainObject(value)) return null; + const names: Record = {}; + for (const [rawLogin, rawDisplayName] of Object.entries(value).slice(0, MAX_STREAMER_ENTRIES)) { + const login = normalizeLogin(rawLogin); + const displayName = typeof rawDisplayName === 'string' ? rawDisplayName.trim() : ''; + if (/^[a-z0-9_]{1,25}$/.test(login) && displayName && displayName.length <= 100) names[login] = displayName; + } + return names; +} + +function normalizedEnum(value: unknown, allowed: readonly string[]): string | null { + return typeof value === 'string' && allowed.includes(value) ? value : null; +} + +function normalizedDownloadPolicy(value: unknown): ReturnType | null { + if (!isPlainObject(value) + || !Object.prototype.hasOwnProperty.call(value, 'throttle') + || !Object.prototype.hasOwnProperty.call(value, 'windows') + || !Array.isArray(value.windows) + || value.windows.length > 32) return null; + if (value.throttle !== null) { + if (!isPlainObject(value.throttle) + || typeof value.throttle.maxBytesPerSecond !== 'number' + || !Number.isSafeInteger(value.throttle.maxBytesPerSecond) + || value.throttle.maxBytesPerSecond <= 0) return null; + } + for (const window of value.windows) { + if (!isPlainObject(window) + || typeof window.start !== 'string' + || typeof window.end !== 'string' + || !/^\d{2}:\d{2}$/.test(window.start) + || !/^\d{2}:\d{2}$/.test(window.end)) return null; + const normalized = normalizeDownloadPolicy({ throttle: null, windows: [window] }); + if (normalized.windows.length !== 1) return null; + } + return normalizeDownloadPolicy(value); +} + +export function sanitizeConfigInput(value: unknown): Record { + if (!isPlainObject(value)) return {}; + const sanitized: Record = {}; + + for (const [key, entry] of Object.entries(value)) { + if (isSecretBearingKey(key)) continue; + + if (key === 'client_id') { + if (typeof entry === 'string') { + const clientId = entry.trim(); + if (clientId === '' || /^[A-Za-z0-9_-]{1,128}$/.test(clientId)) sanitized[key] = clientId; + } + continue; + } + + if (key === 'download_path') { + if (typeof entry === 'string' && entry.length > 0 && entry.length <= MAX_WINDOWS_PATH_LENGTH && !entry.includes('\0')) sanitized[key] = entry; + continue; + } + + if (TEMPLATE_KEYS.has(key)) { + if (typeof entry === 'string' && entry.trim().length > 0 && entry.length <= MAX_TEMPLATE_LENGTH) sanitized[key] = entry; + continue; + } + + if (BOOLEAN_KEYS.has(key)) { + if (typeof entry === 'boolean') sanitized[key] = entry; + continue; + } + + const range = INTEGER_RANGES[key]; + if (range) { + if (typeof entry === 'number' && Number.isSafeInteger(entry) && entry >= range[0] && entry <= range[1]) sanitized[key] = entry; + continue; + } + + if (key === 'streamers' || key === 'auto_record_streamers' || key === 'auto_vod_download_streamers') { + const streamers = normalizeStreamerLogins(entry); + if (streamers) sanitized[key] = streamers; + continue; + } + + if (key === 'downloaded_vod_ids') { + const ids = normalizedStringArray(entry, 4096, (id) => /^[A-Za-z0-9_-]{1,128}$/.test(id)); + if (ids) sanitized[key] = ids; + continue; + } + + if (key === 'streamer_display_names') { + const displayNames = normalizedDisplayNames(entry); + if (displayNames) sanitized[key] = displayNames; + continue; + } + + if (key === 'download_policy') { + const policy = normalizedDownloadPolicy(entry); + if (policy) sanitized[key] = policy; + continue; + } + + if (key === 'theme') { + const theme = normalizedEnum(entry, ['twitch', 'discord', 'youtube', 'apple', 'light', 'system']); + if (theme) sanitized[key] = theme; + continue; + } + + if (key === 'download_mode') { + const mode = normalizedEnum(entry, ['parts', 'full']); + if (mode) sanitized[key] = mode; + continue; + } + + if (key === 'language') { + const language = normalizedEnum(entry, ['de', 'en']); + if (language) sanitized[key] = language; + continue; + } + + if (key === 'performance_mode') { + const performanceMode = normalizedEnum(entry, ['stability', 'balanced', 'speed']); + if (performanceMode) sanitized[key] = performanceMode; + continue; + } + + if (key === 'streamlink_quality') { + const quality = normalizedEnum(entry, VALID_STREAMLINK_QUALITIES); + if (quality) sanitized[key] = quality; + continue; + } + + if (key === 'auto_cleanup_target') { + const target = normalizedEnum(entry, ['live_only', 'all']); + if (target) sanitized[key] = target; + continue; + } + + if (key === 'auto_cleanup_action') { + const action = normalizedEnum(entry, ['delete', 'archive']); + if (action) sanitized[key] = action; + } + } + + return sanitized; +} + +export function sanitizeImportedConfig(value: unknown): Record { + const sanitized = sanitizeConfigInput(value); + delete sanitized.download_path; + return sanitized; +} diff --git a/src/main/domain/cutter-vfr.production-path.test.ts b/src/main/domain/cutter-vfr.production-path.test.ts new file mode 100644 index 0000000..fbca747 --- /dev/null +++ b/src/main/domain/cutter-vfr.production-path.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('VFR cutter production path', () => { + it('accepts VFR media preparation and keeps timestamp-based video and audio trimming', () => { + const mainSource = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const prepareStart = mainSource.indexOf('async function prepareVideoEditorMedia'); + const prepareEnd = mainSource.indexOf('async function prepareVideoEditorWaveform', prepareStart); + const prepare = mainSource.slice(prepareStart, prepareEnd); + const exportSource = readFileSync(join(process.cwd(), 'src', 'main', 'domain', 'cutter-export.ts'), 'utf8'); + + expect(prepare).not.toContain('info.variableFrameRate'); + expect(exportSource).toContain("`trim=start=${start}:end=${end}`"); + expect(exportSource).toContain("'setpts=PTS-STARTPTS'"); + expect(exportSource).toContain('atrim=start=${start}:end=${end},asetpts=PTS-STARTPTS'); + }); +}); diff --git a/src/main/domain/download-policy.test.ts b/src/main/domain/download-policy.test.ts index 5631b66..1628724 100644 --- a/src/main/domain/download-policy.test.ts +++ b/src/main/domain/download-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { decideDownloadStart, + decideStandaloneDownloadStart, isWithinLocalDownloadWindow, normalizeDownloadPolicy, } from './download-policy'; @@ -84,3 +85,22 @@ describe('manual download policy override', () => { }); }); }); + +describe('standalone download policy', () => { + test('blocks an immediate standalone clip outside the configured window and keeps the throttle decision', () => { + const decision = decideStandaloneDownloadStart( + normalizeDownloadPolicy({ + throttle: { maxBytesPerSecond: 256_000 }, + windows: [{ start: '22:00', end: '06:00' }], + }), + new Date(2026, 0, 13, 13, 0), + ); + + expect(decision).toEqual({ + allowed: false, + reason: 'outside-window', + maxBytesPerSecond: 256_000, + nextStart: new Date(2026, 0, 13, 22, 0), + }); + }); +}); diff --git a/src/main/domain/download-policy.ts b/src/main/domain/download-policy.ts index a66126f..0f16705 100644 --- a/src/main/domain/download-policy.ts +++ b/src/main/domain/download-policy.ts @@ -107,3 +107,7 @@ export function decideDownloadStart(policy: DownloadPolicy, now: Date, manualOve } return { allowed: false, reason: 'outside-window', maxBytesPerSecond, nextStart: nextWindowStart(now, parsedWindows) }; } + +export function decideStandaloneDownloadStart(policy: DownloadPolicy, now: Date): DownloadStartDecision { + return decideDownloadStart(policy, now, false); +} diff --git a/src/main/domain/external-error.test.ts b/src/main/domain/external-error.test.ts new file mode 100644 index 0000000..c492eb8 --- /dev/null +++ b/src/main/domain/external-error.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from 'vitest'; +import { projectExternalError, sanitizeLogDetails } from './external-error'; + +describe('projectExternalError', () => { + it('projects an Axios-shaped error without request config, response data, or credentials', () => { + const error = { + name: 'AxiosError', + isAxiosError: true, + message: 'Request failed: client_secret=oauth-secret Authorization: Bearer access-token', + code: 'ERR_BAD_REQUEST', + config: { + params: { client_secret: 'oauth-secret' }, + headers: { Authorization: 'Bearer access-token', Cookie: 'session-cookie' }, + }, + response: { + status: 401, + data: { refreshToken: 'refresh-token', html: 'provider response' }, + }, + }; + + const projected = projectExternalError('twitch-oauth', error); + const serialized = JSON.stringify(projected); + + expect(projected).toMatchObject({ provider: 'twitch-oauth', code: 'ERR_BAD_REQUEST', status: 401 }); + expect(projected.message).toContain('[REDACTED]'); + for (const forbidden of ['oauth-secret', 'access-token', 'session-cookie', 'refresh-token', 'config', 'headers', 'provider response']) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('redacts URL userinfo and complete Discord webhook URLs from projected messages', () => { + const authenticatedUrl = ['https://service-user', 'service-pass@example.test/private'].join(':'); + const webhookUrl = ['https://discord.com/api/webhooks', '123456789012345678', 'super-secret-webhook-token?wait=true'].join('/'); + const projected = projectExternalError('discord', new Error( + `POST ${authenticatedUrl} failed for ${webhookUrl}`, + )); + + expect(projected.message).toContain('example.test/private'); + expect(projected.message).not.toContain('service-user'); + expect(projected.message).not.toContain('service-pass'); + expect(projected.message).not.toContain('123456789012345678'); + expect(projected.message).not.toContain('super-secret-webhook-token'); + expect(projected.message).not.toContain('discord.com/api/webhooks'); + expect(projected.message).toContain('[REDACTED]'); + }); + + it('only retains recognized operational error-code shapes', () => { + const opaqueCredential = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyz'].join('_'); + const credentialShapedCode = ['ERR_AKIA', 'IOSFODNN7EXAMPLE'].join(''); + for (const code of ['AWS_SECRET_ACCESS_KEY', opaqueCredential, credentialShapedCode]) { + const projected = projectExternalError('external', { + name: 'AxiosError', + message: 'Request failed', + code, + }); + + expect(projected).toEqual({ provider: 'external', message: 'Request failed' }); + } + }); +}); + +describe('sanitizeLogDetails', () => { + it('redacts nested secret variants, sensitive URL parameters, and cyclic Axios errors', () => { + const axiosError: Record = { + name: 'AxiosError', + isAxiosError: true, + message: 'GET https://example.test/path?access_token=query-token failed', + config: { headers: { Authorization: 'Bearer header-token' } }, + response: { status: 503 }, + }; + axiosError.self = axiosError; + + const sanitized = sanitizeLogDetails({ + error: axiosError, + clientSecret: 'nested-secret', + safe: 'visible', + callbackUrl: 'https://example.test/callback?refresh_token=url-refresh&state=ok', + }); + const serialized = JSON.stringify(sanitized); + + expect(sanitized).toMatchObject({ safe: 'visible' }); + for (const forbidden of ['query-token', 'header-token', 'nested-secret', 'url-refresh', 'Authorization', 'clientSecret']) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('redacts secrets embedded as quoted JSON properties in error messages', () => { + const sanitized = sanitizeLogDetails('{"refreshToken":"json-refresh","Authorization":"Bearer json-access","safe":"visible"}'); + + expect(String(sanitized)).toContain('visible'); + expect(String(sanitized)).not.toContain('json-refresh'); + expect(String(sanitized)).not.toContain('json-access'); + }); + + it('redacts encoded URL userinfo and legacy Discord webhook hosts in log strings', () => { + const authenticatedUrl = ['https://encoded%2Duser', 'p%40ssword@example.test/path'].join(':'); + const webhookUrl = ['https://canary.discordapp.com/api/v10/webhooks', '987654321098765432', 'legacy-secret-token'].join('/'); + const sanitized = sanitizeLogDetails(`${authenticatedUrl} ${webhookUrl}`); + const serialized = JSON.stringify(sanitized); + + for (const forbidden of ['encoded%2Duser', 'p%40ssword', '987654321098765432', 'legacy-secret-token', 'discordapp.com/api/v10/webhooks']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('example.test/path'); + expect(serialized).toContain('[REDACTED]'); + }); + + it('redacts complete authorization and cookie header lines for every authentication scheme', () => { + const sanitized = sanitizeLogDetails([ + 'Authorization: Digest username="digest-user", nonce="digest-nonce", response="digest-response"', + 'Authorization: AWS4-HMAC-SHA256 Credential=aws-credential, SignedHeaders=host, Signature=aws-signature', + 'Authorization: Digest username="folded-user",', + ' nonce="folded-nonce", response="folded-response"', + '--multipart-boundary', + 'Cookie: session=browser-session; csrf=csrf-value', + 'X-Safe: visible', + ].join('\r\n')); + const serialized = JSON.stringify(sanitized); + + for (const forbidden of ['digest-user', 'digest-nonce', 'digest-response', 'aws-credential', 'aws-signature', 'folded-user', 'folded-nonce', 'folded-response', 'browser-session', 'csrf-value']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('multipart-boundary'); + expect(serialized).toContain('visible'); + }); + + it('redacts complete authorization and cookie values from equals, tuple, and name-value representations', () => { + const sanitized = sanitizeLogDetails({ + text: [ + 'Authorization=Digest username="equals-user", nonce="equals-nonce", response="equals-response"', + 'Cookie=session=equals-session; csrf=equals-csrf', + ].join('\n'), + tuples: [ + ['Authorization', 'Digest username="tuple-user", nonce="tuple-nonce", response="tuple-response"'], + ['Cookie', 'session=tuple-session; csrf=tuple-csrf'], + ], + header: { + name: 'Authorization', + value: 'AWS4-HMAC-SHA256 Credential=object-credential, SignedHeaders=host, Signature=object-signature', + }, + safe: 'visible', + }); + const serialized = JSON.stringify(sanitized); + + for (const forbidden of ['equals-user', 'equals-nonce', 'equals-response', 'equals-session', 'equals-csrf', 'tuple-user', 'tuple-nonce', 'tuple-response', 'tuple-session', 'tuple-csrf', 'object-credential', 'object-signature']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('visible'); + }); + + it('redacts percent-encoded and escaped authenticated URLs and encoded Discord webhook paths', () => { + const encodedUrl = encodeURIComponent(['https://encoded-user', 'encoded-pass@example.test/encoded'].join(':')); + const escapedUrl = ['https:\\/\\/escaped-user', 'escaped-pass@example.test/escaped'].join(':'); + const webhookUrl = ['https://discord.com/api/%77ebhooks', '112233445566778899', 'encoded-webhook-secret'].join('/'); + const serialized = JSON.stringify(sanitizeLogDetails(`${encodedUrl} ${escapedUrl} ${webhookUrl}`)); + + for (const forbidden of ['encoded-user', 'encoded-pass', 'escaped-user', 'escaped-pass', '112233445566778899', 'encoded-webhook-secret']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('example.test/encoded'); + expect(serialized).toContain('example.test/escaped'); + }); + + it('redacts multiply encoded URLs, webhook paths, and unicode-escaped URL separators', () => { + const authenticatedUrl = ['https://multi-user', 'multi-pass@example.test/multi?apiKey=multi-query'].join(':'); + const doublyEncodedUrl = encodeURIComponent(encodeURIComponent(authenticatedUrl)); + const doublyEncodedWebhook = ['https://discord.com/api/%2577ebhooks', '998877665544332211', 'double-webhook-secret'].join('/'); + const unicodeEscapedUrl = ['https:', '\\u002f', '\\u002f', 'unicode-user', 'unicode-pass@example.test/unicode'].join('').replace('unicode-userunicode-pass', 'unicode-user:unicode-pass'); + const serialized = JSON.stringify(sanitizeLogDetails(`${doublyEncodedUrl} ${doublyEncodedWebhook} ${unicodeEscapedUrl}`)); + + for (const forbidden of ['multi-user', 'multi-pass', 'multi-query', '998877665544332211', 'double-webhook-secret', 'unicode-user', 'unicode-pass']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('example.test/multi'); + expect(serialized).toContain('example.test/unicode'); + }); + + it('removes normalized nested credential keys without stripping descriptive counters and consent fields', () => { + const sanitized = sanitizeLogDetails({ + nested: { + 'X-Api-Key': 'header-api-key', + apiKey: 'camel-api-key', + sessionId: 'private-session-id', + credentials: { username: 'private-user', password: 'private-password' }, + notasecret: 'preserve-not-a-secret', + tokenCount: 4, + cookieConsent: true, + }, + }); + const serialized = JSON.stringify(sanitized); + + for (const forbidden of ['header-api-key', 'camel-api-key', 'private-session-id', 'private-user', 'private-password']) { + expect(serialized).not.toContain(forbidden); + } + expect(sanitized).toMatchObject({ + nested: { + notasecret: 'preserve-not-a-secret', + tokenCount: 4, + cookieConsent: true, + }, + }); + }); + + it('removes percent-encoded nested credential keys', () => { + const sanitized = sanitizeLogDetails({ + 'X%2DApi%2DKey': 'encoded-api-key', + 'session%49d': 'encoded-session-id', + 'credent%69als': 'encoded-credentials', + safe: 'visible', + }); + const serialized = JSON.stringify(sanitized); + + for (const forbidden of ['encoded-api-key', 'encoded-session-id', 'encoded-credentials']) { + expect(serialized).not.toContain(forbidden); + } + expect(sanitized).toMatchObject({ safe: 'visible' }); + }); + + it('redacts percent-encoded credential keys in unstructured log text', () => { + const serialized = JSON.stringify(sanitizeLogDetails('X%2DApi%2DKey=encoded-text-key safe=visible')); + + expect(serialized).not.toContain('encoded-text-key'); + expect(serialized).toContain('visible'); + }); + + it('redacts complete values from escaped quoted JSON without leaking authentication suffixes', () => { + const rawJson = JSON.stringify({ + Authorization: 'Digest username="escaped-user", nonce="escaped-nonce", response="escaped-response"', + 'X-Api-Key': 'escaped-api-key', + safe: 'visible', + }); + const escapedJson = JSON.stringify(rawJson).slice(1, -1); + const serialized = JSON.stringify(sanitizeLogDetails(escapedJson)); + + for (const forbidden of ['escaped-user', 'escaped-nonce', 'escaped-response', 'escaped-api-key']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('visible'); + }); + + it('redacts direct serialized JSON values without leaking quoted authentication suffixes', () => { + const serializedInput = JSON.stringify({ + Authorization: 'Digest username="direct-user", nonce="direct-nonce", response="direct-response"', + safe: 'visible', + }); + const serialized = JSON.stringify(sanitizeLogDetails(serializedInput)); + + for (const forbidden of ['direct-user', 'direct-nonce', 'direct-response']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('visible'); + }); + + it('redacts embedded serialized JSON values inside surrounding diagnostic text', () => { + const embedded = JSON.stringify({ + Authorization: 'Digest username="embedded-user", nonce="embedded-nonce", response="embedded-response"', + safe: 'visible', + }); + const serialized = JSON.stringify(sanitizeLogDetails(`Provider failed with ${embedded} after retry`)); + + for (const forbidden of ['embedded-user', 'embedded-nonce', 'embedded-response']) { + expect(serialized).not.toContain(forbidden); + } + expect(serialized).toContain('visible'); + expect(serialized).toContain('after retry'); + }); + + it('preserves false-positive assignment names in unstructured log text', () => { + const sanitized = sanitizeLogDetails('notasecret=visible tokenCount=4 cookieConsent=true'); + + expect(sanitized).toBe('notasecret=visible tokenCount=4 cookieConsent=true'); + }); +}); diff --git a/src/main/domain/external-error.ts b/src/main/domain/external-error.ts new file mode 100644 index 0000000..92e2e9d --- /dev/null +++ b/src/main/domain/external-error.ts @@ -0,0 +1,289 @@ +export interface SafeExternalError { + provider: string; + message: string; + code?: string; + status?: number; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function decodeRepeatedURIComponent(value: string): string { + let decoded = value; + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + const next = decodeURIComponent(decoded); + if (next === decoded) break; + decoded = next; + } catch { + break; + } + } + return decoded; +} + +function keyWords(key: string): string[] { + return decodeRepeatedURIComponent(key) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +function isSensitiveLogKey(key: string): boolean { + const words = keyWords(key); + const compact = words.join(''); + if (compact === 'notasecret' || compact === 'tokencount' || compact === 'cookieconsent') return false; + if ( + compact === 'apikey' + || compact === 'xapikey' + || compact === 'sessionid' + || compact === 'discordwebhookurl' + || compact === 'authorizationheader' + || compact === 'cookieheader' + || compact === 'setcookie' + || compact === 'accesstoken' + || compact === 'refreshtoken' + || compact === 'clientsecret' + ) return true; + return words.some((word, index) => { + if (word === 'authorization' || word === 'password' || word === 'passwd' || word === 'secret' || word === 'credential' || word === 'credentials') return true; + if (word === 'token') return words[index + 1] !== 'count'; + if (word === 'cookie' || word === 'cookies') return words[index + 1] !== 'consent'; + if (word === 'api' && words[index + 1] === 'key') return true; + return word === 'session' && words[index + 1] === 'id'; + }); +} + +function decodeUrlSegment(value: string): string { + return decodeRepeatedURIComponent(value).toLowerCase(); +} + +function normalizeUrl(rawUrl: string): string { + return decodeRepeatedURIComponent(rawUrl); +} + +function redactExternalUrl(rawUrl: string): string { + try { + const parsed = new URL(normalizeUrl(rawUrl)); + const hostname = parsed.hostname.toLowerCase(); + const pathSegments = parsed.pathname.split('/').filter(Boolean).map(decodeUrlSegment); + const webhookSegment = /^v\d+$/.test(pathSegments[1] ?? '') ? 2 : 1; + const isDiscordWebhook = ( + hostname === 'discord.com' + || hostname === 'discordapp.com' + || hostname === 'canary.discord.com' + || hostname === 'canary.discordapp.com' + || hostname === 'ptb.discord.com' + || hostname === 'ptb.discordapp.com' + ) && pathSegments[0]?.toLowerCase() === 'api' + && pathSegments[webhookSegment]?.toLowerCase() === 'webhooks'; + if (isDiscordWebhook) return '[REDACTED]'; + for (const key of Array.from(parsed.searchParams.keys())) { + if (isSensitiveLogKey(key)) parsed.searchParams.set(key, '[REDACTED]'); + } + const safeUrl = parsed.toString(); + if (!parsed.username && !parsed.password) return safeUrl; + const authorityStart = safeUrl.indexOf('//') + 2; + const authorityEnd = ['/', '?', '#'] + .map((separator) => safeUrl.indexOf(separator, authorityStart)) + .filter((index) => index >= 0) + .reduce((minimum, index) => Math.min(minimum, index), safeUrl.length); + const userInfoEnd = safeUrl.lastIndexOf('@', authorityEnd); + if (userInfoEnd < authorityStart) return safeUrl; + return `${safeUrl.slice(0, authorityStart)}[REDACTED]@${safeUrl.slice(userInfoEnd + 1)}`; + } catch { + return rawUrl; + } +} + +function decodeEscapedSyntax(value: string): string { + let normalized = ''; + for (let index = 0; index < value.length; index += 1) { + const current = value[index]; + const next = value[index + 1]; + const unicodeValue = value.slice(index + 2, index + 6); + if (current === '\\' && next === 'u' && /^[0-9a-f]{4}$/i.test(unicodeValue)) { + normalized += String.fromCharCode(Number.parseInt(unicodeValue, 16)); + index += 5; + } else if (current === '\\' && (next === '\\' || next === '/')) { + normalized += next; + index += 1; + } else { + normalized += current; + } + } + return normalized; +} + +function findQuotedValueEnd(value: string, start: number, quote: string): number { + for (let index = start + 1; index < value.length; index += 1) { + if (value[index] !== quote) continue; + let backslashes = 0; + for (let cursor = index - 1; cursor >= start && value[cursor] === '\\'; cursor -= 1) backslashes += 1; + if (backslashes % 2 === 0) return index + 1; + } + return value.length; +} + +function findUnquotedValueEnd(value: string, start: number): number { + for (let index = start; index < value.length; index += 1) { + if (/\s/.test(value[index]) || value[index] === ',' || value[index] === '}') return index; + } + return value.length; +} + +function redactAssignments(value: string): string { + const assignment = /(? { + if (index % 2 === 1) return line; + const match = header.exec(line); + if (match) { + redactContinuation = true; + return `${line.slice(0, match.index)}${match[1]}[REDACTED]`; + } + if (redactContinuation && /^[ \t]+/.test(line)) return `${line.match(/^[ \t]+/)?.[0] ?? ''}[REDACTED]`; + redactContinuation = false; + return line; + }).join(''); +} + +const SAFE_EXTERNAL_ERROR_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'EAI_AGAIN', + 'ECONNABORTED', + 'ECONNREFUSED', + 'ECONNRESET', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', + 'EPIPE', + 'ERR_BAD_OPTION', + 'ERR_BAD_OPTION_VALUE', + 'ERR_BAD_REQUEST', + 'ERR_BAD_RESPONSE', + 'ERR_CANCELED', + 'ERR_DEPRECATED', + 'ERR_FR_TOO_MANY_REDIRECTS', + 'ERR_INVALID_URL', + 'ERR_NETWORK', + 'ERR_NOT_SUPPORT', + 'ETIMEDOUT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UND_ERR_BODY_TIMEOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', +]); + +function isSafeExternalErrorCode(value: string): boolean { + return SAFE_EXTERNAL_ERROR_CODES.has(value); +} + +function sanitizeSerializedText(value: string): string | null { + const candidates = [value]; + if (value.includes('\\"') || value.includes('\\/')) candidates.push(`"${value}"`); + for (const candidate of candidates) { + let current: unknown = candidate; + for (let layer = 0; layer < 3 && typeof current === 'string'; layer += 1) { + try { + current = JSON.parse(current) as unknown; + } catch { + break; + } + if (current !== null && typeof current === 'object') { + return JSON.stringify(sanitizeValue(current, new WeakSet(), 0)); + } + } + } + return null; +} + +export function redactSensitiveText(value: string): string { + const structured = sanitizeSerializedText(value); + if (structured !== null) return structured.slice(0, 1000); + const withRedactedUrls = decodeEscapedSyntax(value) + .replace(/\bhttps%(?:25){0,3}3a%(?:25){0,3}2f%(?:25){0,3}2f[^\s"'<>]+/gi, redactExternalUrl) + .replace(/\bhttps?:\/\/[^\s"'<>]+/gi, redactExternalUrl); + return redactAssignments(redactHeaderLines(withRedactedUrls)) + .replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]') + .slice(0, 1000); +} + +export function projectExternalError(provider: string, error: unknown): SafeExternalError { + const record = asRecord(error); + const response = asRecord(record?.response); + const rawMessage = error instanceof Error + ? error.message + : typeof record?.message === 'string' + ? record.message + : typeof error === 'string' + ? error + : 'External request failed'; + const projected: SafeExternalError = { + provider, + message: redactSensitiveText(rawMessage), + }; + if (typeof record?.code === 'string' && isSafeExternalErrorCode(record.code)) projected.code = record.code; + if (typeof response?.status === 'number' && Number.isInteger(response.status)) projected.status = response.status; + return projected; +} + +function isExternalErrorRecord(value: Record): boolean { + return value.isAxiosError === true || value.name === 'AxiosError' || value instanceof Error; +} + +function sanitizeValue(value: unknown, seen: WeakSet, depth: number): unknown { + if (typeof value === 'string') return redactSensitiveText(value); + if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) return value; + if (typeof value !== 'object') return String(value); + if (seen.has(value)) return '[Circular]'; + if (depth >= 6) return '[Truncated]'; + seen.add(value); + if (value instanceof Error) return projectExternalError('external', value); + if (Array.isArray(value)) { + const entries = value.slice(0, 100); + if (typeof entries[0] === 'string' && isSensitiveLogKey(entries[0])) { + return entries.map((entry, index) => index === 1 ? '[REDACTED]' : sanitizeValue(entry, seen, depth + 1)); + } + return entries.map((entry) => sanitizeValue(entry, seen, depth + 1)); + } + const record = value as Record; + if (isExternalErrorRecord(record)) return projectExternalError('external', record); + const redactedNamedValue = typeof record.name === 'string' && isSensitiveLogKey(record.name) && Object.hasOwn(record, 'value'); + const result: Record = {}; + for (const [key, entry] of Object.entries(record).slice(0, 100)) { + if (isSensitiveLogKey(key)) continue; + result[key] = redactedNamedValue && key === 'value' ? '[REDACTED]' : sanitizeValue(entry, seen, depth + 1); + } + return result; +} + +export function sanitizeLogDetails(value: unknown): unknown { + return sanitizeValue(value, new WeakSet(), 0); +} diff --git a/src/main/domain/i18n-backend.test.ts b/src/main/domain/i18n-backend.test.ts index f5a6f07..d48f140 100644 --- a/src/main/domain/i18n-backend.test.ts +++ b/src/main/domain/i18n-backend.test.ts @@ -41,6 +41,15 @@ describe('tBackend', () => { } }); + test('describes a blocked standalone download and a phase-boundary pause honestly', () => { + expect(tBackend('downloadOutsideWindow', { nextStart: '22:00' }, 'de')).toBe('Download außerhalb des Zeitfensters blockiert. Nächster Start: 22:00.'); + expect(tBackend('downloadOutsideWindow', { nextStart: '22:00' }, 'en')).toBe('Download blocked outside the configured window. Next start: 22:00.'); + expect(tBackend('downloadPausePending', undefined, 'de')).toBe('Pause nach dem aktuellen Schritt.'); + expect(tBackend('downloadPausePending', undefined, 'en')).toBe('Pausing after the current step.'); + expect(tBackend('mergeRecoveryBlocked', undefined, 'de')).toBe('Unterbrochene Merge-Dateien konnten nicht entfernt werden. Entferne den Queue-Eintrag manuell.'); + expect(tBackend('mergeRecoveryBlocked', undefined, 'en')).toBe('Interrupted merge files could not be removed. Remove the queue item manually.'); + }); + test('German backend messages use native umlauts', () => { const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE'); const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe ']; diff --git a/src/main/domain/i18n-backend.ts b/src/main/domain/i18n-backend.ts index e80eae7..f5fc85d 100644 --- a/src/main/domain/i18n-backend.ts +++ b/src/main/domain/i18n-backend.ts @@ -21,11 +21,14 @@ export const BACKEND_MESSAGES = { integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.', downloadCancelled: 'Download wurde abgebrochen.', downloadPaused: 'Download wurde pausiert.', + downloadPausePending: 'Pause nach dem aktuellen Schritt.', + downloadOutsideWindow: 'Download außerhalb des Zeitfensters blockiert. Nächster Start: {nextStart}.', downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})', unknownDownloadError: 'Unbekannter Fehler beim Download', notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.', notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.', mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.', + mergeRecoveryBlocked: 'Unterbrochene Merge-Dateien konnten nicht entfernt werden. Entferne den Queue-Eintrag manuell.', diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.', diskSpaceShortGeneric: 'Zu wenig Speicherplatz.', attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}', @@ -60,11 +63,14 @@ export const BACKEND_MESSAGES = { integrityFailedGeneric: 'Integrity check failed.', downloadCancelled: 'Download was cancelled.', downloadPaused: 'Download was paused.', + downloadPausePending: 'Pausing after the current step.', + downloadOutsideWindow: 'Download blocked outside the configured window. Next start: {nextStart}.', downloadFailedExitCode: 'Download failed (exit code {code})', unknownDownloadError: 'Unknown download error', notAllClipPartsDownloaded: 'Not all clip parts could be downloaded.', notAllPartsDownloaded: 'Not all parts could be downloaded.', mergeGroupFileMissing: 'Downloaded file {index} is missing.', + mergeRecoveryBlocked: 'Interrupted merge files could not be removed. Remove the queue item manually.', diskSpaceShortFor: 'Not enough disk space for {context}: free {free}, need ~{required}.', diskSpaceShortGeneric: 'Not enough disk space.', attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}', diff --git a/src/main/domain/last-good-cache.test.ts b/src/main/domain/last-good-cache.test.ts new file mode 100644 index 0000000..f0ad65b --- /dev/null +++ b/src/main/domain/last-good-cache.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { LastGoodCache } from './last-good-cache'; + +describe('LastGoodCache', () => { + it('retains the last successful value independently from expiring request caches', () => { + const cache = new LastGoodCache(2); + cache.set('a', [1]); + cache.set('a', []); + + expect(cache.get('a')).toEqual([]); + }); + + it('bounds retained values by least-recent insertion and supports authoritative deletion', () => { + const cache = new LastGoodCache(2); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + expect(cache.delete('b')).toBe(true); + expect(cache.get('b')).toBeUndefined(); + }); +}); diff --git a/src/main/domain/last-good-cache.ts b/src/main/domain/last-good-cache.ts new file mode 100644 index 0000000..35ed2ca --- /dev/null +++ b/src/main/domain/last-good-cache.ts @@ -0,0 +1,25 @@ +export class LastGoodCache { + private readonly values = new Map(); + + constructor(private readonly maxEntries: number) { + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new Error('maxEntries must be a positive integer'); + } + + get(key: string): T | undefined { + return this.values.get(key); + } + + set(key: string, value: T): void { + this.values.delete(key); + this.values.set(key, value); + while (this.values.size > this.maxEntries) { + const oldest = this.values.keys().next().value as string | undefined; + if (!oldest) break; + this.values.delete(oldest); + } + } + + delete(key: string): boolean { + return this.values.delete(key); + } +} diff --git a/src/main/domain/merge-recovery.test.ts b/src/main/domain/merge-recovery.test.ts new file mode 100644 index 0000000..8931244 --- /dev/null +++ b/src/main/domain/merge-recovery.test.ts @@ -0,0 +1,177 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { QueueItem } from '../../types'; +import { getInterruptedMergeItemIds, recoverInterruptedMergeArtifacts } from './merge-recovery'; + +let directory: string; + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-merge-recovery-')); +}); + +afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); +}); + +function queueItem(overrides: Partial = {}): QueueItem { + return { + id: 'merge-1', + title: 'Merge', + url: 'https://www.twitch.tv/videos/1', + date: '2026-08-13T00:00:00.000Z', + streamer: 'alice', + duration_str: '2h', + status: 'downloading', + progress: 84, + mergeGroup: { + items: [ + { url: 'https://www.twitch.tv/videos/1', title: 'A', date: '2026-08-13T00:00:00.000Z', streamer: 'alice', duration_str: '1h' }, + { url: 'https://www.twitch.tv/videos/2', title: 'B', date: '2026-08-13T00:00:00.000Z', streamer: 'alice', duration_str: '1h' }, + ], + mergePhase: 'merging', + currentItemIndex: 1, + downloadedFiles: {}, + }, + ...overrides, + }; +} + +describe('recoverInterruptedMergeArtifacts', () => { + it('removes internal crash artifacts and resets an unfinished merge from the beginning', () => { + const jobDirectory = path.join(directory, 'alice'); + fs.mkdirSync(jobDirectory); + const first = path.join(jobDirectory, 'merge_tmp_0_100.mp4'); + const second = path.join(jobDirectory, 'merge_tmp_1_200.mp4'); + const merged = path.join(jobDirectory, '.merge_output_300_123.mp4'); + fs.writeFileSync(first, 'partial-a'); + fs.writeFileSync(second, 'partial-b'); + fs.writeFileSync(merged, 'partial-merge'); + const item = queueItem(); + item.mergeGroup!.downloadedFiles = { 0: first, 1: second }; + item.mergeGroup!.mergedFile = merged; + + const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id])); + + expect(result.changed).toBe(true); + expect(result.removedFiles.sort()).toEqual([first, second, merged].sort()); + expect(result.queue[0]).toMatchObject({ status: 'pending', progress: 0 }); + expect(result.queue[0].mergeGroup).toMatchObject({ + mergePhase: 'downloading', + currentItemIndex: 0, + downloadedFiles: {}, + }); + expect(result.queue[0].mergeGroup).not.toHaveProperty('mergedFile'); + expect(fs.existsSync(first)).toBe(false); + expect(fs.existsSync(second)).toBe(false); + expect(fs.existsSync(merged)).toBe(false); + expect(result.queue[0].artifactRoot).toBe(fs.realpathSync.native(directory)); + }); + + it('uses persisted artifact provenance after the configured download root changes', () => { + const previousRoot = path.join(directory, 'previous'); + const currentRoot = path.join(directory, 'current'); + fs.mkdirSync(previousRoot); + fs.mkdirSync(currentRoot); + const artifact = path.join(previousRoot, 'merge_tmp_0_100.mp4'); + fs.writeFileSync(artifact, 'partial'); + const item = queueItem({ artifactRoot: fs.realpathSync.native(previousRoot) }); + item.mergeGroup!.downloadedFiles = { 0: artifact }; + + const result = recoverInterruptedMergeArtifacts([item], currentRoot, new Set([item.id])); + + expect(result.failedFiles).toEqual([]); + expect(result.removedFiles).toEqual([artifact]); + expect(fs.existsSync(artifact)).toBe(false); + }); + + it('never removes a persisted path outside the configured download root', () => { + const outside = path.join(os.tmpdir(), `merge_tmp_0_${Date.now()}.mp4`); + fs.writeFileSync(outside, 'keep'); + const item = queueItem(); + item.mergeGroup!.downloadedFiles = { 0: outside }; + + try { + const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id])); + expect(result.removedFiles).toEqual([]); + expect(result.failedFiles).toEqual([outside]); + expect(result.queue[0]).toMatchObject({ status: 'error', mergeRecoveryBlocked: true }); + expect(result.queue[0]).not.toHaveProperty('artifactRoot'); + expect(fs.existsSync(outside)).toBe(true); + } finally { + fs.rmSync(outside, { force: true }); + } + }); + + it('leaves completed merge jobs and their published outputs untouched', () => { + const output = path.join(directory, 'published.mp4'); + fs.writeFileSync(output, 'complete'); + const item = queueItem({ status: 'completed', progress: 100, outputFiles: [output] }); + item.mergeGroup!.mergePhase = 'done'; + + const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id])); + + expect(result).toEqual({ queue: [item], removedFiles: [], failedFiles: [], changed: false }); + expect(fs.existsSync(output)).toBe(true); + }); + + it('leaves a normal failed job untouched because it is not a hard-crash recovery', () => { + const item = queueItem({ status: 'error', progress: 72, last_error: 'ffmpeg failed' }); + + const result = recoverInterruptedMergeArtifacts([item], directory, new Set()); + + expect(result).toEqual({ queue: [item], removedFiles: [], failedFiles: [], changed: false }); + }); + + it('removes persisted temp and published split artifacts from an interrupted split', () => { + const jobDirectory = path.join(directory, 'alice'); + fs.mkdirSync(jobDirectory); + const temp = path.join(jobDirectory, '.merge_split_123_0.mp4'); + const published = path.join(jobDirectory, 'Alice_Part01.mp4'); + fs.writeFileSync(temp, 'partial'); + fs.writeFileSync(published, 'published-before-crash'); + const item = queueItem(); + item.mergeGroup!.mergePhase = 'splitting'; + item.mergeGroup!.splitTempFiles = [temp]; + item.mergeGroup!.splitFiles = [published]; + + const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id])); + + expect(result.removedFiles.sort()).toEqual([temp, published].sort()); + expect(result.failedFiles).toEqual([]); + expect(result.queue[0].mergeGroup).not.toHaveProperty('splitFiles'); + expect(result.queue[0].mergeGroup).not.toHaveProperty('splitTempFiles'); + expect(fs.existsSync(temp)).toBe(false); + expect(fs.existsSync(published)).toBe(false); + }); + + it('keeps references and blocks retry when an interrupted artifact cannot be removed', () => { + const jobDirectory = path.join(directory, 'alice'); + fs.mkdirSync(jobDirectory); + const locked = path.join(jobDirectory, '.merge_split_123_0.mp4'); + fs.mkdirSync(locked); + const item = queueItem(); + item.mergeGroup!.mergePhase = 'splitting'; + item.mergeGroup!.splitTempFiles = [locked]; + + const result = recoverInterruptedMergeArtifacts([item], directory, new Set([item.id])); + + expect(result.removedFiles).toEqual([]); + expect(result.failedFiles).toEqual([locked]); + expect(result.queue[0]).toMatchObject({ + status: 'error', + mergeRecoveryBlocked: true, + mergeGroup: { splitTempFiles: [locked] }, + }); + }); + + it('derives recovery eligibility only from persisted downloading merge jobs', () => { + expect([...getInterruptedMergeItemIds([ + { id: 'active', status: 'downloading', mergeGroup: {} }, + { id: 'failed', status: 'error', mergeGroup: {} }, + { id: 'plain', status: 'downloading' }, + null, + ])]).toEqual(['active']); + }); +}); diff --git a/src/main/domain/merge-recovery.ts b/src/main/domain/merge-recovery.ts new file mode 100644 index 0000000..a3426e8 --- /dev/null +++ b/src/main/domain/merge-recovery.ts @@ -0,0 +1,197 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { QueueItem } from '../../types'; + +export interface MergeRecoveryResult { + queue: QueueItem[]; + removedFiles: string[]; + failedFiles: string[]; + changed: boolean; +} + +type ArtifactKind = 'internal' | 'published-split'; +type RemovalResult = 'removed' | 'missing' | 'failed'; + +export interface MergeArtifactRootResolution { + artifactRoot: string | null; + migrated: boolean; +} + +function isInside(root: string, candidate: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function canonicalRoot(root: string): string | null { + if (!path.isAbsolute(root)) return null; + const resolved = path.resolve(root); + try { + return fs.statSync(resolved).isDirectory() ? fs.realpathSync.native(resolved) : null; + } catch { + return resolved; + } +} + +function collectMergeArtifacts(group: NonNullable): Map { + const artifacts = new Map(); + const addArtifact = (filePath: string, kind: ArtifactKind): void => { + const resolved = path.resolve(filePath); + const existing = artifacts.get(resolved); + if (!existing || kind === 'internal') artifacts.set(resolved, { filePath, kind }); + }; + for (const filePath of Object.values(group.downloadedFiles)) addArtifact(filePath, 'internal'); + if (group.mergedFile) addArtifact(group.mergedFile, 'internal'); + for (const filePath of group.splitTempFiles ?? []) addArtifact(filePath, 'internal'); + for (const filePath of group.splitFiles ?? []) addArtifact(filePath, 'published-split'); + return artifacts; +} + +function isPlausiblyInsideRoot(root: string, candidate: string): boolean { + if (!isInside(root, candidate)) return false; + if (!fs.existsSync(candidate)) return true; + try { + const resolvedRoot = canonicalRoot(root); + if (!resolvedRoot) return false; + return isInside(resolvedRoot, fs.realpathSync.native(candidate)); + } catch { + return false; + } +} + +export function resolveMergeArtifactRoot(item: QueueItem, currentDownloadRoot: string): MergeArtifactRootResolution { + if (typeof item.artifactRoot === 'string' && item.artifactRoot) { + const resolved = canonicalRoot(item.artifactRoot); + return { artifactRoot: resolved, migrated: resolved !== null && resolved !== item.artifactRoot }; + } + const resolved = canonicalRoot(currentDownloadRoot); + if (!resolved || !item.mergeGroup) return { artifactRoot: null, migrated: false }; + const plausible = [...collectMergeArtifacts(item.mergeGroup).values()] + .every(({ filePath }) => isPlausiblyInsideRoot(resolved, filePath)); + return plausible + ? { artifactRoot: resolved, migrated: true } + : { artifactRoot: null, migrated: false }; +} + +function isInternalMergeArtifact(filePath: string): boolean { + return /^(?:(?:merge_tmp_\d+_\d+|merged_\d+|\.merge_output_\d+_\d+)(?:_\d+)?|\.merge_split_[A-Za-z0-9_-]+)\.mp4$/i.test(path.basename(filePath)); +} + +function removeArtifact(filePath: string, downloadRoot: string, kind: ArtifactKind): RemovalResult { + if (!isInside(downloadRoot, filePath)) return 'failed'; + if (kind === 'internal' && !isInternalMergeArtifact(filePath)) return 'failed'; + if (!fs.existsSync(filePath)) return 'missing'; + try { + const resolvedRoot = canonicalRoot(downloadRoot); + if (!resolvedRoot || !isInside(resolvedRoot, fs.realpathSync.native(filePath))) return 'failed'; + fs.unlinkSync(filePath); + return fs.existsSync(filePath) ? 'failed' : 'removed'; + } catch { + return 'failed'; + } +} + +export function getInterruptedMergeItemIds(rawQueue: unknown[]): Set { + const result = new Set(); + for (const raw of rawQueue) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; + const item = raw as Record; + if (item.status !== 'downloading' || typeof item.id !== 'string' || !item.id) continue; + if (!item.mergeGroup || typeof item.mergeGroup !== 'object' || Array.isArray(item.mergeGroup)) continue; + result.add(item.id); + } + return result; +} + +function retainFailedArtifacts(group: NonNullable, failed: Set): NonNullable { + const downloadedFiles = Object.fromEntries( + Object.entries(group.downloadedFiles).filter(([, filePath]) => failed.has(path.resolve(filePath))) + ) as Record; + const retained = { ...group, downloadedFiles }; + if (!group.mergedFile || !failed.has(path.resolve(group.mergedFile))) delete retained.mergedFile; + const splitFiles = group.splitFiles?.filter((filePath) => failed.has(path.resolve(filePath))); + const splitTempFiles = group.splitTempFiles?.filter((filePath) => failed.has(path.resolve(filePath))); + if (splitFiles?.length) retained.splitFiles = splitFiles; + else delete retained.splitFiles; + if (splitTempFiles?.length) retained.splitTempFiles = splitTempFiles; + else delete retained.splitTempFiles; + return retained; +} + +export function recoverInterruptedMergeArtifacts( + queue: QueueItem[], + downloadRoot: string, + interruptedItemIds: ReadonlySet +): MergeRecoveryResult { + const removedFiles: string[] = []; + const failedFiles: string[] = []; + let changed = false; + const recoveredQueue = queue.map((item) => { + const group = item.mergeGroup; + if (!group || group.mergePhase === 'done' || !interruptedItemIds.has(item.id)) return item; + + const artifacts = collectMergeArtifacts(group); + const rootResolution = resolveMergeArtifactRoot(item, downloadRoot); + if (!rootResolution.artifactRoot) { + const failed = new Set(artifacts.keys()); + failedFiles.push(...[...artifacts.values()].map(({ filePath }) => filePath)); + changed = true; + return { + ...item, + status: 'error' as const, + mergeRecoveryBlocked: true, + mergeGroup: retainFailedArtifacts(group, failed), + }; + } + const itemWithRoot = item.artifactRoot === rootResolution.artifactRoot + ? item + : { ...item, artifactRoot: rootResolution.artifactRoot }; + + const failed = new Set(); + for (const [resolved, artifact] of artifacts) { + const result = removeArtifact(artifact.filePath, rootResolution.artifactRoot, artifact.kind); + if (result === 'removed') removedFiles.push(artifact.filePath); + if (result === 'failed') { + failed.add(resolved); + failedFiles.push(artifact.filePath); + } + } + + changed = true; + if (failed.size > 0) { + return { + ...itemWithRoot, + status: 'error' as const, + mergeRecoveryBlocked: true, + mergeGroup: retainFailedArtifacts(group, failed), + }; + } + + const recoveredGroup = { + ...group, + mergePhase: 'downloading' as const, + currentItemIndex: 0, + downloadedFiles: {}, + }; + delete recoveredGroup.mergedFile; + delete recoveredGroup.splitFiles; + delete recoveredGroup.splitTempFiles; + const recoveredItem: QueueItem = { + ...itemWithRoot, + status: 'pending', + progress: 0, + mergeGroup: recoveredGroup, + }; + delete recoveredItem.currentPart; + delete recoveredItem.totalParts; + delete recoveredItem.speed; + delete recoveredItem.eta; + delete recoveredItem.downloadedBytes; + delete recoveredItem.totalBytes; + delete recoveredItem.progressStatus; + delete recoveredItem.last_error; + delete recoveredItem.mergeRecoveryBlocked; + return recoveredItem; + }); + + return { queue: recoveredQueue, removedFiles, failedFiles, changed }; +} diff --git a/src/main/domain/merge-split.production-path.test.ts b/src/main/domain/merge-split.production-path.test.ts new file mode 100644 index 0000000..38964bc --- /dev/null +++ b/src/main/domain/merge-split.production-path.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('merge split production path', () => { + it('persists a hidden merge output before ffmpeg can write crash data', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const start = source.indexOf('async function processDownloadMergeGroup'); + const merge = source.slice(start, source.indexOf('// ---- PHASE 3: SPLITTING ----', start)); + + expect(merge).toContain('.merge_output_'); + expect(merge.indexOf('mg.mergedFile = mergedFilePath')).toBeLessThan(merge.indexOf('await mergeVideos(')); + expect(merge.indexOf('saveQueue(downloadQueue)', merge.indexOf('mg.mergedFile = mergedFilePath'))).toBeLessThan(merge.indexOf('await mergeVideos(')); + }); + + it('encodes each split into a persisted app-owned temp file before atomically publishing it', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const start = source.indexOf('async function splitMergedFile'); + const end = source.indexOf('// ==========================================\n// DOWNLOAD FUNCTIONS', start); + const split = source.slice(start, end); + + expect(split).toContain('.merge_split_'); + expect(split).toContain('onPartState(i, outputFile, temporaryFile)'); + expect(split).toContain('fs.renameSync(temporaryFile, outputFile)'); + expect(split).toContain('onPartState(i, outputFile, null)'); + expect(split.indexOf('onPartState(i, outputFile, temporaryFile)')).toBeLessThan(split.indexOf("spawn(ffmpeg, args")); + }); + + it('hydrates interrupted split state and prevents retry while recovery artifacts remain', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + + expect(source).toContain('splitTempFiles: Array.isArray(raw.splitTempFiles)'); + expect(source).toContain("raw.status === 'downloading' && isPlainObject(raw.mergeGroup)"); + expect(source).toContain('const interruptedMergeItemIds = new Set()'); + expect(source).toContain('recoverInterruptedMergeArtifacts(downloadQueue, config.download_path, queueLoad.interruptedMergeItemIds)'); + expect(source).toContain("item.status === 'error' && !item.mergeRecoveryBlocked"); + expect(source).toContain("if (item.status !== 'error' || item.mergeRecoveryBlocked) return downloadQueue"); + }); +}); diff --git a/src/main/domain/migrator.test.ts b/src/main/domain/migrator.test.ts index 047824b..6d9ff5a 100644 --- a/src/main/domain/migrator.test.ts +++ b/src/main/domain/migrator.test.ts @@ -107,6 +107,22 @@ describe('migrateJsonToSqlite', () => { expect(count?.c).toBe(2); }); + test('scrubs secret aliases reintroduced into legacy files after migration', () => { + const configPath = writeJson('config.json', { language: 'de' }); + migrateJsonToSqlite({ db, appDataDir }); + fs.writeFileSync(configPath, JSON.stringify({ language: 'en', clientSecret: 'late-client-secret' }), 'utf-8'); + fs.writeFileSync(`${configPath}.v4-backup`, JSON.stringify({ language: 'de', accessToken: 'late-access-token' }), 'utf-8'); + + const second = migrateJsonToSqlite({ db, appDataDir }); + + expect(second).toMatchObject({ alreadyApplied: true, errors: [] }); + const persistedFiles = `${fs.readFileSync(configPath, 'utf-8')}\n${fs.readFileSync(`${configPath}.v4-backup`, 'utf-8')}`; + for (const forbidden of ['late-client-secret', 'late-access-token', 'clientSecret', 'accessToken']) { + expect(persistedFiles).not.toContain(forbidden); + } + expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de'); + }); + test('writes .v4-backup of source JSONs', () => { const configPath = writeJson('config.json', { language: 'en' }); migrateJsonToSqlite({ db, appDataDir }); @@ -114,6 +130,27 @@ describe('migrateJsonToSqlite', () => { expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).toContain('"language": "en"'); }); + test('does not scrub recoverable plaintext secrets before the SQLite transaction commits', () => { + const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' }); + const transactionDb: DbHandle = { + ...db, + transaction(fn: () => R): R { + return db.transaction(() => { + const result = fn(); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive'); + return result; + }); + }, + }; + const secrets = createSecretStore(transactionDb, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db: transactionDb, appDataDir, secrets }); + + expect(result.errors).toEqual([]); + expect(secrets.get('twitch_client_secret')).toBe('must-survive'); + expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('must-survive'); + }); + test('malformed JSON is logged + skipped', () => { fs.writeFileSync(path.join(appDataDir, 'config.json'), '{ not valid json', 'utf-8'); const result = migrateJsonToSqlite({ db, appDataDir }); @@ -248,6 +285,26 @@ describe('migrateJsonToSqlite', () => { expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined(); }); + test('migrates camel and separator secret aliases and scrubs every secret-bearing key', () => { + const configPath = writeJson('config.json', { + clientSecret: 'camel-client-secret', + 'discord-webhook-url': 'https://discord.com/api/webhooks/camel', + accessToken: 'obsolete-access-token', + language: 'de', + }); + const secrets = createSecretStore(db, new MemorySecureStorage()); + + const result = migrateJsonToSqlite({ db, appDataDir, secrets }); + + expect(result.errors).toEqual([]); + expect(secrets.get('twitch_client_secret')).toBe('camel-client-secret'); + expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/camel'); + const persistedFiles = `${fs.readFileSync(configPath, 'utf-8')}\n${fs.readFileSync(configPath + '.v4-backup', 'utf-8')}`; + for (const forbidden of ['camel-client-secret', '/webhooks/camel', 'obsolete-access-token', 'clientSecret', 'discord-webhook-url', 'accessToken']) { + expect(persistedFiles).not.toContain(forbidden); + } + }); + test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => { const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' }); const secrets = createSecretStore(db, new MemorySecureStorage()); @@ -260,7 +317,7 @@ describe('migrateJsonToSqlite', () => { expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); }); - test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => { + test('commits recovered secrets before reporting a legacy scrub failure', () => { const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' }); fs.mkdirSync(`${configPath}.v4-backup`); const secrets = createSecretStore(db, new MemorySecureStorage()); @@ -269,8 +326,8 @@ describe('migrateJsonToSqlite', () => { expect(result.errors).toHaveLength(1); expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive'); - expect(db.all('SELECT * FROM config_kv')).toEqual([]); - expect(db.all('SELECT * FROM app_secrets')).toEqual([]); - expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined(); + expect(JSON.parse(db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language'])!.value)).toBe('de'); + expect(secrets.get('twitch_client_secret')).toBe('must-survive'); + expect(db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])?.name).toBe('authoritative-state-v1'); }); }); diff --git a/src/main/domain/migrator.ts b/src/main/domain/migrator.ts index e6dfedc..8bb5973 100644 --- a/src/main/domain/migrator.ts +++ b/src/main/domain/migrator.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { DbHandle } from '../infra/db'; import { createAppStateStore } from './app-state-store'; +import { isSecretBearingKey } from './config-export'; import type { SecretStore } from './secret-store'; export interface MigratorOptions { @@ -26,7 +27,19 @@ export interface MigrationResult { } const MIGRATION_NAME = 'authoritative-state-v1'; -const SECRET_KEYS = new Set(['client_secret', 'discord_webhook_url']); +function normalizeSecretKey(key: string): string { + return key.replace(/[^a-z0-9]/gi, '').toLowerCase(); +} + +function findLegacySecret(config: Record, canonicalKey: string): string | null { + const canonicalValue = config[canonicalKey]; + if (typeof canonicalValue === 'string' && canonicalValue) return canonicalValue; + const normalizedKey = normalizeSecretKey(canonicalKey); + for (const [key, value] of Object.entries(config)) { + if (normalizeSecretKey(key) === normalizedKey && typeof value === 'string' && value) return value; + } + return null; +} function readJson(filePath: string, source: string, errors: MigrationError[]): T | undefined { if (!fs.existsSync(filePath)) return undefined; @@ -39,7 +52,7 @@ function readJson(filePath: string, source: string, errors: MigrationError[]) } function withoutSecrets(config: Record): Record { - return Object.fromEntries(Object.entries(config).filter(([key]) => !SECRET_KEYS.has(key))); + return Object.fromEntries(Object.entries(config).filter(([key]) => !isSecretBearingKey(key))); } function writeJsonAtomic(filePath: string, value: unknown): void { @@ -60,9 +73,23 @@ function scrubConfigFiles(configPath: string, config: Record): } function scrubExistingConfig(configPath: string): void { - if (!fs.existsSync(configPath)) return; - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; - scrubConfigFiles(configPath, config); + for (const candidate of [configPath, `${configPath}.v4-backup`]) { + if (!fs.existsSync(candidate)) continue; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8')); + } catch { + fs.rmSync(candidate, { force: true }); + continue; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + fs.rmSync(candidate, { force: true }); + continue; + } + const config = parsed as Record; + const sanitized = withoutSecrets(config); + if (JSON.stringify(config) !== JSON.stringify(sanitized)) writeJsonAtomic(candidate, sanitized); + } } function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult { @@ -82,6 +109,11 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { const queuePath = path.join(appDataDir, 'download_queue.json'); const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]); if (existing) { + try { + scrubExistingConfig(configPath); + } catch (error) { + return emptyResult(true, [{ source: 'legacy-config-scrub', message: error instanceof Error ? error.message : String(error) }]); + } return emptyResult(true); } @@ -97,17 +129,18 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { if (configExists && (!config || typeof config !== 'object' || Array.isArray(config))) { return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]); } - if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) { + const clientSecret = config ? findLegacySecret(config, 'client_secret') : null; + const webhookUrl = config ? findLegacySecret(config, 'discord_webhook_url') : null; + if ((clientSecret || webhookUrl) && !secrets) { return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]); } - if (config && requireEncryption && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key]) && !secrets?.status().encryptionAvailable) { + if ((clientSecret || webhookUrl) && requireEncryption && !secrets?.status().encryptionAvailable) { return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]); } const state = createAppStateStore(db); let downloadedVodsCount = 0; let streamersCount = 0; - let configScrubbed = false; try { db.transaction(() => { if (configExists && config) { @@ -115,12 +148,8 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { downloadedVodsCount = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length : 0; - if (typeof config.client_secret === 'string' && config.client_secret) { - secrets!.set('twitch_client_secret', config.client_secret); - } - if (typeof config.discord_webhook_url === 'string' && config.discord_webhook_url) { - secrets!.set('discord_webhook_url', config.discord_webhook_url); - } + if (clientSecret) secrets!.set('twitch_client_secret', clientSecret); + if (webhookUrl) secrets!.set('discord_webhook_url', webhookUrl); } if (queueExists) state.saveQueue(queue as Array>); streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0; @@ -129,20 +158,26 @@ export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult { [MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })] ); if (queueExists) backupJson(queuePath, queue); - if (configExists && config) { - scrubConfigFiles(configPath, config); - configScrubbed = true; - } }); } catch (error) { - if (configScrubbed && config) { - try { - writeJsonAtomic(configPath, config); - } catch { } - } return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]); } + if (configExists && config) { + try { + scrubConfigFiles(configPath, config); + } catch (error) { + return { + alreadyApplied: false, + configMigrated: true, + queueMigrated: queueExists, + downloadedVodsCount, + streamersCount, + errors: [{ source: 'legacy-config-scrub', message: error instanceof Error ? error.message : String(error) }], + }; + } + } + return { alreadyApplied: false, configMigrated: configExists, diff --git a/src/main/domain/persistence-commit.test.ts b/src/main/domain/persistence-commit.test.ts index 726b34d..6681643 100644 --- a/src/main/domain/persistence-commit.test.ts +++ b/src/main/domain/persistence-commit.test.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { openDatabase, type DbHandle } from '../infra/db'; import { createAppStateStore } from './app-state-store'; -import { commitQueueMutation, persistStateChange } from './persistence-commit'; +import { applyQueueSnapshotPreservingActiveItems, commitQueueMutation, persistStateChange } from './persistence-commit'; let directory: string; let db: DbHandle; @@ -20,6 +20,23 @@ afterEach(() => { }); describe('persistStateChange', () => { + it('preserves active item identity while applying a persisted pause snapshot', () => { + const active = { id: 'q1', status: 'downloading', progress: 72, mergePhase: 'merging' }; + const idle = { id: 'q2', status: 'pending', progress: 0 }; + const applied = applyQueueSnapshotPreservingActiveItems( + [active, idle], + [ + { id: 'q1', status: 'paused', progress: 72, mergePhase: 'merging' }, + { id: 'q2', status: 'paused', progress: 0 }, + ], + new Set(['q1']), + ); + + expect(applied[0]).toBe(active); + expect(active).toMatchObject({ status: 'paused', progress: 72, mergePhase: 'merging' }); + expect(applied[1]).not.toBe(idle); + }); + it('keeps runtime configuration at the persisted value when a SQLite write fails', () => { const previous = { language: 'de' }; const next = { language: 'en' }; diff --git a/src/main/domain/persistence-commit.ts b/src/main/domain/persistence-commit.ts index fe1af18..7fa39d3 100644 --- a/src/main/domain/persistence-commit.ts +++ b/src/main/domain/persistence-commit.ts @@ -4,6 +4,16 @@ export function persistStateChange(current: T, createNext: (current: T) => T, return next; } +export function applyQueueSnapshotPreservingActiveItems(current: T[], next: T[], activeItemIds: ReadonlySet): T[] { + const currentById = new Map(current.map((item) => [item.id, item])); + return next.map((candidate) => { + const active = activeItemIds.has(candidate.id) ? currentById.get(candidate.id) : undefined; + if (!active) return candidate; + Object.assign(active, candidate); + return active; + }); +} + export async function commitQueueMutation( current: T, createNext: (current: T) => T, diff --git a/src/main/domain/phase-boundary-process.test.ts b/src/main/domain/phase-boundary-process.test.ts new file mode 100644 index 0000000..9b1e073 --- /dev/null +++ b/src/main/domain/phase-boundary-process.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueueProcessRegistry } from '../queue/process-registry'; +import { createPhaseBoundaryProcessResource, waitForPhaseBoundary } from './phase-boundary-process'; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('phase-boundary queue processes', () => { + it('lets the current process finish on pause and only terminates it on cancellation', async () => { + const registry = new QueueProcessRegistry(); + const exited = deferred(); + const kill = vi.fn(); + const cleanup = vi.fn(); + registry.register('item-a', 'merge', createPhaseBoundaryProcessResource({ kill }, () => exited.promise, cleanup)); + + await registry.pauseItem('item-a'); + expect(kill).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + + const cancelling = registry.cancelItem('item-a'); + expect(kill).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + exited.resolve(); + await cancelling; + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it('waits at a safe boundary until the paused item is resumed', async () => { + const registry = new QueueProcessRegistry(); + registry.register('item-a', 'split', {}); + await registry.pauseItem('item-a'); + const transitions: string[] = []; + let settled = false; + const waiting = waitForPhaseBoundary('item-a', registry, { + onPaused: () => { transitions.push('paused'); }, + onResumed: () => { transitions.push('resumed'); }, + }).then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + + expect(settled).toBe(false); + expect(transitions).toEqual(['paused']); + + await registry.resumeItem('item-a'); + await expect(waiting).resolves.toBe(true); + expect(transitions).toEqual(['paused', 'resumed']); + }); + + it('does not report resumed after cancellation releases a paused boundary', async () => { + const registry = new QueueProcessRegistry(); + registry.register('item-a', 'split', {}); + await registry.pauseItem('item-a'); + const onResumed = vi.fn(); + const waiting = waitForPhaseBoundary('item-a', registry, { onResumed }); + + await registry.cancelItem('item-a'); + + await expect(waiting).resolves.toBe(false); + expect(onResumed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/domain/phase-boundary-process.ts b/src/main/domain/phase-boundary-process.ts new file mode 100644 index 0000000..1be6b8f --- /dev/null +++ b/src/main/domain/phase-boundary-process.ts @@ -0,0 +1,39 @@ +import type { QueueProcessResource } from '../queue/process-registry'; + +export interface KillableProcess { + kill(): unknown; +} + +export interface PhaseBoundaryState { + isPaused(itemId: string): boolean; + isCancelled(itemId: string): boolean; + whenResumed(itemId: string): Promise; +} + +export interface PhaseBoundaryTransition { + onPaused?: () => unknown | Promise; + onResumed?: () => unknown | Promise; +} + +export function createPhaseBoundaryProcessResource( + process: KillableProcess, + wait: () => Promise, + cleanup?: () => unknown | Promise, +): QueueProcessResource { + return { + kill: () => process.kill(), + wait, + cleanup, + }; +} + +export async function waitForPhaseBoundary(itemId: string | null, state: PhaseBoundaryState, transition: PhaseBoundaryTransition = {}): Promise { + if (!itemId) return true; + if (state.isPaused(itemId)) { + await transition.onPaused?.(); + await state.whenResumed(itemId); + if (state.isCancelled(itemId)) return false; + await transition.onResumed?.(); + } + return !state.isCancelled(itemId); +} diff --git a/src/main/domain/phase-boundary.production-path.test.ts b/src/main/domain/phase-boundary.production-path.test.ts new file mode 100644 index 0000000..6f08ad3 --- /dev/null +++ b/src/main/domain/phase-boundary.production-path.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('phase-boundary production path', () => { + it('pauses only at completed boundaries and never reruns a failed concat or copy-merge phase', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const concatStart = source.indexOf('async function concatVideoFiles'); + const concatEnd = source.indexOf('async function cutVideo', concatStart); + const concat = source.slice(concatStart, concatEnd); + const mergeStart = source.indexOf('async function mergeVideos'); + const mergeEnd = source.indexOf('async function splitMergedFile', mergeStart); + const merge = source.slice(mergeStart, mergeEnd); + + expect(source).toContain('async function waitForQueuePhaseBoundary'); + expect(concat).not.toContain('while (true)'); + expect(concat).toContain('await waitForQueuePhaseBoundary(itemId)'); + expect(concat).toContain('fs.rmSync(outputFile, { force: true })'); + expect(merge).toContain('const boundaryReady = await waitForQueuePhaseBoundary(itemId)'); + expect(merge).toContain('if (appShutdownStarted || !boundaryReady)'); + expect(merge).not.toContain('queueProcessRegistry.isCancelled(itemId) || queueProcessRegistry.isPaused(itemId)'); + }); +}); diff --git a/src/main/domain/privileged-ipc.test.ts b/src/main/domain/privileged-ipc.test.ts index 0f78168..1c24371 100644 --- a/src/main/domain/privileged-ipc.test.ts +++ b/src/main/domain/privileged-ipc.test.ts @@ -11,7 +11,7 @@ describe('privileged IPC behavior', () => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); - it.each(['add-to-queue', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])( + it.each(['add-to-queue', 'add-to-queue-with-result', 'remove-from-queue', 'download-clip', 'run-preflight', 'get-debug-log'])( 'registers %s so an untrusted renderer event cannot execute it', async (channel) => { const directory = mkdtempSync(join(tmpdir(), 'tvm-privileged-ipc-')); diff --git a/src/main/domain/provider-payload.test.ts b/src/main/domain/provider-payload.test.ts new file mode 100644 index 0000000..70692b6 --- /dev/null +++ b/src/main/domain/provider-payload.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { parseGraphqlDataEnvelope, parseGraphqlUser, parseHelixDataArray } from './provider-payload'; + +describe('provider payload semantics', () => { + it('accepts only an explicit object-valued GraphQL data envelope', () => { + expect(parseGraphqlDataEnvelope({ data: { user: null } })).toEqual({ status: 'success', value: { user: null } }); + expect(parseGraphqlDataEnvelope({})).toEqual({ status: 'unavailable' }); + expect(parseGraphqlDataEnvelope({ data: null })).toEqual({ status: 'unavailable' }); + expect(parseGraphqlDataEnvelope('failure')).toEqual({ status: 'unavailable' }); + }); + + it('distinguishes an explicit missing GraphQL user from a malformed response', () => { + expect(parseGraphqlUser({ user: null })).toEqual({ status: 'not-found' }); + expect(parseGraphqlUser({ user: { id: '1' } })).toEqual({ status: 'success', value: { id: '1' } }); + expect(parseGraphqlUser({})).toEqual({ status: 'unavailable' }); + expect(parseGraphqlUser({ user: 'invalid' })).toEqual({ status: 'unavailable' }); + }); + + it('accepts an explicit empty Helix data array without accepting missing data', () => { + expect(parseHelixDataArray({ data: [] })).toEqual({ status: 'success', value: [] }); + expect(parseHelixDataArray({})).toEqual({ status: 'unavailable' }); + expect(parseHelixDataArray({ data: null })).toEqual({ status: 'unavailable' }); + expect(parseHelixDataArray({ data: {} })).toEqual({ status: 'unavailable' }); + }); +}); diff --git a/src/main/domain/provider-payload.ts b/src/main/domain/provider-payload.ts new file mode 100644 index 0000000..8b05ef9 --- /dev/null +++ b/src/main/domain/provider-payload.ts @@ -0,0 +1,26 @@ +import type { RefreshOutcome } from './refresh-result'; + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : null; +} + +export function parseGraphqlDataEnvelope(value: unknown): RefreshOutcome> { + const envelope = asRecord(value); + if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'data')) return { status: 'unavailable' }; + const data = asRecord(envelope.data); + return data ? { status: 'success', value: data } : { status: 'unavailable' }; +} + +export function parseGraphqlUser(value: unknown): RefreshOutcome> { + const data = asRecord(value); + if (!data || !Object.prototype.hasOwnProperty.call(data, 'user')) return { status: 'unavailable' }; + if (data.user === null) return { status: 'not-found' }; + const user = asRecord(data.user); + return user ? { status: 'success', value: user } : { status: 'unavailable' }; +} + +export function parseHelixDataArray(value: unknown): RefreshOutcome { + const envelope = asRecord(value); + if (!envelope || !Object.prototype.hasOwnProperty.call(envelope, 'data') || !Array.isArray(envelope.data)) return { status: 'unavailable' }; + return { status: 'success', value: envelope.data }; +} diff --git a/src/main/domain/queue-addition.production-path.test.ts b/src/main/domain/queue-addition.production-path.test.ts new file mode 100644 index 0000000..8682a8f --- /dev/null +++ b/src/main/domain/queue-addition.production-path.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('queue addition IPC contract', () => { + it('keeps the legacy queue result and exposes the atomic accepted result separately', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + + expect(source).toContain("registerTrustedIpcHandler(ipcMain, 'add-to-queue-with-result'"); + expect(source).toContain('function addRendererQueueItemWithResult(input: unknown, notifyDuplicate: boolean): QueueAdditionResult'); + expect(source).toContain('return addRendererQueueItemWithResult(input, true).queue;'); + expect(source).toContain('return addRendererQueueItemWithResult(input, false);'); + expect(source).toContain("reason: 'access-denied' as const"); + expect(source).toContain("reason: 'shutting-down'"); + }); +}); diff --git a/src/main/domain/queue-addition.test.ts b/src/main/domain/queue-addition.test.ts new file mode 100644 index 0000000..60cf30f --- /dev/null +++ b/src/main/domain/queue-addition.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { commitQueueAddition } from './queue-addition'; + +describe('commitQueueAddition', () => { + it('returns the accepted item id from the same synchronous mutation it persists', () => { + const current = [{ id: 'existing' }]; + const persist = vi.fn(); + + const result = commitQueueAddition(current, { id: 'added' }, () => false, persist); + + expect(result).toEqual({ + queue: [{ id: 'existing' }, { id: 'added' }], + accepted: true, + addedId: 'added', + }); + expect(persist).toHaveBeenCalledOnce(); + expect(persist).toHaveBeenCalledWith(result.queue); + }); + + it('returns an unmodified queue and no id for duplicates or invalid items', () => { + const current = [{ id: 'existing' }]; + const persist = vi.fn(); + + expect(commitQueueAddition(current, { id: 'duplicate' }, () => true, persist)).toEqual({ + queue: current, + accepted: false, + reason: 'duplicate', + }); + expect(commitQueueAddition(current, null, () => false, persist)).toEqual({ + queue: current, + accepted: false, + reason: 'invalid', + }); + expect(persist).not.toHaveBeenCalled(); + }); + + it('returns an explicit persistence failure without leaking a replacement queue', () => { + const current = [{ id: 'existing' }]; + + expect(commitQueueAddition(current, { id: 'added' }, () => false, () => { + throw new Error('disk full'); + })).toEqual({ + queue: current, + accepted: false, + reason: 'persistence-failed', + }); + }); +}); diff --git a/src/main/domain/queue-addition.ts b/src/main/domain/queue-addition.ts new file mode 100644 index 0000000..a3a1ae1 --- /dev/null +++ b/src/main/domain/queue-addition.ts @@ -0,0 +1,32 @@ +export type QueueAdditionRejectionReason = 'duplicate' | 'invalid' | 'shutting-down' | 'persistence-failed' | 'access-denied'; + +export interface QueueAdditionAccepted { + queue: T[]; + accepted: true; + addedId: string; +} + +export interface QueueAdditionRejected { + queue: T[]; + accepted: false; + reason: QueueAdditionRejectionReason; +} + +export type QueueAdditionResult = QueueAdditionAccepted | QueueAdditionRejected; + +export function commitQueueAddition( + current: T[], + item: T | null, + isDuplicate: (item: T) => boolean, + persist: (next: T[]) => void, +): QueueAdditionResult { + if (!item) return { queue: current, accepted: false, reason: 'invalid' }; + if (isDuplicate(item)) return { queue: current, accepted: false, reason: 'duplicate' }; + const queue = [...current, item]; + try { + persist(queue); + } catch { + return { queue: current, accepted: false, reason: 'persistence-failed' }; + } + return { queue, accepted: true, addedId: item.id }; +} diff --git a/src/main/domain/queue-runtime.test.ts b/src/main/domain/queue-runtime.test.ts new file mode 100644 index 0000000..6a2b4ef --- /dev/null +++ b/src/main/domain/queue-runtime.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import type { QueueItem } from '../../types'; +import { + canonicalQueueItemIdentity, + clearQueueTransferState, + getQueueCreatedAtMs, + isValidPersistedQueueId, + mergeQueueProgressState, + prepareQueueRetryProgress, +} from './queue-runtime'; + +function queueItem(overrides: Partial = {}): QueueItem { + return { + id: '1760000000000-1', + url: 'https://www.twitch.tv/videos/1234567890', + title: 'Title', + date: '2026-08-13T10:00:00.000Z', + streamer: 'streamer', + duration_str: '1h', + status: 'pending', + progress: 0, + ...overrides, + }; +} + +describe('queue runtime invariants', () => { + it('accepts every historical generated id shape and rejects markup or selector payloads', () => { + expect(isValidPersistedQueueId('1760000000000-1')).toBe(true); + expect(isValidPersistedQueueId('1760000000000-999')).toBe(true); + expect(isValidPersistedQueueId('1760000000000')).toBe(true); + expect(isValidPersistedQueueId('item" onclick="alert(1)')).toBe(false); + expect(isValidPersistedQueueId('1760000000000-1000')).toBe(false); + expect(isValidPersistedQueueId('not-an-id')).toBe(false); + }); + + it('uses createdAt first and the timestamp prefix of historical ids as fallback', () => { + expect(getQueueCreatedAtMs(queueItem({ createdAt: '2026-08-13T09:30:00.000Z' }), 1)).toBe(Date.parse('2026-08-13T09:30:00.000Z')); + expect(getQueueCreatedAtMs(queueItem({ id: '1760000000000-27' }), 1)).toBe(1760000000000); + expect(getQueueCreatedAtMs(queueItem({ id: 'invalid', createdAt: 'invalid' }), 123)).toBe(123); + }); + + it('canonicalizes Twitch VOD identity independently of query, fragment, and metadata', () => { + const first = queueItem({ + url: 'https://www.twitch.tv/videos/1234567890?filter=archives#chapter', + streamer: 'Streamer', + date: '2026-01-01', + }); + const second = queueItem({ + url: 'https://twitch.tv/videos/0001234567890', + streamer: 'renamed', + date: '2025-01-01', + }); + + expect(canonicalQueueItemIdentity(first)).toBe(canonicalQueueItemIdentity(second)); + }); + + it('uses media clip coordinates but not filename metadata for custom clip identity', () => { + const first = queueItem({ + customClip: { startSec: 10, durationSec: 20, startPart: 1, filenameFormat: 'simple' }, + }); + const renamed = queueItem({ + customClip: { startSec: 10, durationSec: 20, startPart: 1, filenameFormat: 'template', filenameTemplate: 'other' }, + }); + const differentRange = queueItem({ + customClip: { startSec: 11, durationSec: 20, startPart: 1, filenameFormat: 'simple' }, + }); + + expect(canonicalQueueItemIdentity(first)).toBe(canonicalQueueItemIdentity(renamed)); + expect(canonicalQueueItemIdentity(first)).not.toBe(canonicalQueueItemIdentity(differentRange)); + }); + + it('removes transient transfer state on non-active transitions', () => { + const transitioned = clearQueueTransferState(queueItem({ + status: 'paused', + progress: 42, + speed: '12 MB/s', + eta: '10s', + progressStatus: 'Paused', + downloadedBytes: 10, + totalBytes: 20, + recordingHealth: 'stale', + }), 'pending', 0); + + expect(transitioned).toEqual(expect.objectContaining({ status: 'pending', progress: 0 })); + expect(transitioned).not.toHaveProperty('speed'); + expect(transitioned).not.toHaveProperty('eta'); + expect(transitioned).not.toHaveProperty('progressStatus'); + expect(transitioned).not.toHaveProperty('downloadedBytes'); + expect(transitioned).not.toHaveProperty('totalBytes'); + expect(transitioned).not.toHaveProperty('recordingHealth'); + }); + + it('atomically replaces stale transfer state with a retry countdown', () => { + const item = queueItem({ + status: 'downloading', + speed: '12 MB/s', + eta: '10s', + progressStatus: 'Downloading', + recordingHealth: 'stale', + }); + + mergeQueueProgressState(item, { + id: item.id, + progress: -1, + speed: '', + eta: '', + status: 'Retrying in 5 seconds', + }, false); + + expect(item.speed).toBe(''); + expect(item.eta).toBe(''); + expect(item.progressStatus).toBe('Retrying in 5 seconds'); + expect(item).not.toHaveProperty('recordingHealth'); + }); + + it('marks a live retry countdown as unknown instead of preserving stale health', () => { + const item = queueItem({ + status: 'downloading', + currentPart: 2, + totalParts: 4, + downloadedBytes: 10, + totalBytes: 20, + recordingHealth: 'stale', + }); + + const retryProgress = prepareQueueRetryProgress(item, 'Retrying in 5 seconds'); + expect(item.recordingHealth).toBe('unknown'); + expect(item).not.toHaveProperty('downloadedBytes'); + expect(item).not.toHaveProperty('totalBytes'); + mergeQueueProgressState(item, retryProgress, false); + + expect(retryProgress).toEqual({ + id: item.id, + progress: -1, + speed: '', + eta: '', + status: 'Retrying in 5 seconds', + currentPart: 2, + totalParts: 4, + recordingHealth: 'unknown', + }); + expect(item.recordingHealth).toBe('unknown'); + expect(item).not.toHaveProperty('downloadedBytes'); + expect(item).not.toHaveProperty('totalBytes'); + }); + + it('does not overwrite pause-pending state with late process progress', () => { + const item = queueItem({ + status: 'downloading', + speed: '', + eta: '', + progressStatus: 'Pause pending', + }); + + mergeQueueProgressState(item, { + id: item.id, + progress: 75, + speed: '12 MB/s', + eta: '10s', + status: 'Downloading', + recordingHealth: 'stale', + }, true); + + expect(item).toEqual(expect.objectContaining({ + progress: 0, + speed: '', + eta: '', + progressStatus: 'Pause pending', + })); + expect(item).not.toHaveProperty('recordingHealth'); + }); +}); diff --git a/src/main/domain/queue-runtime.ts b/src/main/domain/queue-runtime.ts new file mode 100644 index 0000000..f515ce1 --- /dev/null +++ b/src/main/domain/queue-runtime.ts @@ -0,0 +1,124 @@ +import type { DownloadProgress, QueueItem } from '../../types'; + +type QueueIdentityInput = Pick; +type QueueTransitionStatus = QueueItem['status']; + +function onlyDigits(value: string): boolean { + return value.length > 0 && [...value].every((character) => character >= '0' && character <= '9'); +} + +function parseHistoricalQueueId(value: string): number | null { + const separator = value.indexOf('-'); + if (separator !== -1 && separator !== value.lastIndexOf('-')) return null; + const timestampText = separator === -1 ? value : value.slice(0, separator); + const counterText = separator === -1 ? null : value.slice(separator + 1); + if (timestampText.length !== 13 || !onlyDigits(timestampText)) return null; + if (counterText !== null) { + if (!onlyDigits(counterText) || counterText.length > 3) return null; + if (counterText.length > 1 && counterText.startsWith('0')) return null; + if (Number(counterText) > 999) return null; + } + const timestamp = Number(timestampText); + return Number.isSafeInteger(timestamp) ? timestamp : null; +} + +function normalizedUrlIdentity(rawUrl: string): string { + const trimmed = rawUrl.trim(); + try { + const parsed = new URL(trimmed); + const hostname = parsed.hostname.toLowerCase().replace(/^www\./, ''); + const vod = hostname === 'twitch.tv' ? parsed.pathname.match(/^\/videos\/(\d+)\/?$/i) : null; + if (vod) return `twitch-vod:${vod[1].replace(/^0+(?=\d)/, '')}`; + const clip = hostname === 'clips.twitch.tv' + ? parsed.pathname.match(/^\/([A-Za-z0-9_-]+)\/?$/) + : hostname === 'twitch.tv' + ? parsed.pathname.match(/^\/[^/]+\/clip\/([A-Za-z0-9_-]+)\/?$/i) + : null; + if (clip) return `twitch-clip:${clip[1]}`; + const pathname = parsed.pathname.replace(/\/+$/, '') || '/'; + return `${parsed.protocol.toLowerCase()}//${hostname}${pathname}`; + } catch { + return trimmed.split(/[?#]/, 1)[0].replace(/\/+$/, '').toLowerCase(); + } +} + +export function isValidPersistedQueueId(value: unknown): value is string { + return typeof value === 'string' && parseHistoricalQueueId(value) !== null; +} + +export function getQueueCreatedAtMs(item: Pick, fallback: number): number { + const explicit = Date.parse(item.createdAt || ''); + if (Number.isFinite(explicit)) return explicit; + return parseHistoricalQueueId(item.id) ?? fallback; +} + +export function canonicalQueueItemIdentity(item: QueueIdentityInput): string { + const mediaIdentity = normalizedUrlIdentity(item.url); + if (!item.customClip) return `${mediaIdentity}|full`; + return [ + mediaIdentity, + 'clip', + item.customClip.startSec, + item.customClip.durationSec, + item.customClip.startPart, + ].join('|'); +} + +export function clearQueueTransferState(item: QueueItem, status: QueueTransitionStatus, progress: number): QueueItem { + const stable = { ...item }; + delete stable.speed; + delete stable.eta; + delete stable.progressStatus; + delete stable.downloadedBytes; + delete stable.totalBytes; + delete stable.recordingHealth; + return { ...stable, status, progress }; +} + +export function applyQueueTransferState(item: QueueItem, status: QueueTransitionStatus, progress: number): QueueItem { + delete item.speed; + delete item.eta; + delete item.progressStatus; + delete item.downloadedBytes; + delete item.totalBytes; + delete item.recordingHealth; + item.status = status; + item.progress = progress; + return item; +} + +export function prepareQueueRetryProgress(item: QueueItem, status: string): DownloadProgress { + applyQueueTransferState(item, 'downloading', item.progress); + item.recordingHealth = 'unknown'; + return { + id: item.id, + progress: -1, + speed: '', + eta: '', + status, + currentPart: item.currentPart, + totalParts: item.totalParts, + recordingHealth: 'unknown', + }; +} + +export function mergeQueueProgressState(item: QueueItem, progress: DownloadProgress, paused: boolean): QueueItem { + if (paused) return item; + const numericProgress = Number(progress.progress); + if (Number.isFinite(numericProgress) && numericProgress > 0 && numericProgress <= 100) { + item.progress = Math.max(item.progress, numericProgress); + } + item.speed = progress.speed || ''; + item.eta = progress.eta || ''; + item.progressStatus = progress.status; + if (typeof progress.currentPart === 'number') item.currentPart = progress.currentPart; + if (typeof progress.totalParts === 'number') item.totalParts = progress.totalParts; + if (typeof progress.downloadedBytes === 'number') item.downloadedBytes = progress.downloadedBytes; + if (typeof progress.totalBytes === 'number') item.totalBytes = progress.totalBytes; + if (progress.recordingHealth === 'ok' || progress.recordingHealth === 'stale' || progress.recordingHealth === 'unknown') { + item.recordingHealth = progress.recordingHealth; + } else { + delete item.recordingHealth; + } + return item; +} diff --git a/src/main/domain/refresh-result.test.ts b/src/main/domain/refresh-result.test.ts new file mode 100644 index 0000000..f321aac --- /dev/null +++ b/src/main/domain/refresh-result.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { resolveRefreshOutcome, type RefreshOutcome } from './refresh-result'; + +describe('resolveRefreshOutcome', () => { + it('keeps the last good value when a refresh is unavailable without caching the failure', () => { + const previous = [{ id: 'vod-1' }]; + const outcome: RefreshOutcome> = { status: 'unavailable' }; + + expect(resolveRefreshOutcome(previous, outcome)).toEqual({ + value: previous, + shouldCache: false, + stale: true, + }); + }); + + it('accepts a successful empty collection as fresh authoritative data', () => { + expect(resolveRefreshOutcome([{ id: 'vod-1' }], { status: 'success', value: [] })).toEqual({ + value: [], + shouldCache: true, + stale: false, + }); + }); + + it('returns no value when the source is unavailable and no last good value exists', () => { + expect(resolveRefreshOutcome(undefined, { status: 'unavailable' })).toEqual({ + value: null, + shouldCache: false, + stale: false, + }); + }); + + it('treats an authoritative not-found result differently from an outage', () => { + expect(resolveRefreshOutcome([{ id: 'vod-1' }], { status: 'not-found' })).toEqual({ + value: null, + shouldCache: true, + stale: false, + }); + }); +}); diff --git a/src/main/domain/refresh-result.ts b/src/main/domain/refresh-result.ts new file mode 100644 index 0000000..64a2813 --- /dev/null +++ b/src/main/domain/refresh-result.ts @@ -0,0 +1,17 @@ +export type RefreshOutcome = + | { status: 'success'; value: T } + | { status: 'not-found' } + | { status: 'unavailable' }; + +export interface ResolvedRefresh { + value: T | null; + shouldCache: boolean; + stale: boolean; +} + +export function resolveRefreshOutcome(previous: T | undefined, outcome: RefreshOutcome): ResolvedRefresh { + if (outcome.status === 'success') return { value: outcome.value, shouldCache: true, stale: false }; + if (outcome.status === 'not-found') return { value: null, shouldCache: true, stale: false }; + if (previous !== undefined) return { value: previous, shouldCache: false, stale: true }; + return { value: null, shouldCache: false, stale: false }; +} diff --git a/src/main/domain/renderer-queue-input.test.ts b/src/main/domain/renderer-queue-input.test.ts index 32e2269..cdf23e0 100644 --- a/src/main/domain/renderer-queue-input.test.ts +++ b/src/main/domain/renderer-queue-input.test.ts @@ -87,4 +87,41 @@ describe('renderer queue input', () => { customClip: { startSec: -1, durationSec: 10, startPart: 1, filenameFormat: 'simple' }, })).toBeNull(); }); + + it('cleans interrupted merge artifacts without deleting completed published outputs', () => { + const base = createRendererQueueItem({ + url: 'https://www.twitch.tv/videos/1', + title: 'Merge', + date: '2026-08-13T00:00:00.000Z', + streamer: 'fixture_streamer', + duration_str: '1h', + }, 'merge-id')!; + const interrupted = { + ...base, + status: 'error' as const, + mergeGroup: { + items: [], + mergePhase: 'splitting' as const, + currentItemIndex: 0, + downloadedFiles: { 0: 'C:\\downloads\\merge_tmp_0_1.mp4' }, + mergedFile: 'C:\\downloads\\merged_2.mp4', + splitFiles: ['C:\\downloads\\Part01.mp4'], + splitTempFiles: ['C:\\downloads\\.merge_split_2_0.mp4'], + }, + }; + expect(getMergeGroupCleanupPaths(interrupted)).toEqual([ + 'C:\\downloads\\merge_tmp_0_1.mp4', + 'C:\\downloads\\merged_2.mp4', + 'C:\\downloads\\Part01.mp4', + 'C:\\downloads\\.merge_split_2_0.mp4', + ]); + expect(getMergeGroupCleanupPaths({ + ...interrupted, + status: 'completed', + mergeGroup: { ...interrupted.mergeGroup, mergePhase: 'done' }, + })).toEqual([ + 'C:\\downloads\\merge_tmp_0_1.mp4', + 'C:\\downloads\\merged_2.mp4', + ]); + }); }); diff --git a/src/main/domain/renderer-queue-input.ts b/src/main/domain/renderer-queue-input.ts index 797d201..d960800 100644 --- a/src/main/domain/renderer-queue-input.ts +++ b/src/main/domain/renderer-queue-input.ts @@ -60,8 +60,12 @@ export function createRendererQueueItem(value: unknown, id: string): QueueItem | export function getMergeGroupCleanupPaths(item: QueueItem | undefined): string[] { if (!item?.mergeGroup) return []; + const interruptedSplitFiles = item.mergeGroup.mergePhase === 'done' + ? [] + : [...(item.mergeGroup.splitFiles ?? []), ...(item.mergeGroup.splitTempFiles ?? [])]; return [ ...Object.values(item.mergeGroup.downloadedFiles), ...(item.mergeGroup.mergedFile ? [item.mergeGroup.mergedFile] : []), + ...interruptedSplitFiles, ]; } diff --git a/src/main/domain/runtime-safety.test.ts b/src/main/domain/runtime-safety.test.ts new file mode 100644 index 0000000..bb5aa0c --- /dev/null +++ b/src/main/domain/runtime-safety.test.ts @@ -0,0 +1,94 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { + readSecretSafely, + createManagedToolExecutionTracker, + runResilientSteps, + secureImportedConfigTransition, +} from './runtime-safety'; + +describe('runtime safety', () => { + it('isolates a secret read failure without invalidating the store', () => { + const onError = vi.fn(); + const store = { + get: vi.fn((key: string) => { + if (key === 'broken') throw new Error('foreign DPAPI ciphertext'); + return 'usable'; + }), + }; + + expect(readSecretSafely(store, 'broken', onError)).toBe(''); + expect(readSecretSafely(store, 'valid', onError)).toBe('usable'); + expect(onError).toHaveBeenCalledOnce(); + }); + + it('does not activate an imported all-files delete policy from a safe state', () => { + expect(secureImportedConfigTransition( + { auto_cleanup_enabled: false, auto_cleanup_target: 'live_only', auto_cleanup_action: 'archive' }, + { auto_cleanup_enabled: true, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' }, + )).toEqual({ auto_cleanup_enabled: false, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' }); + }); + + it('does not alter an already active cleanup policy when unrelated values are imported', () => { + expect(secureImportedConfigTransition( + { auto_cleanup_enabled: true, auto_cleanup_target: 'all', auto_cleanup_action: 'delete' }, + { language: 'en' }, + )).toEqual({ language: 'en' }); + }); + + it('runs every cleanup step after earlier failures', async () => { + const calls: string[] = []; + const errors: Array<{ name: string; error: unknown }> = []; + + await runResilientSteps([ + ['persist-config', () => { calls.push('persist-config'); throw new Error('disk full'); }], + ['persist-queue', async () => { calls.push('persist-queue'); }], + ['cleanup-partial', () => { calls.push('cleanup-partial'); }], + ], (name, error) => errors.push({ name, error })); + + expect(calls).toEqual(['persist-config', 'persist-queue', 'cleanup-partial']); + expect(errors.map((entry) => entry.name)).toEqual(['persist-config']); + }); + + it('continues cleanup when failure reporting itself throws', async () => { + const calls: string[] = []; + + await runResilientSteps([ + ['first', () => { calls.push('first'); throw new Error('cleanup failed'); }], + ['second', () => { calls.push('second'); }], + ], () => { + throw new Error('reporting failed'); + }); + + expect(calls).toEqual(['first', 'second']); + }); + + it('records native canonical paths and execution counts only while the cutter E2E gate is enabled', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-tool-diagnostics-')); + const nested = path.join(directory, 'nested'); + fs.mkdirSync(nested); + const ffmpeg = path.join(directory, 'ffmpeg.exe'); + const ffprobe = path.join(directory, 'ffprobe.exe'); + const streamlink = path.join(directory, 'streamlink.exe'); + for (const filePath of [ffmpeg, ffprobe, streamlink]) fs.writeFileSync(filePath, 'tool'); + try { + const disabled = createManagedToolExecutionTracker(false); + disabled.record('ffmpeg', ffmpeg); + expect(disabled.snapshot()).toBeNull(); + const tracker = createManagedToolExecutionTracker(true); + tracker.record('ffmpeg', path.join(nested, '..', 'ffmpeg.exe')); + tracker.record('ffmpeg', ffmpeg); + tracker.record('ffprobe', path.join(nested, '..', 'ffprobe.exe')); + tracker.record('streamlink', path.join(nested, '..', 'streamlink.exe')); + expect(tracker.snapshot()).toEqual({ + ffmpeg: { path: fs.realpathSync.native(ffmpeg), count: 2 }, + ffprobe: { path: fs.realpathSync.native(ffprobe), count: 1 }, + streamlink: { path: fs.realpathSync.native(streamlink), count: 1 }, + }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/main/domain/runtime-safety.ts b/src/main/domain/runtime-safety.ts new file mode 100644 index 0000000..97be554 --- /dev/null +++ b/src/main/domain/runtime-safety.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs'; + +export type CleanupStep = readonly [name: string, run: () => unknown | Promise]; + +export type ManagedToolExecutionKind = 'ffmpeg' | 'ffprobe' | 'streamlink'; + +export interface ManagedToolExecutionRecord { + path: string | null; + count: number; +} + +export interface ManagedToolExecutionDiagnostics { + ffmpeg: ManagedToolExecutionRecord; + ffprobe: ManagedToolExecutionRecord; + streamlink: ManagedToolExecutionRecord; +} + +interface SecretReader { + get(key: K): string | null; +} + +interface CleanupConfig { + auto_cleanup_enabled: boolean; + auto_cleanup_target: 'live_only' | 'all'; + auto_cleanup_action: 'archive' | 'delete'; +} + +export function readSecretSafely( + store: SecretReader, + key: K, + onError: (error: unknown) => void, +): string { + try { + return store.get(key) ?? ''; + } catch (error) { + onError(error); + return ''; + } +} + +export function secureImportedConfigTransition>( + current: CleanupConfig, + imported: T, +): T { + const effective = { ...current, ...imported } as CleanupConfig & T; + const currentlyDestructive = current.auto_cleanup_enabled + && current.auto_cleanup_target === 'all' + && current.auto_cleanup_action === 'delete'; + const activatesDestructiveCleanup = effective.auto_cleanup_enabled + && effective.auto_cleanup_target === 'all' + && effective.auto_cleanup_action === 'delete'; + if (currentlyDestructive || !activatesDestructiveCleanup) return imported; + return { ...imported, auto_cleanup_enabled: false }; +} + +export async function runResilientSteps( + steps: ReadonlyArray, + onError: (name: string, error: unknown) => void, +): Promise { + for (const [name, run] of steps) { + try { + await run(); + } catch (error) { + try { + onError(name, error); + } catch { } + } + } +} + +export function createManagedToolExecutionTracker(enabled: boolean): { + record(kind: ManagedToolExecutionKind, command: string): void; + snapshot(): ManagedToolExecutionDiagnostics | null; +} { + const state: ManagedToolExecutionDiagnostics = { + ffmpeg: { path: null, count: 0 }, + ffprobe: { path: null, count: 0 }, + streamlink: { path: null, count: 0 }, + }; + return { + record(kind, command) { + if (!enabled) return; + try { + state[kind] = { + path: fs.realpathSync.native(command), + count: state[kind].count + 1, + }; + } catch { } + }, + snapshot() { + if (!enabled) return null; + return { + ffmpeg: { ...state.ffmpeg }, + ffprobe: { ...state.ffprobe }, + streamlink: { ...state.streamlink }, + }; + }, + }; +} diff --git a/src/main/domain/top-clips-crawler.test.ts b/src/main/domain/top-clips-crawler.test.ts index e4c6a99..754d9a3 100644 --- a/src/main/domain/top-clips-crawler.test.ts +++ b/src/main/domain/top-clips-crawler.test.ts @@ -15,6 +15,15 @@ function fakeFetch(rows: Array>, status = 200): typeof f }) as unknown as typeof fetch; } +async function captureError(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + if (error instanceof Error) return error; + } + throw new Error('Expected the operation to reject with an Error'); +} + describe('fetchTopClips', () => { test('returns parsed clips sorted by view_count desc', async () => { const fakeRows = [ @@ -98,17 +107,27 @@ describe('fetchTopClips', () => { }); test('throws on non-2xx response', async () => { - await expect(fetchTopClips({ + const responseFetch = (async (): Promise => new Response('{"Authorization":"Bearer response-token","cookie":"response-cookie"}', { status: 503 })) as unknown as typeof fetch; + const error = await captureError(() => fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', - fetchImpl: fakeFetch([], 503), - })).rejects.toThrow(/503/); + fetchImpl: responseFetch, + })); + + expect(error.message).toBe('top-clips-crawler: helix returned HTTP 503'); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain('response-token'); + expect(JSON.stringify(error)).not.toContain('response-cookie'); }); test('throws on malformed JSON', async () => { - const brokenFetch = (async (): Promise => new Response('{not-json', { status: 200 })) as unknown as typeof fetch; - await expect(fetchTopClips({ + const brokenFetch = (async (): Promise => new Response('{"accessToken":"parse-token"', { status: 200 })) as unknown as typeof fetch; + const error = await captureError(() => fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch, - })).rejects.toThrow(/parse failed/); + })); + + expect(error.message).toBe('top-clips-crawler: invalid helix response'); + expect(error.cause).toBeUndefined(); + expect(`${error.message}${JSON.stringify(error)}`).not.toContain('parse-token'); }); test('empty data returns empty array (not null)', async () => { diff --git a/src/main/domain/top-clips-crawler.ts b/src/main/domain/top-clips-crawler.ts index 37cc86b..ec02240 100644 --- a/src/main/domain/top-clips-crawler.ts +++ b/src/main/domain/top-clips-crawler.ts @@ -92,22 +92,23 @@ export async function fetchTopClips(opts: FetchTopClipsOptions): Promise { }); } +async function captureError(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + if (error instanceof Error) return error; + } + throw new Error('Expected the operation to reject with an Error'); +} + describe('startLoginFlow', () => { test('builds Twitch authorize URL with required params + PKCE + state', async () => { const flow = await startLoginFlow({ @@ -121,11 +130,16 @@ describe('exchangeCodeForToken', () => { }); test('throws on non-2xx response', async () => { - const fakeFetch = async (): Promise => new Response('bad request', { status: 400 }); - await expect(exchangeCodeForToken({ + const fakeFetch = async (): Promise => new Response('{"refreshToken":"body-refresh","cookie":"body-cookie"}', { status: 400 }); + const error = await captureError(() => exchangeCodeForToken({ clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x', fetchImpl: fakeFetch as unknown as typeof fetch, - })).rejects.toThrow(/400/); + })); + + expect(error.message).toBe('twitch-oauth: token endpoint returned HTTP 400'); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain('body-refresh'); + expect(JSON.stringify(error)).not.toContain('body-cookie'); }); }); @@ -150,4 +164,14 @@ describe('fetchTwitchUserInfo', () => { await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch)) .rejects.toThrow(/no user/); }); + + test('never exposes a helix response body or request credential in errors', async () => { + const fakeFetch = async (): Promise => new Response('{"accessToken":"body-access","clientSecret":"body-secret"}', { status: 401 }); + const error = await captureError(() => fetchTwitchUserInfo('request-token', 'request-client', fakeFetch as unknown as typeof fetch)); + + expect(error.message).toBe('twitch-oauth: helix /users returned HTTP 401'); + expect(error.cause).toBeUndefined(); + const serialized = `${error.message}${JSON.stringify(error)}`; + for (const forbidden of ['body-access', 'body-secret', 'request-token', 'request-client']) expect(serialized).not.toContain(forbidden); + }); }); diff --git a/src/main/domain/twitch-oauth.ts b/src/main/domain/twitch-oauth.ts index 85979cf..fa5e506 100644 --- a/src/main/domain/twitch-oauth.ts +++ b/src/main/domain/twitch-oauth.ts @@ -93,9 +93,9 @@ export interface CompleteLoginResult { export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise { const params = await login.server.awaitParams({ timeoutMs }); if (params.has('error')) { - const err = params.get('error') ?? 'unknown_error'; - const desc = params.get('error_description') ?? ''; - throw new Error(`twitch-oauth: provider error: ${err}${desc ? ` — ${desc}` : ''}`); + const rawError = params.get('error') ?? ''; + const errorCode = /^[A-Za-z0-9_.-]{1,80}$/.test(rawError) ? rawError : 'unknown_error'; + throw new Error(`twitch-oauth: provider error: ${errorCode}`); } const returnedState = params.get('state') ?? ''; if (returnedState !== login.state) { @@ -126,17 +126,22 @@ export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise< redirect_uri: opts.redirectUri, }); - const res = await fetchFn(TWITCH_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - }); - - const text = await res.text(); - if (!res.ok) { - throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`); + let res: Response; + try { + res = await fetchFn(TWITCH_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + } catch { + throw new Error('twitch-oauth: token request failed'); + } + if (!res.ok) throw new Error(`twitch-oauth: token endpoint returned HTTP ${res.status}`); + try { + return JSON.parse(await res.text()) as TwitchTokenResponse; + } catch { + throw new Error('twitch-oauth: invalid token response'); } - return JSON.parse(text) as TwitchTokenResponse; } export async function fetchTwitchUserInfo( @@ -145,17 +150,24 @@ export async function fetchTwitchUserInfo( fetchImpl?: typeof fetch ): Promise { const fetchFn = fetchImpl ?? fetch; - const res = await fetchFn(TWITCH_HELIX_USERS_URL, { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Client-Id': clientId, - }, - }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`); + let res: Response; + try { + res = await fetchFn(TWITCH_HELIX_USERS_URL, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Client-Id': clientId, + }, + }); + } catch { + throw new Error('twitch-oauth: helix /users request failed'); + } + if (!res.ok) throw new Error(`twitch-oauth: helix /users returned HTTP ${res.status}`); + let json: { data?: TwitchUserInfo[] }; + try { + json = JSON.parse(await res.text()) as { data?: TwitchUserInfo[] }; + } catch { + throw new Error('twitch-oauth: invalid helix /users response'); } - const json = JSON.parse(text) as { data?: TwitchUserInfo[] }; const first = json.data?.[0]; if (!first) throw new Error('twitch-oauth: helix /users returned no user'); return first; diff --git a/src/main/domain/twitch-refresh.production-path.test.ts b/src/main/domain/twitch-refresh.production-path.test.ts new file mode 100644 index 0000000..54cbb86 --- /dev/null +++ b/src/main/domain/twitch-refresh.production-path.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('Twitch refresh production path', () => { + it('deduplicates force and normal VOD refreshes and keeps a retained last-good value', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const start = source.indexOf('async function getVODs'); + const end = source.indexOf('interface LiveStreamInfo', start); + const getVods = source.slice(start, end); + + expect(getVods).toContain("withInFlightDedup(inFlightVodRequests, cacheKey"); + expect(getVods).not.toContain("force' : 'default'"); + expect(getVods).toContain('vodListLastGood.get(cacheKey)'); + expect(getVods).toContain('requestTwitchHelixVideos(axios'); + expect(getVods).toContain('refreshTwitchProviderData('); + }); + + it('retains profiles outside their expiring cache and deletes an authoritative not-found profile', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const start = source.indexOf('async function getStreamerProfile'); + const end = source.indexOf('// ==========================================\n// VOD STORYBOARD', start); + const getProfile = source.slice(start, end); + + expect(getProfile).toContain('streamerProfileLastGood.get(normalized)'); + expect(getProfile).toContain('streamerProfileLastGood.delete(normalized)'); + expect(getProfile).toContain('streamerProfileLastGood.set(normalized, profile)'); + }); +}); diff --git a/src/main/infra/format-helpers.test.ts b/src/main/infra/format-helpers.test.ts index 889ab01..9dfdf5f 100644 --- a/src/main/infra/format-helpers.test.ts +++ b/src/main/infra/format-helpers.test.ts @@ -84,9 +84,9 @@ describe('formatDateWithPattern', () => { describe('getMergeGroupPhaseText', () => { test('known DE phases', () => { expect(getMergeGroupPhaseText('downloading', 'de')).toBe('VOD wird heruntergeladen'); - expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfugen...'); + expect(getMergeGroupPhaseText('merging', 'de')).toBe('Zusammenfügen...'); expect(getMergeGroupPhaseText('splitting', 'de')).toBe('Part wird erstellt'); - expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufraumen...'); + expect(getMergeGroupPhaseText('cleanup', 'de')).toBe('Aufräumen...'); }); test('known EN phases', () => { expect(getMergeGroupPhaseText('downloading', 'en')).toBe('Downloading VOD'); diff --git a/src/main/infra/format-helpers.ts b/src/main/infra/format-helpers.ts index b059ac5..33c860b 100644 --- a/src/main/infra/format-helpers.ts +++ b/src/main/infra/format-helpers.ts @@ -69,9 +69,9 @@ export function getMergeGroupPhaseText(phase: string, language: MergeGroupLangua const isEnglish = language === 'en'; switch (phase) { case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; - case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; + case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfügen...'; case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; - case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; + case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufräumen...'; default: return phase; } } diff --git a/src/main/queue/index.ts b/src/main/queue/index.ts new file mode 100644 index 0000000..301768f --- /dev/null +++ b/src/main/queue/index.ts @@ -0,0 +1,7 @@ +export { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry'; +export { createRendererQueueItem, getMergeGroupCleanupPaths } from '../domain/renderer-queue-input'; +export { commitQueueAddition } from '../domain/queue-addition'; +export type { QueueAdditionResult } from '../domain/queue-addition'; +export { getInterruptedMergeItemIds, recoverInterruptedMergeArtifacts, resolveMergeArtifactRoot } from '../domain/merge-recovery'; +export { createPhaseBoundaryProcessResource, waitForPhaseBoundary } from '../domain/phase-boundary-process'; +export { applyQueueSnapshotPreservingActiveItems, commitQueueMutation, persistStateChange } from '../domain/persistence-commit'; diff --git a/src/main/queue/process-lifecycle.integration.test.ts b/src/main/queue/process-lifecycle.integration.test.ts index 33794a1..19635b1 100644 --- a/src/main/queue/process-lifecycle.integration.test.ts +++ b/src/main/queue/process-lifecycle.integration.test.ts @@ -3,7 +3,7 @@ import { once } from 'node:events'; import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './process-registry'; function waitForExit(process: ReturnType): Promise { @@ -15,6 +15,10 @@ function waitForExit(process: ReturnType): Promise { } describe('queue process lifecycle integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('keeps quick resume behind a real child pause without deleting retry output', async () => { const directory = mkdtempSync(join(tmpdir(), 'tvm-queue-pause-')); const retryFile = join(directory, 'merge-retry.mp4'); @@ -25,14 +29,18 @@ describe('queue process lifecycle integration', () => { try { writeFileSync(retryFile, 'retry'); await once(child, 'spawn'); + const pauseSettled = vi.fn(); + const resumeStarted = vi.fn(); registry.register('item-a', 'merge', { kill: () => child.kill(), wait: () => waitForChildProcessExit(child, 30), pause: async () => { child.kill(); - await waitForChildProcessExit(child, 30); + await waitForChildProcessExit(child, 30, 250); + pauseSettled(); }, resume: () => { + resumeStarted(); resumedAfterExit = child.exitCode !== null || child.signalCode !== null; }, cleanup: () => rmSync(retryFile, { force: true }), @@ -45,6 +53,7 @@ describe('queue process lifecycle integration', () => { await Promise.all([pausing, resuming]); expect(resumedAfterExit).toBe(true); + expect(pauseSettled).toHaveBeenCalledBefore(resumeStarted); expect(registry.isPaused('item-a')).toBe(false); expect(existsSync(retryFile)).toBe(true); } finally { @@ -69,7 +78,7 @@ describe('queue process lifecycle integration', () => { lifecycle.schedule(async () => childExited); registry.register('item-a', 'merge', { kill: () => undefined, - wait: () => waitForChildProcessExit(child, 30), + wait: () => waitForChildProcessExit(child, 30, 250), cleanup: () => { expect(child.exitCode !== null || child.signalCode !== null).toBe(true); rmSync(partialFile, { force: true }); diff --git a/src/main/queue/process-registry.test.ts b/src/main/queue/process-registry.test.ts index eee3c51..087a4c1 100644 --- a/src/main/queue/process-registry.test.ts +++ b/src/main/queue/process-registry.test.ts @@ -67,7 +67,7 @@ describe('waitForChildProcessExit', () => { } }); - it('settles and releases resources when close never arrives after forced termination', async () => { + it('settles on process exit without waiting for delayed stream closure', async () => { vi.useFakeTimers(); try { const child = Object.assign(new EventEmitter(), { @@ -75,23 +75,41 @@ describe('waitForChildProcessExit', () => { signalCode: null, kill: vi.fn(() => true), }) as unknown as ChildProcess; - let settled = false; - const waiting = waitForChildProcessExit(child, 25).then(() => { - settled = true; - }); + const waiting = waitForChildProcessExit(child, 25); + + child.emit('exit', null, 'SIGTERM'); + await waiting; + + expect(child.kill).not.toHaveBeenCalled(); + expect(child.listenerCount('close')).toBe(0); + expect(child.listenerCount('exit')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects within a bounded deadline when close never arrives after forced termination', async () => { + vi.useFakeTimers(); + try { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + }) as unknown as ChildProcess; + const waiting = waitForChildProcessExit(child, 25); + const rejected = expect(waiting).rejects.toThrow('Child process did not exit after forced termination'); await vi.advanceTimersByTimeAsync(25); expect(child.kill).toHaveBeenCalledOnce(); expect(child.kill).toHaveBeenCalledWith('SIGKILL'); - expect(settled).toBe(false); await vi.advanceTimersByTimeAsync(25); - expect(settled).toBe(true); expect(child.listenerCount('close')).toBe(0); expect(vi.getTimerCount()).toBe(0); - await waiting; + await rejected; } finally { vi.useRealTimers(); } @@ -179,6 +197,57 @@ describe('QueueProcessRegistry', () => { expect(registry.isPaused('item-a')).toBe(false); }); + it('does not resume resources when a pause operation fails', async () => { + const registry = new QueueProcessRegistry(); + const pauseError = new Error('process exit was not confirmed'); + const resource = createResource(); + resource.pause = vi.fn(() => Promise.reject(pauseError)); + + registry.register('item-a', 'merge', resource); + const pausing = registry.pauseItem('item-a'); + const resuming = registry.resumeItem('item-a'); + + await expect(pausing).rejects.toBe(pauseError); + await expect(resuming).rejects.toBe(pauseError); + + expect(resource.resume).not.toHaveBeenCalled(); + expect(registry.isPaused('item-a')).toBe(true); + }); + + it('resumes after a later pause retry succeeds', async () => { + const registry = new QueueProcessRegistry(); + const resource = createResource(); + resource.pause = vi.fn() + .mockRejectedValueOnce(new Error('process exit was not confirmed')) + .mockResolvedValueOnce(undefined); + + registry.register('item-a', 'merge', resource); + await expect(registry.pauseItem('item-a')).rejects.toThrow('process exit was not confirmed'); + await expect(registry.pauseItem('item-a')).resolves.toBeUndefined(); + await expect(registry.resumeItem('item-a')).resolves.toBeUndefined(); + + expect(resource.pause).toHaveBeenCalledTimes(2); + expect(resource.resume).toHaveBeenCalledOnce(); + expect(registry.isPaused('item-a')).toBe(false); + }); + + it('keeps a paused boundary controllable after the completed process registration releases', async () => { + const registry = new QueueProcessRegistry(); + const registration = registry.register('item-a', 'merge', createResource()); + await registry.pauseItem('item-a'); + registration.release(); + + expect(registry.activeItemIds()).toEqual(['item-a']); + + let resumed = false; + const waiting = registry.whenResumed('item-a').then(() => { resumed = true; }); + await registry.resumeItem('item-a'); + await waiting; + + expect(resumed).toBe(true); + expect(registry.activeItemIds()).toEqual([]); + }); + it.each(['merge', 'split'] as const)('waits for %s termination before removing partial output', async (phase) => { const registry = new QueueProcessRegistry(); const closed = deferred(); @@ -199,6 +268,18 @@ describe('QueueProcessRegistry', () => { expect(registry.activeItemIds()).toEqual([]); }); + it('retains partial output when process exit cannot be confirmed', async () => { + const registry = new QueueProcessRegistry(); + const resource = createResource(Promise.reject(new Error('exit timeout'))); + + registry.register('item-a', 'merge', resource); + await registry.cancelItem('item-a'); + + expect(resource.kill).toHaveBeenCalledOnce(); + expect(resource.cleanup).not.toHaveBeenCalled(); + expect(registry.activeItemIds()).toEqual([]); + }); + it('allows an explicitly reset item to retry without affecting another item', async () => { const registry = new QueueProcessRegistry(); const firstAttempt = createResource(); @@ -266,4 +347,47 @@ describe('QueueRunLifecycle', () => { expect(persist).toHaveBeenCalledOnce(); expect(registry.activeItemIds()).toEqual([]); }); + + it('reports a persistence failure without rejecting shutdown', async () => { + const registry = new QueueProcessRegistry(); + const lifecycle = new QueueRunLifecycle(registry); + const persistenceError = new Error('disk unavailable'); + const reportError = vi.fn(); + + await expect(lifecycle.shutdown( + () => undefined, + () => { throw persistenceError; }, + reportError, + )).resolves.toBeUndefined(); + + expect(reportError).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledWith(persistenceError); + }); + + it('finishes shutdown when process exit and the scheduled run never settle', async () => { + vi.useFakeTimers(); + try { + const registry = new QueueProcessRegistry(); + const lifecycle = new QueueRunLifecycle(registry, 25); + const neverFinishes = deferred(); + const resource = createResource(Promise.reject(new Error('exit timeout'))); + const persist = vi.fn(async () => undefined); + const reportTimeout = vi.fn(); + + lifecycle.schedule(async () => neverFinishes.promise); + registry.register('item-a', 'merge', resource); + + const shutdown = lifecycle.shutdown(() => undefined, persist, undefined, reportTimeout); + await vi.advanceTimersByTimeAsync(25); + await shutdown; + + expect(resource.kill).toHaveBeenCalledOnce(); + expect(resource.cleanup).not.toHaveBeenCalled(); + expect(persist).toHaveBeenCalledOnce(); + expect(reportTimeout).toHaveBeenCalledWith(expect.objectContaining({ message: 'Queue run did not settle after process cancellation' })); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/main/queue/process-registry.ts b/src/main/queue/process-registry.ts index 9110597..c15b72b 100644 --- a/src/main/queue/process-registry.ts +++ b/src/main/queue/process-registry.ts @@ -2,28 +2,39 @@ import type { ChildProcess } from 'node:child_process'; export type QueueProcessPhase = 'streamlink' | 'merge' | 'split' | 'post-processing'; -export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000): Promise { +export function waitForChildProcessExit(process: ChildProcess | null, forceKillAfterMs = 5000, confirmExitAfterKillMs = forceKillAfterMs): Promise { if (!process || process.exitCode !== null || process.signalCode !== null) return Promise.resolve(); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let forceKillTimer: ReturnType | null = null; let settleTimer: ReturnType | null = null; let settled = false; - const finish = (): void => { - if (settled) return; - settled = true; + const release = (): void => { if (forceKillTimer) clearTimeout(forceKillTimer); if (settleTimer) clearTimeout(settleTimer); process.removeListener('close', finish); + process.removeListener('exit', finish); + }; + const finish = (): void => { + if (settled) return; + settled = true; + release(); resolve(); }; + const fail = (): void => { + if (settled) return; + settled = true; + release(); + reject(new Error('Child process did not exit after forced termination')); + }; process.once('close', finish); + process.once('exit', finish); forceKillTimer = setTimeout(() => { forceKillTimer = null; if (process.exitCode !== null || process.signalCode !== null) { finish(); return; } - settleTimer = setTimeout(finish, forceKillAfterMs); + settleTimer = setTimeout(fail, confirmExitAfterKillMs); try { process.kill('SIGKILL'); } catch { } }, forceKillAfterMs); }); @@ -43,6 +54,20 @@ export interface QueueProcessRegistration { release: () => void; } +async function waitForSettlementWithin(promise: Promise, timeoutMs: number): Promise { + return await new Promise((resolve) => { + let settled = false; + const finish = (completed: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(completed); + }; + const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs)); + promise.then(() => finish(true), () => finish(true)); + }); +} + interface RegisteredResource { itemId: string; phase: QueueProcessPhase; @@ -162,9 +187,11 @@ export class QueueProcessRegistry { } activeItemIds(): string[] { - return [...this.groups.entries()] + const active = new Set([...this.groups.entries()] .filter(([, entries]) => entries.size > 0) - .map(([itemId]) => itemId); + .map(([itemId]) => itemId)); + for (const itemId of this.pausedItems) active.add(itemId); + return [...active]; } private async invokeItem(itemId: string, operation: 'pause' | 'resume'): Promise { @@ -179,8 +206,11 @@ export class QueueProcessRegistry { entry.stopping = (async () => { try { entry.resource.kill?.(); } catch { } try { await entry.resource.cancel?.(); } catch { } - try { await entry.resource.wait?.(); } catch { } - try { await entry.resource.cleanup?.(); } catch { } + let exited = true; + try { await entry.resource.wait?.(); } catch { exited = false; } + if (exited) { + try { await entry.resource.cleanup?.(); } catch { } + } this.release(entry); })(); return entry.stopping; @@ -192,13 +222,17 @@ export class QueueProcessRegistry { } private enqueuePause(itemId: string, entries: RegisteredResource[]): Promise { - const previous = this.pauseRuns.get(itemId) || Promise.resolve(); + const previous = this.pauseRuns.get(itemId)?.catch(() => undefined) || Promise.resolve(); const pauseRun = Promise.allSettled([ previous, ...entries.map(async ({ resource }) => { await resource.pause?.(); }), - ]).then(() => undefined); + ]).then((results) => { + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + } + }); this.pauseRuns.set(itemId, pauseRun); return pauseRun; } @@ -233,7 +267,10 @@ export class QueueRunLifecycle { private currentRun: Promise | null = null; private shutdownRun: Promise | null = null; - constructor(private readonly registry: QueueProcessRegistry) { } + constructor( + private readonly registry: QueueProcessRegistry, + private readonly currentRunShutdownTimeoutMs = 5000, + ) { } schedule(run: () => Promise, onError?: (error: unknown) => void): boolean { if (this.shutdownRun || this.currentRun) return false; @@ -247,15 +284,27 @@ export class QueueRunLifecycle { return true; } - shutdown(beforeCancel: () => unknown | Promise, persist: () => unknown | Promise): Promise { + shutdown( + beforeCancel: () => unknown | Promise, + persist: () => unknown | Promise, + onPersistError?: (error: unknown) => void, + onRunTimeout?: (error: unknown) => void, + ): Promise { if (this.shutdownRun) return this.shutdownRun; this.registry.beginShutdown(); this.shutdownRun = (async () => { try { await beforeCancel(); } catch { } await this.registry.cancelAll(); - if (this.currentRun) await this.currentRun; + const currentRun = this.currentRun; + if (currentRun && !(await waitForSettlementWithin(currentRun, this.currentRunShutdownTimeoutMs))) { + onRunTimeout?.(new Error('Queue run did not settle after process cancellation')); + } await this.registry.waitForIdle(); - await persist(); + try { + await persist(); + } catch (error) { + onPersistError?.(error); + } })(); return this.shutdownRun; } diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts new file mode 100644 index 0000000..fd7823a --- /dev/null +++ b/src/main/storage/index.ts @@ -0,0 +1,11 @@ +export { openDatabase } from '../infra/db'; +export type { DbHandle } from '../infra/db'; +export { createAppStateStore } from '../domain/app-state-store'; +export type { AppStateStore } from '../domain/app-state-store'; +export { createExportableConfig } from '../domain/config-export'; +export { normalizeStreamerLogins, sanitizeConfigInput, sanitizeImportedConfig } from '../domain/config-input'; +export { resolveSecretInputUpdate } from '../domain/secret-input'; +export { createSecretStore } from '../domain/secret-store'; +export type { SecretStore } from '../domain/secret-store'; +export { migrateJsonToSqlite } from '../domain/migrator'; +export { createElectronSecureStorage } from '../infra/secure-storage'; diff --git a/src/main/twitch/app-token.test.ts b/src/main/twitch/app-token.test.ts new file mode 100644 index 0000000..c614173 --- /dev/null +++ b/src/main/twitch/app-token.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; +import { requestTwitchAppAccessToken, TwitchAppTokenService, type TwitchAppTokenCredentials } from './app-token'; + +function credentials(clientId = 'client-id', clientSecret = 'client-secret'): TwitchAppTokenCredentials { + return { clientId, clientSecret }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void } { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((accept, decline) => { + resolve = accept; + reject = decline; + }); + return { promise, resolve, reject }; +} + +describe('TwitchAppTokenService', () => { + it('caches a successful token for the active credentials', async () => { + const requestToken = vi.fn().mockResolvedValue('token-one'); + const service = new TwitchAppTokenService(requestToken); + + expect(await service.ensure(credentials())).toBe('token-one'); + expect(await service.ensure(credentials())).toBe('token-one'); + expect(service.currentToken).toBe('token-one'); + expect(requestToken).toHaveBeenCalledTimes(1); + }); + + it('deduplicates parallel requests for the same credentials', async () => { + const pending = deferred(); + const requestToken = vi.fn().mockReturnValue(pending.promise); + const service = new TwitchAppTokenService(requestToken); + + const first = service.ensure(credentials()); + const second = service.ensure(credentials()); + pending.resolve('shared-token'); + + await expect(Promise.all([first, second])).resolves.toEqual(['shared-token', 'shared-token']); + expect(requestToken).toHaveBeenCalledTimes(1); + }); + + it('refreshes a cached token once and deduplicates parallel forced refreshes', async () => { + const refresh = deferred(); + const requestToken = vi.fn() + .mockResolvedValueOnce('token-one') + .mockReturnValueOnce(refresh.promise); + const service = new TwitchAppTokenService(requestToken); + await service.ensure(credentials()); + + const first = service.ensure(credentials(), true); + const second = service.ensure(credentials(), true); + refresh.resolve('token-two'); + + await expect(Promise.all([first, second])).resolves.toEqual(['token-two', 'token-two']); + expect(service.currentToken).toBe('token-two'); + expect(requestToken).toHaveBeenCalledTimes(2); + }); + + it('discards an in-flight token after clear', async () => { + const stale = deferred(); + const requestToken = vi.fn() + .mockReturnValueOnce(stale.promise) + .mockResolvedValueOnce('fresh-token'); + const service = new TwitchAppTokenService(requestToken); + + const first = service.ensure(credentials()); + service.clear(); + stale.resolve('stale-token'); + + await expect(first).resolves.toBeNull(); + expect(service.currentToken).toBeNull(); + await expect(service.ensure(credentials())).resolves.toBe('fresh-token'); + }); + + it('ignores an obsolete request error after clear', async () => { + const stale = deferred(); + const errors: unknown[] = []; + const service = new TwitchAppTokenService(() => stale.promise, (error) => errors.push(error)); + + const first = service.ensure(credentials()); + service.clear(); + stale.reject(new Error('obsolete request failed')); + + await expect(first).resolves.toBeNull(); + expect(errors).toEqual([]); + }); + + it('clears the cache and skips requests when credentials are missing', async () => { + const requestToken = vi.fn().mockResolvedValue('token-one'); + const service = new TwitchAppTokenService(requestToken); + await service.ensure(credentials()); + + await expect(service.ensure(credentials('', ''))).resolves.toBeNull(); + expect(service.currentToken).toBeNull(); + expect(requestToken).toHaveBeenCalledTimes(1); + }); + + it('returns null and reports only a projected safe error', async () => { + const errors: unknown[] = []; + const requestToken = vi.fn().mockRejectedValue({ + name: 'AxiosError', + isAxiosError: true, + message: 'client_secret=provider-secret Authorization: Bearer provider-token', + config: { params: { client_secret: 'provider-secret' } }, + response: { status: 401, data: { access_token: 'response-token' } }, + }); + const service = new TwitchAppTokenService(requestToken, (error) => errors.push(error)); + + await expect(service.ensure(credentials())).resolves.toBeNull(); + expect(service.currentToken).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ provider: 'twitch-oauth', status: 401 }); + expect(JSON.stringify(errors[0])).not.toMatch(/provider-secret|provider-token|response-token|config|response/); + }); + + it('rejects malformed token responses without exposing them', async () => { + const errors: unknown[] = []; + const requestToken = vi.fn().mockResolvedValue(' '); + const service = new TwitchAppTokenService(requestToken, (error) => errors.push(error)); + + await expect(service.ensure(credentials())).resolves.toBeNull(); + expect(errors).toEqual([{ + provider: 'twitch-oauth', + message: 'Twitch app token response was invalid', + }]); + }); +}); + +describe('requestTwitchAppAccessToken', () => { + it('uses the Twitch client-credentials endpoint and parses its token', async () => { + const post = vi.fn().mockResolvedValue({ data: { access_token: ' live-token ' } }); + + await expect(requestTwitchAppAccessToken({ post }, credentials(), 1234)).resolves.toBe('live-token'); + expect(post).toHaveBeenCalledWith('https://id.twitch.tv/oauth2/token', null, { + params: { + client_id: 'client-id', + client_secret: 'client-secret', + grant_type: 'client_credentials', + }, + timeout: 1234, + }); + }); + + it('rejects malformed response envelopes', async () => { + await expect(requestTwitchAppAccessToken({ post: vi.fn().mockResolvedValue({ data: {} }) }, credentials(), 1000)) + .rejects.toThrow('Twitch app token response was invalid'); + }); +}); diff --git a/src/main/twitch/app-token.ts b/src/main/twitch/app-token.ts new file mode 100644 index 0000000..c0c98ae --- /dev/null +++ b/src/main/twitch/app-token.ts @@ -0,0 +1,121 @@ +import { projectExternalError, type SafeExternalError } from '../domain/external-error'; + +export interface TwitchAppTokenCredentials { + clientId: string; + clientSecret: string; +} + +export type TwitchAppTokenRequester = (credentials: TwitchAppTokenCredentials) => Promise; +export type TwitchAppTokenErrorHandler = (error: SafeExternalError) => void; + +export interface TwitchAppTokenHttpClient { + post(url: string, data: null, config: { + params: { client_id: string; client_secret: string; grant_type: 'client_credentials' }; + timeout: number; + }): Promise; +} + +export async function requestTwitchAppAccessToken( + client: TwitchAppTokenHttpClient, + credentials: TwitchAppTokenCredentials, + timeoutMs: number, +): Promise { + const response = await client.post('https://id.twitch.tv/oauth2/token', null, { + params: { + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + grant_type: 'client_credentials', + }, + timeout: timeoutMs, + }); + if (!response || typeof response !== 'object' || Array.isArray(response)) { + throw new Error('Twitch app token response was invalid'); + } + const data = (response as Record).data; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('Twitch app token response was invalid'); + } + const token = (data as Record).access_token; + if (typeof token !== 'string' || !token.trim()) { + throw new Error('Twitch app token response was invalid'); + } + return token.trim(); +} + +function hasCredentials(credentials: TwitchAppTokenCredentials): boolean { + return credentials.clientId.trim().length > 0 && credentials.clientSecret.trim().length > 0; +} + +function sameCredentials(left: TwitchAppTokenCredentials | null, right: TwitchAppTokenCredentials): boolean { + return left?.clientId === right.clientId && left.clientSecret === right.clientSecret; +} + +export class TwitchAppTokenService { + private token: string | null = null; + private credentials: TwitchAppTokenCredentials | null = null; + private activeRequest: Promise | null = null; + private generation = 0; + + constructor( + private readonly requestToken: TwitchAppTokenRequester, + private readonly onError?: TwitchAppTokenErrorHandler, + ) { } + + get currentToken(): string | null { + return this.token; + } + + ensure(credentials: TwitchAppTokenCredentials, forceRefresh = false): Promise { + if (!hasCredentials(credentials)) { + this.clear(); + return Promise.resolve(null); + } + + if (!sameCredentials(this.credentials, credentials)) { + this.invalidate(); + this.credentials = { ...credentials }; + } + + if (this.activeRequest) return this.activeRequest; + if (!forceRefresh && this.token) return Promise.resolve(this.token); + + const requestGeneration = this.generation; + const requestCredentials = { ...credentials }; + const request = this.resolveRequest(requestCredentials, requestGeneration); + const tracked = request.finally(() => { + if (this.activeRequest === tracked) this.activeRequest = null; + }); + this.activeRequest = tracked; + return tracked; + } + + clear(): void { + this.invalidate(); + this.credentials = null; + } + + private invalidate(): void { + this.generation += 1; + this.token = null; + this.activeRequest = null; + } + + private async resolveRequest(credentials: TwitchAppTokenCredentials, generation: number): Promise { + try { + const response = await this.requestToken(credentials); + if (typeof response !== 'string' || !response.trim()) { + throw new Error('Twitch app token response was invalid'); + } + if (this.generation !== generation) return null; + this.token = response.trim(); + return this.token; + } catch (error) { + if (this.generation !== generation) return null; + this.token = null; + try { + this.onError?.(projectExternalError('twitch-oauth', error)); + } catch { } + return null; + } + } +} diff --git a/src/main/twitch/index.ts b/src/main/twitch/index.ts new file mode 100644 index 0000000..2ea303b --- /dev/null +++ b/src/main/twitch/index.ts @@ -0,0 +1,8 @@ +export { requestTwitchAppAccessToken, TwitchAppTokenService } from './app-token'; +export type { TwitchAppTokenCredentials, TwitchAppTokenHttpClient } from './app-token'; +export { createTwitchProviderRefreshService, refreshTwitchProviderData, requestPublicTwitchGraphql, requestPublicTwitchVodsByLogin, requestTwitchHelixUsers, requestTwitchHelixVideos } from './provider-refresh'; +export type { TwitchGraphqlHttpClient, TwitchHelixAuth, TwitchHelixHttpClient, TwitchHelixRefreshOutcome, TwitchHelixUser, TwitchProviderRefreshDependencies, TwitchProviderRefreshResult, TwitchVod } from './provider-refresh'; +export { buildVodPreviewFrameUrls } from '../domain/vod-preview'; +export { resolveRefreshOutcome } from '../domain/refresh-result'; +export type { RefreshOutcome } from '../domain/refresh-result'; +export { parseGraphqlDataEnvelope, parseGraphqlUser, parseHelixDataArray } from '../domain/provider-payload'; diff --git a/src/main/twitch/provider-refresh.test.ts b/src/main/twitch/provider-refresh.test.ts new file mode 100644 index 0000000..7b5c548 --- /dev/null +++ b/src/main/twitch/provider-refresh.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTwitchProviderRefreshService, requestPublicTwitchGraphql, requestPublicTwitchVodsByLogin, requestTwitchHelixUsers, requestTwitchHelixVideos } from './provider-refresh'; + +describe('Twitch provider refresh product path', () => { + it('requests and parses a public GraphQL data envelope', async () => { + const post = vi.fn().mockResolvedValue({ data: { data: { user: { id: '42' } } } }); + + await expect(requestPublicTwitchGraphql({ post }, 'query', { login: 'alice' }, 1200, 1)) + .resolves.toEqual({ status: 'success', value: { user: { id: '42' } } }); + expect(post).toHaveBeenCalledWith('https://gql.twitch.tv/gql', { query: 'query', variables: { login: 'alice' } }, { + headers: { 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko', 'Content-Type': 'application/json' }, + timeout: 1200, + }); + }); + + it('encapsulates the product VOD query and projects public rows', async () => { + const post = vi.fn().mockResolvedValue({ data: { data: { user: { videos: { edges: [{ node: { id: '42', title: 'Archive', publishedAt: '2026-01-01T00:00:00Z', lengthSeconds: 3661, viewCount: 7, previewThumbnailURL: 'https://example.com/42.jpg' } }] } } } } }); + + await expect(requestPublicTwitchVodsByLogin({ post }, 'alice', 1, 1200, 1)).resolves.toEqual({ + status: 'success', + value: [{ id: '42', title: 'Archive', created_at: '2026-01-01T00:00:00Z', duration: '1h1m1s', thumbnail_url: 'https://example.com/42.jpg', url: 'https://www.twitch.tv/videos/42', view_count: 7, stream_id: '', user_login: 'alice' }], + }); + expect(post.mock.calls[0][1].query).toContain('videos(first:$first, type:ARCHIVE, sort:TIME)'); + expect(post.mock.calls[0][1].variables).toEqual({ login: 'alice', first: 1 }); + }); + + it.each([ + { lengthSeconds: null, viewCount: 7 }, + { lengthSeconds: '', viewCount: 7 }, + { lengthSeconds: 3661, viewCount: null }, + { lengthSeconds: 3661, viewCount: '7' }, + ])('rejects non-numeric public VOD metrics: %o', async ({ lengthSeconds, viewCount }) => { + const post = vi.fn().mockResolvedValue({ + data: { + data: { + user: { + videos: { + edges: [{ node: { id: '42', lengthSeconds, viewCount } }], + }, + }, + }, + }, + }); + + await expect(requestPublicTwitchVodsByLogin({ post }, 'alice', 1, 1200, 1)) + .resolves.toEqual({ status: 'unavailable' }); + }); + + it('refreshes Helix after a 401, falls back to public, and retains last-good on provider outage', async () => { + const publicValues = [ + { status: 'success' as const, value: [{ id: 'public-1' }] }, + { status: 'unavailable' as const }, + ]; + const helixValues = [ + { status: 'success' as const, value: [{ id: 'helix-1' }] }, + { status: 'unauthorized' as const }, + { status: 'unavailable' as const }, + { status: 'unavailable' as const }, + ]; + const refreshToken = vi.fn().mockResolvedValue(true); + const service = createTwitchProviderRefreshService<{ id: string }>({ + requestPublic: vi.fn(async () => publicValues.shift() ?? { status: 'unavailable' as const }), + requestHelix: vi.fn(async () => helixValues.shift() ?? { status: 'unavailable' as const }), + refreshToken, + maxLastGoodEntries: 4, + }); + + await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'helix-1' }], source: 'helix', stale: false }); + await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'public-1' }], source: 'public', stale: false }); + await expect(service.refresh('alice')).resolves.toEqual({ value: [{ id: 'public-1' }], source: 'last-good', stale: true }); + expect(refreshToken).toHaveBeenCalledOnce(); + }); + + it('requests and validates Helix users and paginated archive videos', async () => { + const get = vi.fn() + .mockResolvedValueOnce({ data: { data: [{ id: '42', login: 'alice', display_name: 'Alice', description: '', profile_image_url: 'https://example.com/a.png', broadcaster_type: 'partner' }] } }) + .mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'One', created_at: '2026-01-01T00:00:00Z', duration: '1h', thumbnail_url: '', url: 'https://www.twitch.tv/videos/1', view_count: 2, stream_id: '', user_login: 'alice' }], pagination: { cursor: 'next' } } }) + .mockResolvedValueOnce({ data: { data: [{ id: '2', title: 'Two', created_at: '2026-01-02T00:00:00Z', duration: '2h', thumbnail_url: '', url: 'https://www.twitch.tv/videos/2', view_count: 3, stream_id: '', user_login: 'alice' }], pagination: {} } }); + const auth = { clientId: 'client', accessToken: 'token' }; + + await expect(requestTwitchHelixUsers({ get }, 'alice', auth, 1000)).resolves.toMatchObject({ status: 'success', value: [{ id: '42' }] }); + await expect(requestTwitchHelixVideos({ get }, '42', auth, 1000)).resolves.toMatchObject({ status: 'success', value: [{ id: '1' }, { id: '2' }] }); + expect(get).toHaveBeenNthCalledWith(3, 'https://api.twitch.tv/helix/videos', expect.objectContaining({ params: expect.objectContaining({ after: 'next' }) })); + }); + + it('reports Helix authorization expiry without projecting provider payloads', async () => { + const get = vi.fn().mockRejectedValue({ response: { status: 401, data: { token: 'secret' } } }); + + await expect(requestTwitchHelixUsers({ get }, 'alice', { clientId: 'client', accessToken: 'token' }, 1000)) + .resolves.toEqual({ status: 'unauthorized' }); + }); +}); diff --git a/src/main/twitch/provider-refresh.ts b/src/main/twitch/provider-refresh.ts new file mode 100644 index 0000000..54938d0 --- /dev/null +++ b/src/main/twitch/provider-refresh.ts @@ -0,0 +1,282 @@ +import { LastGoodCache } from '../domain/last-good-cache'; +import { parseGraphqlDataEnvelope, parseHelixDataArray } from '../domain/provider-payload'; +import type { RefreshOutcome } from '../domain/refresh-result'; + +const TWITCH_PUBLIC_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; +const TWITCH_PUBLIC_VODS_QUERY = 'query($login:String!,$first:Int!){ user(login:$login){ videos(first:$first, type:ARCHIVE, sort:TIME){ edges{ node{ id title publishedAt lengthSeconds viewCount previewThumbnailURL(width:320,height:180) } } } } }'; + +export interface TwitchGraphqlHttpClient { + post(url: string, body: { query: string; variables: Record }, config: { + headers: { 'Client-ID': string; 'Content-Type': 'application/json' }; + timeout: number; + }): Promise<{ data?: unknown }>; +} + +export interface TwitchHelixHttpClient { + get(url: string, config: { + params: Record; + headers: { 'Client-ID': string; Authorization: string }; + timeout: number; + }): Promise<{ data?: unknown }>; +} + +export interface TwitchHelixAuth { + clientId: string; + accessToken: string; +} + +export interface TwitchHelixUser { + id: string; + login: string; + display_name: string; + description: string; + profile_image_url: string; + broadcaster_type: string; +} + +export interface TwitchVod { + id: string; + title: string; + created_at: string; + duration: string; + thumbnail_url: string; + url: string; + view_count: number; + stream_id: string; + user_login?: string; +} + +export type TwitchHelixRefreshOutcome = RefreshOutcome | { status: 'unauthorized' }; + +export interface TwitchProviderRefreshDependencies { + requestPublic(key: string): Promise>; + requestHelix(key: string): Promise>; + refreshToken(): Promise; + maxLastGoodEntries: number; +} + +export interface TwitchProviderRefreshResult { + value: T[] | null; + source: 'helix' | 'public' | 'last-good' | 'not-found' | 'unavailable'; + stale: boolean; +} + +type TwitchProviderRefreshOperations = Omit, 'maxLastGoodEntries'>; + +function isTransientHttpError(error: unknown): boolean { + if (!error || typeof error !== 'object') return true; + const response = (error as { response?: { status?: unknown } }).response; + const status = Number(response?.status); + return !Number.isFinite(status) || status === 408 || status === 429 || (status >= 500 && status < 600); +} + +function httpStatus(error: unknown): number | null { + if (!error || typeof error !== 'object') return null; + const status = Number((error as { response?: { status?: unknown } }).response?.status); + return Number.isFinite(status) ? status : null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; +} + +function helixConfig(auth: TwitchHelixAuth, params: Record, timeout: number) { + return { + params, + headers: { 'Client-ID': auth.clientId, Authorization: `Bearer ${auth.accessToken}` }, + timeout, + }; +} + +export async function requestTwitchHelixUsers( + client: TwitchHelixHttpClient, + login: string, + auth: TwitchHelixAuth, + timeoutMs: number, +): Promise> { + try { + const response = await client.get('https://api.twitch.tv/helix/users', helixConfig(auth, { login }, timeoutMs)); + const parsed = parseHelixDataArray(response.data); + if (parsed.status !== 'success') return parsed; + if (parsed.value.length === 0) return { status: 'not-found' }; + const users: TwitchHelixUser[] = []; + for (const value of parsed.value) { + const user = asRecord(value); + if (!user + || typeof user.id !== 'string' + || typeof user.login !== 'string' + || typeof user.display_name !== 'string' + || typeof user.description !== 'string' + || typeof user.profile_image_url !== 'string' + || typeof user.broadcaster_type !== 'string') return { status: 'unavailable' }; + users.push(user as unknown as TwitchHelixUser); + } + return { status: 'success', value: users }; + } catch (error) { + return httpStatus(error) === 401 ? { status: 'unauthorized' } : { status: 'unavailable' }; + } +} + +export async function requestTwitchHelixVideos( + client: TwitchHelixHttpClient, + userId: string, + auth: TwitchHelixAuth, + timeoutMs: number, + maxPages = 50, +): Promise> { + const videos: TwitchVod[] = []; + let cursor = ''; + try { + for (let page = 0; page < maxPages; page++) { + const params: Record = { user_id: userId, type: 'archive', first: 100 }; + if (cursor) params.after = cursor; + const response = await client.get('https://api.twitch.tv/helix/videos', helixConfig(auth, params, timeoutMs)); + const parsed = parseHelixDataArray(response.data); + if (parsed.status !== 'success') return parsed; + for (const value of parsed.value) { + const video = asRecord(value); + if (!video + || typeof video.id !== 'string' + || typeof video.title !== 'string' + || typeof video.created_at !== 'string' + || typeof video.duration !== 'string' + || typeof video.thumbnail_url !== 'string' + || typeof video.url !== 'string' + || typeof video.view_count !== 'number' + || typeof video.stream_id !== 'string') return { status: 'unavailable' }; + videos.push(video as unknown as TwitchVod); + } + const envelope = asRecord(response.data); + const pagination = asRecord(envelope?.pagination); + if (!pagination) return { status: 'unavailable' }; + if (pagination.cursor !== undefined && typeof pagination.cursor !== 'string') return { status: 'unavailable' }; + cursor = typeof pagination.cursor === 'string' ? pagination.cursor : ''; + if (!cursor) break; + } + return { status: 'success', value: videos }; + } catch (error) { + return httpStatus(error) === 401 ? { status: 'unauthorized' } : { status: 'unavailable' }; + } +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export async function requestPublicTwitchGraphql( + client: TwitchGraphqlHttpClient, + query: string, + variables: Record, + timeoutMs: number, + attempts = 3, +): Promise> { + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const response = await client.post('https://gql.twitch.tv/gql', { query, variables }, { + headers: { 'Client-ID': TWITCH_PUBLIC_WEB_CLIENT_ID, 'Content-Type': 'application/json' }, + timeout: timeoutMs, + }); + if (response.data && typeof response.data === 'object' && !Array.isArray(response.data)) { + const errors = (response.data as Record).errors; + if (Array.isArray(errors) && errors.length > 0) return { status: 'unavailable' }; + } + const parsed = parseGraphqlDataEnvelope(response.data); + return parsed.status === 'success' + ? { status: 'success', value: parsed.value as T } + : parsed; + } catch (error) { + if (!isTransientHttpError(error) || attempt === attempts) return { status: 'unavailable' }; + await delay(400 * Math.pow(2, attempt - 1)); + } + } + return { status: 'unavailable' }; +} + +function formatTwitchDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainder = seconds % 60; + return `${hours > 0 ? `${hours}h` : ''}${minutes > 0 ? `${minutes}m` : ''}${remainder > 0 || (hours === 0 && minutes === 0) ? `${remainder}s` : ''}`; +} + +export async function requestPublicTwitchVodsByLogin( + client: TwitchGraphqlHttpClient, + login: string, + first = 100, + timeoutMs = 10000, + attempts = 3, +): Promise> { + if (!login || !Number.isSafeInteger(first) || first < 1 || first > 100) return { status: 'not-found' }; + const outcome = await requestPublicTwitchGraphql>( + client, + TWITCH_PUBLIC_VODS_QUERY, + { login, first }, + timeoutMs, + attempts, + ); + if (outcome.status !== 'success') return outcome; + const user = asRecord(outcome.value.user); + if (outcome.value.user === null) return { status: 'not-found' }; + const videos = asRecord(user?.videos); + if (!videos || !Array.isArray(videos.edges)) return { status: 'unavailable' }; + const vods: TwitchVod[] = []; + for (const edgeValue of videos.edges) { + const node = asRecord(asRecord(edgeValue)?.node); + if (!node + || typeof node.id !== 'string' + || !node.id + || typeof node.lengthSeconds !== 'number' + || !Number.isFinite(node.lengthSeconds) + || typeof node.viewCount !== 'number' + || !Number.isFinite(node.viewCount)) { + return { status: 'unavailable' }; + } + vods.push({ + id: node.id, + title: typeof node.title === 'string' && node.title ? node.title : 'Untitled VOD', + created_at: typeof node.publishedAt === 'string' && node.publishedAt ? node.publishedAt : new Date(0).toISOString(), + duration: formatTwitchDuration(node.lengthSeconds), + thumbnail_url: typeof node.previewThumbnailURL === 'string' ? node.previewThumbnailURL : '', + url: `https://www.twitch.tv/videos/${node.id}`, + view_count: node.viewCount, + stream_id: '', + user_login: login, + }); + } + return { status: 'success', value: vods }; +} + +export async function refreshTwitchProviderData( + key: string, + previous: T[] | undefined, + operations: TwitchProviderRefreshOperations, +): Promise> { + let helix = await operations.requestHelix(key); + if (helix.status === 'unauthorized' && await operations.refreshToken()) { + helix = await operations.requestHelix(key); + } + if (helix.status === 'success') return { value: helix.value, source: 'helix', stale: false }; + const publicOutcome = await operations.requestPublic(key); + if (publicOutcome.status === 'success') return { value: publicOutcome.value, source: 'public', stale: false }; + if (helix.status === 'not-found' || publicOutcome.status === 'not-found') { + return { value: null, source: 'not-found', stale: false }; + } + return previous + ? { value: previous, source: 'last-good', stale: true } + : { value: null, source: 'unavailable', stale: false }; +} + +export function createTwitchProviderRefreshService(dependencies: TwitchProviderRefreshDependencies): { + refresh(key: string): Promise>; +} { + const lastGood = new LastGoodCache(dependencies.maxLastGoodEntries); + return { + async refresh(key: string): Promise> { + const result = await refreshTwitchProviderData(key, lastGood.get(key), dependencies); + if (result.source === 'helix' || result.source === 'public') lastGood.set(key, result.value ?? []); + if (result.source === 'not-found') lastGood.delete(key); + return result; + }, + }; +} diff --git a/src/main/updates/index.ts b/src/main/updates/index.ts new file mode 100644 index 0000000..c75eadb --- /dev/null +++ b/src/main/updates/index.ts @@ -0,0 +1,3 @@ +export { compareUpdateVersions, normalizeUpdateVersion } from '../domain/update-version-utils'; +export { createUpdateCheckCoordinator } from '../domain/update-check-operation'; +export { UpdateLifecycle } from './update-lifecycle'; diff --git a/src/main/updates/update-lifecycle.production-path.test.ts b/src/main/updates/update-lifecycle.production-path.test.ts new file mode 100644 index 0000000..3df122e --- /dev/null +++ b/src/main/updates/update-lifecycle.production-path.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('main update lifecycle production path', () => { + it('uses one lifecycle for checks, downloads, terminals and typed errors', () => { + const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8'); + const start = source.indexOf('async function requestUpdateCheck'); + const end = source.indexOf('// ==========================================\n// IPC HANDLERS', start); + const updateSource = source.slice(start, end); + + expect(source).toMatch(/import\s*\{[\s\S]*UpdateLifecycle[\s\S]*\}\s*from '\.\/main\/updates'/); + expect(source).toContain('const autoUpdateLifecycle = new UpdateLifecycle()'); + expect(updateSource).toContain('autoUpdateLifecycle.beginCheck()'); + expect(updateSource).toContain('autoUpdateLifecycle.beginDownload(version)'); + expect(updateSource).toContain('autoUpdateLifecycle.completeCheckAvailable(incomingVersion)'); + expect(updateSource).toContain('autoUpdateLifecycle.completeCheckNotAvailable()'); + expect(updateSource).toContain('autoUpdateLifecycle.completeDownload(downloadedVersion)'); + const timeoutBranch = updateSource.slice( + updateSource.indexOf("if (result.state === 'timed-out')"), + updateSource.indexOf("if (result.state === 'in-progress')") + ); + expect(timeoutBranch).toContain('autoUpdateLifecycle.failCheck()'); + expect(updateSource).toContain("kind: 'check'"); + expect(updateSource).toContain("kind: 'download'"); + expect(updateSource).not.toContain('autoUpdateDownloadInProgress = false'); + }); +}); diff --git a/src/main/updates/update-lifecycle.test.ts b/src/main/updates/update-lifecycle.test.ts new file mode 100644 index 0000000..c9c1a56 --- /dev/null +++ b/src/main/updates/update-lifecycle.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { UpdateLifecycle } from './update-lifecycle'; + +describe('UpdateLifecycle', () => { + it('serializes checks and downloads through ready state', () => { + const lifecycle = new UpdateLifecycle(); + + expect(lifecycle.beginCheck()).toEqual({ started: true }); + expect(lifecycle.completeCheckAvailable('1.2.3')).toBe(true); + expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: true }); + expect(lifecycle.beginCheck()).toEqual({ started: false, reason: 'downloading' }); + expect(lifecycle.completeDownload('1.2.3')).toBe(true); + expect(lifecycle.beginCheck()).toEqual({ started: false, reason: 'ready-to-install' }); + expect(lifecycle.snapshot).toEqual({ phase: 'ready', version: '1.2.3' }); + }); + + it('ignores check events that arrive during a download', () => { + const lifecycle = new UpdateLifecycle(); + lifecycle.beginCheck(); + lifecycle.completeCheckAvailable('1.2.3'); + lifecycle.beginDownload('1.2.3'); + + expect(lifecycle.completeCheckAvailable('1.2.4')).toBe(false); + expect(lifecycle.completeCheckNotAvailable()).toBe(false); + expect(lifecycle.failCheck()).toBe(false); + expect(lifecycle.snapshot).toEqual({ phase: 'downloading', version: '1.2.3' }); + }); + + it('restores the available version after a download failure', () => { + const lifecycle = new UpdateLifecycle(); + lifecycle.beginCheck(); + lifecycle.completeCheckAvailable('1.2.3'); + lifecycle.beginDownload('1.2.3'); + + expect(lifecycle.failDownload('1.2.3')).toBe(true); + expect(lifecycle.snapshot).toEqual({ phase: 'available', version: '1.2.3' }); + expect(lifecycle.failDownload('1.2.3')).toBe(false); + }); + + it('restores the previous available version after a failed refresh check', () => { + const lifecycle = new UpdateLifecycle(); + lifecycle.beginCheck(); + lifecycle.completeCheckAvailable('1.2.3'); + lifecycle.beginCheck(); + + expect(lifecycle.failCheck()).toBe(true); + expect(lifecycle.snapshot).toEqual({ phase: 'available', version: '1.2.3' }); + }); + + it('rejects unavailable or mismatched download transitions', () => { + const lifecycle = new UpdateLifecycle(); + + expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: false, reason: 'not-available' }); + lifecycle.beginCheck(); + lifecycle.completeCheckAvailable('1.2.3'); + expect(lifecycle.beginDownload('1.2.4')).toEqual({ started: false, reason: 'stale-version' }); + expect(lifecycle.beginDownload('1.2.3')).toEqual({ started: true }); + expect(lifecycle.completeDownload('1.2.4')).toBe(false); + expect(lifecycle.snapshot).toEqual({ phase: 'downloading', version: '1.2.3' }); + }); +}); diff --git a/src/main/updates/update-lifecycle.ts b/src/main/updates/update-lifecycle.ts new file mode 100644 index 0000000..866708c --- /dev/null +++ b/src/main/updates/update-lifecycle.ts @@ -0,0 +1,71 @@ +export type UpdateLifecycleSnapshot = + | { phase: 'idle' } + | { phase: 'checking' } + | { phase: 'available'; version: string } + | { phase: 'downloading'; version: string } + | { phase: 'ready'; version: string }; + +export type UpdateLifecycleStartResult = + | { started: true } + | { started: false; reason: 'in-progress' | 'downloading' | 'ready-to-install' | 'not-available' | 'stale-version' }; + +export class UpdateLifecycle { + private state: UpdateLifecycleSnapshot = { phase: 'idle' }; + private checkFallback: UpdateLifecycleSnapshot | null = null; + + get snapshot(): UpdateLifecycleSnapshot { + return { ...this.state }; + } + + beginCheck(): UpdateLifecycleStartResult { + if (this.state.phase === 'checking') return { started: false, reason: 'in-progress' }; + if (this.state.phase === 'downloading') return { started: false, reason: 'downloading' }; + if (this.state.phase === 'ready') return { started: false, reason: 'ready-to-install' }; + this.checkFallback = this.state.phase === 'available' ? this.state : { phase: 'idle' }; + this.state = { phase: 'checking' }; + return { started: true }; + } + + completeCheckAvailable(version: string): boolean { + if (this.state.phase !== 'checking' || !version) return false; + this.state = { phase: 'available', version }; + this.checkFallback = null; + return true; + } + + completeCheckNotAvailable(): boolean { + if (this.state.phase !== 'checking') return false; + this.state = { phase: 'idle' }; + this.checkFallback = null; + return true; + } + + failCheck(): boolean { + if (this.state.phase !== 'checking') return false; + this.state = this.checkFallback ?? { phase: 'idle' }; + this.checkFallback = null; + return true; + } + + beginDownload(version: string): UpdateLifecycleStartResult { + if (this.state.phase === 'downloading') return { started: false, reason: 'in-progress' }; + if (this.state.phase === 'ready') return { started: false, reason: 'ready-to-install' }; + if (this.state.phase !== 'available') return { started: false, reason: 'not-available' }; + if (!version || this.state.version !== version) return { started: false, reason: 'stale-version' }; + this.state = { phase: 'downloading', version }; + return { started: true }; + } + + completeDownload(version: string): boolean { + if (this.state.phase !== 'downloading' || this.state.version !== version) return false; + this.state = { phase: 'ready', version }; + return true; + } + + failDownload(version?: string): boolean { + if (this.state.phase !== 'downloading') return false; + if (version && this.state.version !== version) return false; + this.state = { phase: 'available', version: this.state.version }; + return true; + } +} diff --git a/src/preload.ts b/src/preload.ts index dfc73a2..68e5dd0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'; -import { CustomClip, MergeGroupItem, MergeGroup, QueueItem, DownloadProgress } from './types'; +import type { DownloadProgress, QueueAdditionResult, QueueItem } from './types'; let chatReadSequence = 0; @@ -93,12 +93,6 @@ interface VideoEditExportRequest { cuts: Array<{ id: string; start: number; end: number }>; } -interface FileCapabilityReference { - token: string; - name: string; - displayPath?: string; -} - // Expose protected methods to renderer contextBridge.exposeInMainWorld('api', { // Config @@ -121,6 +115,7 @@ contextBridge.exposeInMainWorld('api', { // Queue getQueue: () => ipcRenderer.invoke('get-queue'), addToQueue: (item: Pick) => ipcRenderer.invoke('add-to-queue', item), + addToQueueWithResult: (item: Pick): Promise => ipcRenderer.invoke('add-to-queue-with-result', 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), @@ -216,6 +211,7 @@ contextBridge.exposeInMainWorld('api', { openExternal: (url: string) => ipcRenderer.invoke('open-external', url), runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix), getManagedToolStatus: () => ipcRenderer.invoke('get-managed-tool-status'), + getManagedToolExecutionDiagnostics: (): Promise<{ ffmpeg: { path: string | null; count: number }; ffprobe: { path: string | null; count: number }; streamlink: { path: string | null; count: number } } | null> => ipcRenderer.invoke('get-managed-tool-execution-diagnostics'), repairManagedTools: () => ipcRenderer.invoke('repair-managed-tools'), resetManagedTools: () => ipcRenderer.invoke('reset-managed-tools'), getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines), @@ -276,7 +272,7 @@ contextBridge.exposeInMainWorld('api', { onUpdateDownloaded: (callback: (info: { version: string; releaseDate?: string; releaseName?: string; releaseNotes?: string }) => void) => { ipcRenderer.on('update-downloaded', (_, info) => callback(info)); }, - onUpdateError: (callback: (payload: { message: string }) => void) => { + onUpdateError: (callback: (payload: { message: string; kind: 'check' | 'download'; version?: string }) => void) => { ipcRenderer.on('update-error', (_, payload) => callback(payload)); } }); diff --git a/src/renderer-archive.ts b/src/renderer-archive.ts index ffda352..02e5b5d 100644 --- a/src/renderer-archive.ts +++ b/src/renderer-archive.ts @@ -112,7 +112,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
${escapeHtml(formatBytes(hit.size))}
- + ${chatBtn} ${eventsBtn} diff --git a/src/renderer-cutter.production-path.test.ts b/src/renderer-cutter.production-path.test.ts index a98b7c2..e7a94e3 100644 --- a/src/renderer-cutter.production-path.test.ts +++ b/src/renderer-cutter.production-path.test.ts @@ -62,7 +62,178 @@ function createCutterSelects(): Map { ]); } +const englishCutterChoiceTexts = { + noAudio: 'No audio track', + audioStream: 'Audio track {index}', + channelSingular: 'channel', + channelPlural: 'channels', + profileQuality: 'Quality', + profileBalanced: 'Balanced', + profileFast: 'Fast', + profileArchive: 'Archive', + encoderSoftware: 'Software', + encoderNvenc: 'NVIDIA NVENC', + encoderQsv: 'Intel Quick Sync', + encoderAmf: 'AMD AMF', +}; + describe('cutter production paths', () => { + test('uses unambiguous frame timecodes and accepts pasted HH:MM:SS values', () => { + const context = { + cutterEditorState: { fps: 30, duration: 90 }, + cutterVideoInfo: null, + snapCutterTime: (value: number) => value, + }; + const api = evaluate( + sourceFragment('function formatCutterTimecode', 'function getCutterVideo'), + context, + 'formatCutterTimecode, parseCutterTimecode', + ); + + expect(api.formatCutterTimecode(1.5)).toBe('00:00:01:15'); + expect(api.parseCutterTimecode('00:01:15')).toBe(75); + expect(api.parseCutterTimecode('00:00:01:15')).toBe(1.5); + }); + + test.each([ + { + language: 'de', + texts: { + recoveryFound: 'Gespeicherte Bearbeitung gefunden', + noAudio: 'Keine Audiospur', + audioStream: 'Audiospur {index}', + channelSingular: 'Kanal', + channelPlural: 'Kanäle', + profileQuality: 'Qualität', + profileBalanced: 'Ausgewogen', + profileFast: 'Schnell', + profileArchive: 'Archiv', + encoderSoftware: 'Software', + encoderNvenc: 'NVIDIA NVENC', + encoderQsv: 'Intel Quick Sync', + encoderAmf: 'AMD AMF', + }, + expectedAudio: ['Audiospur 1 (deu · aac · 1 Kanal)', 'Audiospur 3 (eng · opus · 2 Kanäle)'], + expectedProfiles: ['Qualität', 'Ausgewogen', 'Schnell', 'Archiv'], + }, + { + language: 'en', + texts: { + recoveryFound: 'Saved edit found', + noAudio: 'No audio track', + audioStream: 'Audio track {index}', + channelSingular: 'channel', + channelPlural: 'channels', + profileQuality: 'Quality', + profileBalanced: 'Balanced', + profileFast: 'Fast', + profileArchive: 'Archive', + encoderSoftware: 'Software', + encoderNvenc: 'NVIDIA NVENC', + encoderQsv: 'Intel Quick Sync', + encoderAmf: 'AMD AMF', + }, + expectedAudio: ['Audio track 1 (deu · aac · 1 channel)', 'Audio track 3 (eng · opus · 2 channels)'], + expectedProfiles: ['Quality', 'Balanced', 'Fast', 'Archive'], + }, + ])('renders $language recovery, audio and export choices from the active cutter locale', ({ texts, expectedAudio, expectedProfiles }) => { + const selects = createCutterSelects(); + const recoveryPanel = { hidden: true }; + const recoveryText = { textContent: '' }; + const context: Record = { + cutterPendingProject: null, + cutterVideoInfo: { + audioStreams: [ + { index: 0, language: 'deu', codec: 'aac', channels: 1 }, + { index: 2, language: 'eng', codec: 'opus', channels: 2 }, + ], + }, + cutterAudioStreamIndex: 0, + cutterExportProfile: 'balanced', + cutterExportEncoder: 'software', + UI_TEXT: { cutter: texts }, + byId: (id: string) => id === 'cutterRecoveryPanel' + ? recoveryPanel + : id === 'cutterRecoveryText' + ? recoveryText + : selects.get(id), + document: { createElement: () => ({ value: '', textContent: '' }) }, + }; + const api = evaluate(sourceFragment('function renderCutterProjectRecovery', 'async function loadCutterExportOptions'), context, 'renderCutterProjectRecovery, updateCutterAudioStreams, updateCutterExportControls'); + + api.renderCutterProjectRecovery({ trimStart: 12 }); + api.updateCutterAudioStreams(); + api.updateCutterExportControls({ + profiles: [ + { id: 'quality', label: 'Quality', container: 'mp4' }, + { id: 'balanced', label: 'Balanced', container: 'mp4' }, + { id: 'fast', label: 'Fast', container: 'mp4' }, + { id: 'archive', label: 'Archive', container: 'mkv' }, + ], + hardwareEncoders: ['h264_nvenc', 'h264_qsv', 'h264_amf'], + }); + + expect(recoveryPanel.hidden).toBe(false); + expect(recoveryText.textContent).toBe(texts.recoveryFound); + expect(selects.get('cutterAudioStream')?.options.map((option) => option.textContent)).toEqual(expectedAudio); + expect(selects.get('cutterExportProfile')?.options.map((option) => option.textContent)).toEqual(expectedProfiles); + expect(selects.get('cutterExportEncoder')?.options.map((option) => option.textContent)).toEqual([ + texts.encoderSoftware, + texts.encoderNvenc, + texts.encoderQsv, + texts.encoderAmf, + ]); + }); + + test('uses active English project feedback for save, recovery and manual open actions', async () => { + const toasts: Array<[string, string]> = []; + const project = { duration: 90, fps: 30, trimStart: 5, trimEnd: 80, cuts: [], profile: 'balanced', encoder: 'software', audioStreamIndex: 0 }; + let openResult: unknown = project; + const context: Record = { + cutterEditorState: { duration: 90, fps: 30, trimStart: 0, trimEnd: 90, cuts: [] }, + cutterFile: { token: 'source-capability' }, + cutterExportProfile: 'balanced', + cutterExportEncoder: 'software', + cutterAudioStreamIndex: 0, + cutterPendingProject: project, + cutterRecoveryDecisionPending: true, + UI_TEXT: { + cutter: { + projectSaved: 'Project saved', + projectSaveFailed: 'Project could not be saved', + projectRecoveryFailed: 'Project could not be restored', + projectRecovered: 'Project restored', + projectNotFound: 'No matching project found', + projectOpened: 'Project opened', + }, + }, + applyCutterProject: () => true, + renderCutterProjectRecovery: () => undefined, + showAppToast: (message: string, type: string) => toasts.push([message, type]), + api: { + saveCutterProject: async () => true, + openCutterProject: async () => openResult, + }, + }; + context.getCutterProjectPayload = () => ({ trimStart: 0, trimEnd: 90, cuts: [], profile: 'balanced', encoder: 'software', audioStreamIndex: 0 }); + const persistence = evaluate(sourceFragment('async function persistCutterProject', 'function scheduleCutterAutosave'), context, 'persistCutterProject'); + context.persistCutterProject = persistence.persistCutterProject; + const actions = evaluate(sourceFragment('async function recoverCutterProject', 'function setCutterExportProfile'), context, 'recoverCutterProject, openCutterProject'); + + await persistence.persistCutterProject(true); + await actions.recoverCutterProject(); + await actions.openCutterProject(); + openResult = null; + await actions.openCutterProject(); + + expect(toasts).toEqual([ + ['Project saved', 'info'], + ['Project restored', 'info'], + ['Project opened', 'info'], + ['No matching project found', 'warn'], + ]); + }); + test('rejects a PNG drop before requesting a capability or loader', async () => { const listeners = new Map) => Promise | void>(); let capabilityRequests = 0; @@ -105,6 +276,7 @@ describe('cutter production paths', () => { applyCutterProject: () => true, renderCutterProjectRecovery: () => undefined, showAppToast: () => undefined, + UI_TEXT: { cutter: { projectNotFound: 'No matching project found', projectOpened: 'Project opened' } }, api: { openCutterProject: async () => { opens += 1; @@ -158,6 +330,7 @@ describe('cutter production paths', () => { cutterHistoryPast: [], cutterHistoryFuture: [], cutterActiveCutId: null, + UI_TEXT: { cutter: englishCutterChoiceTexts }, byId: (id: string) => selects.get(id), document: { createElement: () => ({ value: '', textContent: '' }) }, renderCutterEditor: () => undefined, @@ -206,6 +379,7 @@ describe('cutter production paths', () => { cutterExportOptions: undefined, cutterLoadGeneration: 4, cutterFile: file, + UI_TEXT: { cutter: englishCutterChoiceTexts }, byId: (id: string) => selects.get(id), document: { createElement: () => ({ value: '', textContent: '' }) }, api: { getCutterExportOptions: async () => { throw new Error('probe failed'); } }, diff --git a/src/renderer-cutter.ts b/src/renderer-cutter.ts index ffd833d..2f662df 100644 --- a/src/renderer-cutter.ts +++ b/src/renderer-cutter.ts @@ -118,11 +118,7 @@ function formatCutterTimecode(time: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; - const useHours = (cutterEditorState?.duration || cutterVideoInfo?.duration || time) >= 3600; - const fields = useHours - ? [hours, minutes, remainingSeconds, frames] - : [minutes, remainingSeconds, frames]; - return fields.map((field) => String(field).padStart(2, '0')).join(':'); + return [hours, minutes, remainingSeconds, frames].map((field) => String(field).padStart(2, '0')).join(':'); } function parseCutterTimecode(value: string): number | null { @@ -131,7 +127,7 @@ function parseCutterTimecode(value: string): number | null { if ((fields.length !== 3 && fields.length !== 4) || fields.some((field) => !Number.isInteger(field) || field < 0)) return null; const [hours, minutes, seconds, frames] = fields.length === 4 ? fields - : [0, fields[0], fields[1], fields[2]]; + : [fields[0], fields[1], fields[2], 0]; if (minutes >= 60 || seconds >= 60 || frames >= Math.max(1, Math.round(cutterEditorState.fps))) return null; return snapCutterTime(hours * 3600 + minutes * 60 + seconds + frames / cutterEditorState.fps); } @@ -166,7 +162,7 @@ async function persistCutterProject(showResult: boolean): Promise { try { saved = await window.api.saveCutterProject(file.token, project); } catch { } - if (showResult) showAppToast(saved ? 'Projekt gespeichert' : 'Projekt konnte nicht gespeichert werden', saved ? 'info' : 'warn'); + if (showResult) showAppToast(saved ? UI_TEXT.cutter.projectSaved : UI_TEXT.cutter.projectSaveFailed, saved ? 'info' : 'warn'); return saved; } @@ -184,7 +180,7 @@ function renderCutterProjectRecovery(project: CutterProject | null): void { cutterPendingProject = project; const panel = byId('cutterRecoveryPanel'); panel.hidden = !project; - if (project) byId('cutterRecoveryText').textContent = 'Gespeicherte Bearbeitung gefunden'; + if (project) byId('cutterRecoveryText').textContent = UI_TEXT.cutter.recoveryFound; } function updateCutterAudioStreams(): void { @@ -194,7 +190,7 @@ function updateCutterAudioStreams(): void { if (streams.length === 0) { const option = document.createElement('option'); option.value = '0'; - option.textContent = 'Keine Audiospur'; + option.textContent = UI_TEXT.cutter.noAudio; select.append(option); select.disabled = true; cutterAudioStreamIndex = 0; @@ -203,8 +199,10 @@ function updateCutterAudioStreams(): void { streams.forEach((stream) => { const option = document.createElement('option'); option.value = String(stream.index); - const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} Kanäle` : ''].filter(Boolean).join(' · '); - option.textContent = `Audiospur ${stream.index + 1}${details ? ` (${details})` : ''}`; + const channelLabel = stream.channels === 1 ? UI_TEXT.cutter.channelSingular : UI_TEXT.cutter.channelPlural; + const details = [stream.language, stream.codec, stream.channels > 0 ? `${stream.channels} ${channelLabel}` : ''].filter(Boolean).join(' · '); + const label = UI_TEXT.cutter.audioStream.replace('{index}', String(stream.index + 1)); + option.textContent = `${label}${details ? ` (${details})` : ''}`; select.append(option); }); if (!streams.some((stream) => stream.index === cutterAudioStreamIndex)) cutterAudioStreamIndex = streams[0].index; @@ -219,7 +217,12 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi profile.replaceChildren(...options.profiles.map((entry) => { const option = document.createElement('option'); option.value = entry.id; - option.textContent = entry.label; + option.textContent = { + quality: UI_TEXT.cutter.profileQuality, + balanced: UI_TEXT.cutter.profileBalanced, + fast: UI_TEXT.cutter.profileFast, + archive: UI_TEXT.cutter.profileArchive, + }[entry.id]; return option; })); } @@ -227,7 +230,7 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi encoder.replaceChildren(); const software = document.createElement('option'); software.value = 'software'; - software.textContent = 'Software'; + software.textContent = UI_TEXT.cutter.encoderSoftware; encoder.append(software); if (cutterExportProfile !== 'archive') { const hardwareEncoders = options?.hardwareEncoders @@ -235,7 +238,11 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi hardwareEncoders.forEach((value) => { const option = document.createElement('option'); option.value = value; - option.textContent = value === 'h264_nvenc' ? 'NVIDIA NVENC' : value === 'h264_qsv' ? 'Intel Quick Sync' : 'AMD AMF'; + option.textContent = value === 'h264_nvenc' + ? UI_TEXT.cutter.encoderNvenc + : value === 'h264_qsv' + ? UI_TEXT.cutter.encoderQsv + : UI_TEXT.cutter.encoderAmf; encoder.append(option); }); } @@ -244,6 +251,12 @@ function updateCutterExportControls(options: CutterExportOptions | null | undefi encoder.disabled = !options || cutterExportProfile === 'archive'; } +function refreshCutterLocalizedUi(): void { + renderCutterProjectRecovery(cutterPendingProject); + updateCutterAudioStreams(); + updateCutterExportControls(cutterExportOptions); +} + async function loadCutterExportOptions(file: FileCapabilityReference, generation: number): Promise { let options: CutterExportOptions | null = null; try { @@ -279,12 +292,12 @@ function applyCutterProject(project: CutterProject): boolean { async function recoverCutterProject(): Promise { if (!cutterPendingProject || !applyCutterProject(cutterPendingProject)) { - showAppToast('Projekt konnte nicht wiederhergestellt werden', 'warn'); + showAppToast(UI_TEXT.cutter.projectRecoveryFailed, 'warn'); return; } cutterRecoveryDecisionPending = false; renderCutterProjectRecovery(null); - showAppToast('Projekt wiederhergestellt', 'info'); + showAppToast(UI_TEXT.cutter.projectRecovered, 'info'); } async function discardCutterProject(): Promise { @@ -304,12 +317,12 @@ async function openCutterProject(): Promise { let project: CutterProject | null = null; try { project = await window.api.openCutterProject(cutterFile.token); } catch { } if (!project || !applyCutterProject(project)) { - showAppToast('Kein passendes Projekt gefunden', 'warn'); + showAppToast(UI_TEXT.cutter.projectNotFound, 'warn'); return; } cutterRecoveryDecisionPending = false; renderCutterProjectRecovery(null); - showAppToast('Projekt geöffnet', 'info'); + showAppToast(UI_TEXT.cutter.projectOpened, 'info'); } function setCutterExportProfile(value: string): void { @@ -1156,7 +1169,7 @@ async function requestCutterVideoReplacement(file: FileCapabilityReference): Pro if (!file || isCutting) return; if (!await confirmCutterReplacement(file)) return; if (cutterEditorState && !cutterRecoveryDecisionPending && !await persistCutterProject(false)) { - showAppToast('Projekt konnte nicht gespeichert werden', 'warn'); + showAppToast(UI_TEXT.cutter.projectSaveFailed, 'warn'); return; } await loadCutterFromPath(file); diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 7087177..7944879 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -79,11 +79,13 @@ interface MergeGroup { downloadedFiles: Record; mergedFile?: string; splitFiles?: string[]; + splitTempFiles?: string[]; totalDurationSec?: number; } interface QueueItem { id: string; + createdAt?: string; title: string; url: string; date: string; @@ -101,6 +103,8 @@ interface QueueItem { last_error?: string; customClip?: CustomClip; mergeGroup?: MergeGroup; + mergeRecoveryBlocked?: boolean; + artifactRoot?: string; outputFiles?: string[]; isLive?: boolean; recordingHealth?: 'ok' | 'stale' | 'unknown'; @@ -173,6 +177,8 @@ interface VideoInfo { audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>; } +type QueueAdditionResult = import('./main/domain/queue-addition').QueueAdditionResult; + interface DownloadPolicy { throttle: { maxBytesPerSecond: number } | null; windows: Array<{ start: string; end: string }>; @@ -433,6 +439,7 @@ interface ApiBridge { getVODs(userId: string, forceRefresh?: boolean): Promise; getQueue(): Promise; addToQueue(item: Pick): Promise; + addToQueueWithResult(item: Pick): Promise; startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>; removeFromQueue(id: string): Promise; reorderQueue(orderIds: string[]): Promise; @@ -502,6 +509,7 @@ interface ApiBridge { openExternal(url: string): Promise; runPreflight(autoFix: boolean): Promise; getManagedToolStatus(): Promise; + getManagedToolExecutionDiagnostics(): Promise<{ ffmpeg: { path: string | null; count: number }; ffprobe: { path: string | null; count: number }; streamlink: { path: string | null; count: number } } | null>; repairManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>; resetManagedTools(): Promise<{ success: boolean; statuses: ManagedToolStatuses } | null>; getDebugLog(lines: number): Promise; @@ -525,7 +533,7 @@ interface ApiBridge { onUpdateNotAvailable(callback: () => void): void; onUpdateDownloadProgress(callback: (progress: UpdateDownloadProgress) => void): void; onUpdateDownloaded(callback: (info: UpdateInfo) => void): void; - onUpdateError(callback: (payload: { message: string }) => void): void; + onUpdateError(callback: (payload: { message: string; kind: 'check' | 'download'; version?: string }) => void): void; } interface Window { diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index ebcc702..b7b799f 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -35,7 +35,7 @@ const UI_TEXT_DE = { streamerPlaceholder: 'Streamer hinzufügen…', clipsHeading: 'Twitch Clip-Download', clipsInfoTitle: 'Info', - clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', + clipsInfoText: 'Unterstützte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.', cutterSelectTitle: 'Video auswählen', cutterPreviewPlaceholder: 'Video auswählen, um eine Vorschau zu sehen', cutterBrowse: 'Durchsuchen', @@ -76,11 +76,11 @@ const UI_TEXT_DE = { recordingMetadataTitle: 'Aufnahmen und Metadaten', storageLabel: 'Speicherort', selectFolder: 'Ordner', - openFolder: 'Offnen', + openFolder: 'Öffnen', modeLabel: 'Download-Modus', modeFull: 'Ganzes VOD', modeParts: 'In Teile splitten', - partMinutesLabel: 'Teil-Lange (Minuten)', + partMinutesLabel: 'Teil-Länge (Minuten)', parallelDownloadsLabel: 'Parallele Downloads', parallelDownloads1: '1 (Standard)', parallelDownloads2: '2 (Parallel)', @@ -195,7 +195,7 @@ const UI_TEXT_DE = { resetDownloadedIds: 'Downloaded-VODs zurücksetzen', configExported: 'Konfiguration exportiert.', configExportFailed: 'Export der Konfiguration fehlgeschlagen.', - configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.', + configImported: 'Konfiguration importiert. Einige Änderungen erfordern evtl. einen Neustart.', configImportFailed: 'Import der Konfiguration fehlgeschlagen.', resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zurücksetzen? Karten verlieren das grüne Häkchen, es werden aber keine Dateien gelöscht.', resetDownloadedDone: '{count} Einträge aus der Downloaded-Liste entfernt.', @@ -210,7 +210,7 @@ const UI_TEXT_DE = { downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)', downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der öffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behält Chat-Replays nur solange wie das VOD selbst.', captureLiveChatLabel: 'Live-Chat während der Aufnahme mitschneiden (.chat.jsonl)', - captureLiveChatHint: 'Oeffnet während 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 früheren Inhalt nicht korrumpiert).', + captureLiveChatHint: 'Öffnet während 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 früheren 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 günstig — ein zusätzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.', streamlinkQualityLabel: 'Stream-Qualität', @@ -316,6 +316,7 @@ const UI_TEXT_DE = { preflightRun: 'Check ausführen', preflightFix: 'Auto-Fix Tools', preflightEmpty: 'Noch kein Check ausgeführt.', + preflightError: 'System-Check fehlgeschlagen.', preflightChecking: 'Prüfe...', preflightFixing: 'Fixe...', preflightReady: 'Alles bereit.', @@ -416,6 +417,7 @@ const UI_TEXT_DE = { ctxCopyUrl: 'URL kopieren', ctxOpenOnTwitch: 'Auf Twitch öffnen', ctxRemove: 'Aus Queue entfernen', + ctxCopyFailed: 'URL konnte nicht kopiert werden.', ctxCopiedUrl: 'URL in Zwischenablage kopiert.', liveRecordingTitle: 'Live-Aufnahme - läuft bis der Stream endet', recordingHealth: { @@ -501,17 +503,34 @@ const UI_TEXT_DE = { bulkAdding: 'Füge hinzu...', bulkClear: 'Löschen', bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefügt.', + bulkAddedToQueueOne: '1 VOD zur Warteschlange hinzugefügt.', bulkAddSkipped: 'Keine VODs hinzugefügt (bereits in Queue oder ungültig).', + bulkAddPartial: '{added} VODs hinzugefügt; {skipped} übersprungen (bereits in Queue oder ungültig).', + bulkAddDuplicate: '{count} VODs sind bereits in der Warteschlange.', + bulkAddDuplicateOne: 'Dieses VOD ist bereits in der Warteschlange.', + bulkAddInvalid: '{count} VODs enthalten ungültige Daten und wurden übersprungen.', + bulkAddInvalidOne: 'Dieses VOD enthält ungültige Daten und wurde übersprungen.', + bulkAddFailed: '{count} VODs konnten nicht hinzugefügt werden und bleiben für einen erneuten Versuch ausgewählt.', + bulkAddFailedOne: 'Dieses VOD konnte nicht hinzugefügt werden und bleibt für einen erneuten Versuch ausgewählt.', + bulkAddResult: '{added} hinzugefügt; {duplicates} bereits vorhanden; {invalid} ungültig; {failed} fehlgeschlagen.', bulkMarkDownloaded: 'Als heruntergeladen markieren', bulkUnmark: 'Markierung entfernen', bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.', + bulkMarkedDownloadedOne: '1 VOD als heruntergeladen markiert.', bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.', + bulkUnmarkedDownloadedOne: 'Markierung von 1 VOD entfernt.', + bulkMarkFailed: '{count} VODs konnten nicht aktualisiert werden und bleiben für einen erneuten Versuch ausgewählt.', + bulkMarkFailedOne: 'Dieses VOD konnte nicht aktualisiert werden und bleibt für einen erneuten Versuch ausgewählt.', + bulkMarkResult: '{updated} aktualisiert; {failed} fehlgeschlagen.', alreadyDownloaded: 'Bereits heruntergeladen', hideDownloaded: 'Bereits geladene ausblenden', hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind', + hideDownloadedEmptyTitle: 'Alle VODs ausgeblendet', + hideDownloadedEmptyText: 'Alle VODs sind bereits als heruntergeladen markiert. Deaktiviere den Filter, um sie anzuzeigen.', openOnTwitch: 'Auf Twitch öffnen', ctxOpenOnTwitch: 'Auf Twitch öffnen', ctxCopyUrl: 'VOD-URL kopieren', + ctxCopyFailed: 'URL konnte nicht kopiert werden.', ctxCopiedUrl: 'URL in Zwischenablage kopiert.', ctxMarkDownloaded: 'Als heruntergeladen markieren', ctxUnmarkDownloaded: 'Markierung entfernen' @@ -527,7 +546,7 @@ const UI_TEXT_DE = { dialogPartHint: 'Leer lassen = Teil 1', dialogFormatLabel: 'Dateinamen-Format:', dialogConfirm: 'Zur Queue hinzufügen', - invalidDuration: 'Ungultig!', + invalidDuration: 'Ungültig!', invalidTime: 'Ungültige Zeitangaben', endBeforeStart: 'Endzeit muss größer als Startzeit sein!', outOfRange: 'Zeit außerhalb des VOD-Bereichs!', @@ -576,6 +595,33 @@ const UI_TEXT_DE = { videoTrack: 'VIDEO', audioTrack: 'AUDIO', noAudio: 'Keine Audiospur', + newVideo: 'Neues Video', + openProject: 'Projekt öffnen', + saveProject: 'Projekt speichern', + recoveryFound: 'Gespeicherte Bearbeitung gefunden', + recoverProject: 'Wiederherstellen', + discardProject: 'Verwerfen', + exportProfileLabel: 'Exportprofil', + exportEncoderLabel: 'Encoder', + audioStreamLabel: 'Audiospur', + profileQuality: 'Qualität', + profileBalanced: 'Ausgewogen', + profileFast: 'Schnell', + profileArchive: 'Archiv', + encoderSoftware: 'Software', + encoderNvenc: 'NVIDIA NVENC', + encoderQsv: 'Intel Quick Sync', + encoderAmf: 'AMD AMF', + audioStream: 'Audiospur {index}', + channelSingular: 'Kanal', + channelPlural: 'Kanäle', + speedNormal: 'Normal', + projectSaved: 'Projekt gespeichert', + projectSaveFailed: 'Projekt konnte nicht gespeichert werden', + projectRecoveryFailed: 'Projekt konnte nicht wiederhergestellt werden', + projectRecovered: 'Projekt wiederhergestellt', + projectNotFound: 'Kein passendes Projekt gefunden', + projectOpened: 'Projekt geöffnet', loadingMedia: 'Video wird vorbereitet…', speedLabel: 'Geschwindigkeit', play: 'Abspielen', @@ -607,10 +653,10 @@ const UI_TEXT_DE = { discardConfirm: 'Verwerfen und öffnen' }, merge: { - empty: 'Keine Videos ausgewahlt', + empty: 'Keine Videos ausgewählt', merging: 'Zusammenfügen...', merge: 'Zusammenfügen', - success: 'Videos erfolgreich zusammengefugt!', + success: 'Videos erfolgreich zusammengefügt!', failed: 'Fehler beim Zusammenfügen der Videos.', moveUpAria: 'Nach oben verschieben', moveDownAria: 'Nach unten verschieben', @@ -621,7 +667,7 @@ const UI_TEXT_DE = { phaseDownloading: 'VOD wird heruntergeladen', phaseMerging: 'Zusammenfügen...', phaseSplitting: 'Part wird erstellt', - phaseCleanup: 'Aufraumen...', + phaseCleanup: 'Aufräumen...', needMinTwo: 'Mindestens 2 VODs auswählen', titleTwo: 'Merge: {title1} + {title2}', titleMany: 'Merge: {title1} + {count} weitere', @@ -631,11 +677,11 @@ const UI_TEXT_DE = { bannerDefault: 'Neue Version verfügbar!', latest: 'Du hast die neueste Version!', checking: 'Suche nach Updates...', - checkInProgress: 'Update-Prufung lauft bereits.', + checkInProgress: 'Update-Prüfung läuft bereits.', readyToInstall: 'Update ist bereit zur Installation.', - checkFailed: 'Update-Prufung fehlgeschlagen.', + checkFailed: 'Update-Prüfung fehlgeschlagen.', downloading: 'Wird heruntergeladen...', - downloadInProgress: 'Update-Download lauft bereits.', + downloadInProgress: 'Update-Download läuft bereits.', downloadFailed: 'Update-Download fehlgeschlagen.', available: 'verfügbar!', downloadNow: 'Jetzt herunterladen', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 8eef8b0..a550ee7 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -316,6 +316,7 @@ const UI_TEXT_EN = { preflightRun: 'Run check', preflightFix: 'Auto-fix tools', preflightEmpty: 'No checks run yet.', + preflightError: 'System check failed.', preflightChecking: 'Checking...', preflightFixing: 'Fixing...', preflightReady: 'Everything is ready.', @@ -416,6 +417,7 @@ const UI_TEXT_EN = { ctxCopyUrl: 'Copy URL', ctxOpenOnTwitch: 'Open on Twitch', ctxRemove: 'Remove from queue', + ctxCopyFailed: 'Could not copy URL.', ctxCopiedUrl: 'URL copied to clipboard.', liveRecordingTitle: 'Live recording — captures until the stream ends', recordingHealth: { @@ -501,17 +503,34 @@ const UI_TEXT_EN = { bulkAdding: 'Adding...', bulkClear: 'Clear', bulkAddedToQueue: 'Added {count} VODs to the queue.', + bulkAddedToQueueOne: 'Added 1 VOD to the queue.', bulkAddSkipped: 'No VODs were added (already in queue or invalid).', + bulkAddPartial: '{added} VODs added; {skipped} skipped (already in queue or invalid).', + bulkAddDuplicate: '{count} VODs are already in the queue.', + bulkAddDuplicateOne: 'This VOD is already in the queue.', + bulkAddInvalid: '{count} VODs have invalid data and were skipped.', + bulkAddInvalidOne: 'This VOD has invalid data and was skipped.', + bulkAddFailed: '{count} VODs could not be added and remain selected for retry.', + bulkAddFailedOne: 'This VOD could not be added and remains selected for retry.', + bulkAddResult: '{added} added; {duplicates} already queued; {invalid} invalid; {failed} failed.', bulkMarkDownloaded: 'Mark as downloaded', bulkUnmark: 'Unmark', bulkMarkedDownloaded: 'Marked {count} VODs as downloaded.', + bulkMarkedDownloadedOne: 'Marked 1 VOD as downloaded.', bulkUnmarkedDownloaded: 'Removed {count} VODs from the downloaded list.', + bulkUnmarkedDownloadedOne: 'Removed 1 VOD from the downloaded list.', + bulkMarkFailed: '{count} VODs could not be updated and remain selected for retry.', + bulkMarkFailedOne: 'This VOD could not be updated and remains selected for retry.', + bulkMarkResult: '{updated} updated; {failed} failed.', alreadyDownloaded: 'Already downloaded', hideDownloaded: 'Hide downloaded', hideDownloadedTitle: 'Hide VODs that are marked as already downloaded', + hideDownloadedEmptyTitle: 'All VODs hidden', + hideDownloadedEmptyText: 'All VODs are marked as downloaded. Turn off the filter to show them.', openOnTwitch: 'Open on Twitch', ctxOpenOnTwitch: 'Open on Twitch', ctxCopyUrl: 'Copy VOD URL', + ctxCopyFailed: 'Could not copy URL.', ctxCopiedUrl: 'URL copied to clipboard.', ctxMarkDownloaded: 'Mark as downloaded', ctxUnmarkDownloaded: 'Unmark downloaded' @@ -576,6 +595,33 @@ const UI_TEXT_EN = { videoTrack: 'VIDEO', audioTrack: 'AUDIO', noAudio: 'No audio track', + newVideo: 'New video', + openProject: 'Open project', + saveProject: 'Save project', + recoveryFound: 'Saved edit found', + recoverProject: 'Restore', + discardProject: 'Discard', + exportProfileLabel: 'Export profile', + exportEncoderLabel: 'Encoder', + audioStreamLabel: 'Audio track', + profileQuality: 'Quality', + profileBalanced: 'Balanced', + profileFast: 'Fast', + profileArchive: 'Archive', + encoderSoftware: 'Software', + encoderNvenc: 'NVIDIA NVENC', + encoderQsv: 'Intel Quick Sync', + encoderAmf: 'AMD AMF', + audioStream: 'Audio track {index}', + channelSingular: 'channel', + channelPlural: 'channels', + speedNormal: 'Normal', + projectSaved: 'Project saved', + projectSaveFailed: 'Project could not be saved', + projectRecoveryFailed: 'Project could not be restored', + projectRecovered: 'Project restored', + projectNotFound: 'No matching project found', + projectOpened: 'Project opened', loadingMedia: 'Preparing video…', speedLabel: 'Playback speed', play: 'Play', diff --git a/src/renderer-profile.production-path.test.ts b/src/renderer-profile.production-path.test.ts new file mode 100644 index 0000000..37f631a --- /dev/null +++ b/src/renderer-profile.production-path.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +const source = readFileSync(join(__dirname, 'renderer-profile.ts'), 'utf8'); + +describe('renderer profile production paths', () => { + it('invalidates an in-flight profile request when the active profile is hidden', () => { + const from = source.indexOf('let activeProfileRequestId'); + const to = source.indexOf('function renderStreamerProfileSkeleton', from); + expect(from).toBeGreaterThanOrEqual(0); + expect(to).toBeGreaterThan(from); + const code = transpileModule( + `${source.slice(from, to)}\nglobalThis.profilePath = { hideStreamerProfileHeader, getRequestId: () => activeProfileRequestId };`, + { compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } } + ).outputText; + const context = { + document: { getElementById: () => null }, + Map, + applyHtml: () => undefined + } as Record; + runInNewContext(code, context); + const profilePath = context.profilePath as { hideStreamerProfileHeader(): void; getRequestId(): number }; + + expect(profilePath.getRequestId()).toBe(0); + profilePath.hideStreamerProfileHeader(); + expect(profilePath.getRequestId()).toBe(1); + }); + + it.each(['unavailable', 'rejected'] as const)('keeps the last good profile visible when refresh is %s', async (outcome) => { + const from = source.indexOf('async function loadStreamerProfile'); + const to = source.indexOf('async function fetchStreamerProfile', from); + expect(from).toBeGreaterThanOrEqual(0); + expect(to).toBeGreaterThan(from); + const code = transpileModule( + `let activeProfileLogin = ''; let activeProfileRequestId = 0; const streamerProfileCache = globalThis.profileCache; ${source.slice(from, to)}\nglobalThis.profilePath = { loadStreamerProfile };`, + { compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } } + ).outputText; + const hide = vi.fn(); + const renderCard = vi.fn(); + const cachedProfile = { login: 'fixture-alpha', displayName: 'Fixture Alpha' }; + const context = { + profileCache: new Map([['fixture-alpha', cachedProfile]]), + hideStreamerProfileHeader: hide, + renderStreamerProfileCard: renderCard, + renderStreamerProfileSkeleton: vi.fn(), + fetchStreamerProfile: outcome === 'unavailable' + ? async () => null + : async () => { throw new Error('offline'); }, + streamerProfilesMatch: () => true, + window: {} + } as Record; + runInNewContext(code, context); + const profilePath = context.profilePath as { loadStreamerProfile(login: string, forceRefresh?: boolean): Promise }; + + await profilePath.loadStreamerProfile('fixture-alpha', true); + + expect(renderCard).toHaveBeenCalledWith(cachedProfile); + expect(hide).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer-profile.ts b/src/renderer-profile.ts index 160bcbe..ab87bc8 100644 --- a/src/renderer-profile.ts +++ b/src/renderer-profile.ts @@ -52,6 +52,7 @@ function streamerProfilesMatch(left: StreamerProfile, right: StreamerProfile): b } function hideStreamerProfileHeader(): void { + activeProfileRequestId += 1; activeProfileLogin = ''; const el = document.getElementById('streamerProfileHeader'); if (!el) return; @@ -203,14 +204,14 @@ async function loadStreamerProfile(login: string, forceRefresh = false): Promise // while we were waiting on the API. if (reqId !== activeProfileRequestId) return; if (!profile) { - hideStreamerProfileHeader(); + if (!cached) hideStreamerProfileHeader(); return; } const rememberDisplayName = (window as unknown as { rememberStreamerDisplayName?: (login: string, displayName: string) => void }).rememberStreamerDisplayName; if (typeof rememberDisplayName === 'function') rememberDisplayName(profile.login, profile.displayName); if (!cached || !streamerProfilesMatch(cached, profile)) renderStreamerProfileCard(profile); } catch (_) { - if (reqId === activeProfileRequestId) hideStreamerProfileHeader(); + if (reqId === activeProfileRequestId && !cached) hideStreamerProfileHeader(); } } diff --git a/src/renderer-queue.production-path.test.ts b/src/renderer-queue.production-path.test.ts new file mode 100644 index 0000000..5ec5a2f --- /dev/null +++ b/src/renderer-queue.production-path.test.ts @@ -0,0 +1,572 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +const source = readFileSync(join(__dirname, 'renderer-queue.ts'), 'utf8'); +const rendererSource = readFileSync(join(__dirname, 'renderer.ts'), 'utf8'); + +function fragment(start: string, end: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + if (from < 0 || to < 0) throw new Error(`Missing renderer queue fragment: ${start}`); + return source.slice(from, to); +} + +function evaluate>(code: string, names: string, context: T): T & { exposed: Record unknown> } { + const compiled = transpileModule(`${code}\nglobalThis.exposed = { ${names} };`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } + }).outputText; + runInNewContext(compiled, context); + return context as T & { exposed: Record unknown> }; +} + +const queueText = { + openFile: 'Open file', + showInFolder: 'Show in folder', + viewChat: 'View chat', + viewEvents: 'View events', + outputFilesLabel: '{count} files', + openFileFailed: 'Could not open file.', + ctxCopiedUrl: 'URL copied.', + ctxCopyFailed: 'Could not copy URL.', + readyToDownload: 'Ready', + statusPaused: 'Paused', + statusDone: 'Done', + started: 'Started', + done: 'Done', + failed: 'Failed', + part: 'Part' +}; + +class HealthElement { + className = ''; + title = ''; + parent: HealthElement | null = null; + children: HealthElement[] = []; + attributes = new Map(); + + constructor(className = '') { + this.className = className; + } + + querySelector(selector: string): HealthElement | null { + const className = selector.startsWith('.') ? selector.slice(1) : ''; + for (const child of this.children) { + if (child.className.split(/\s+/).includes(className)) return child; + const nested = child.querySelector(selector); + if (nested) return nested; + } + return null; + } + + append(child: HealthElement): void { + child.parent = this; + this.children.push(child); + } + + prepend(child: HealthElement): void { + child.parent = this; + this.children.unshift(child); + } + + insertAdjacentElement(position: string, child: HealthElement): void { + if (position !== 'afterend' || !this.parent) return; + const index = this.parent.children.indexOf(this); + child.parent = this.parent; + this.parent.children.splice(index + 1, 0, child); + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + remove(): void { + if (!this.parent) return; + const index = this.parent.children.indexOf(this); + if (index >= 0) this.parent.children.splice(index, 1); + this.parent = null; + } +} + +class DelegatedElement { + parent: DelegatedElement | null = null; + dataset: Record = {}; + clicked = false; + + constructor(readonly selectors: string[] = []) { } + + closest(selector: string): DelegatedElement | null { + if (selector.split(',').some((entry) => this.selectors.includes(entry.trim()))) return this; + return this.parent?.closest(selector) ?? null; + } + + click(): void { + this.clicked = true; + } + + contains(candidate: DelegatedElement): boolean { + let current: DelegatedElement | null = candidate; + while (current) { + if (current === this) return true; + current = current.parent; + } + return false; + } +} + +class DelegatedList extends DelegatedElement { + private listeners = new Map void>>(); + + addEventListener(type: string, listener: (event: { target: DelegatedElement; key?: string; preventDefault(): void }) => void): void { + this.listeners.set(type, [...(this.listeners.get(type) || []), listener]); + } + + dispatch(type: string, target: DelegatedElement, key?: string): boolean { + let prevented = false; + for (const listener of this.listeners.get(type) || []) { + listener({ target, key, preventDefault: () => { prevented = true; } }); + } + return prevented; + } +} + +function decodeHtmlAttribute(value: string): string { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +describe('renderer queue production paths', () => { + it('routes rendered item controls through delegated data actions without inline JavaScript', () => { + expect(source).not.toMatch(/\son(?:click|keydown)=/); + expect(source).toContain('data-queue-action="details"'); + expect(source).toContain('data-queue-action="remove"'); + expect(source).toContain('data-queue-action="retry"'); + expect(source).toContain('data-id="${escapeHtml(item.id)}"'); + expect(source).toContain("list.addEventListener('click'"); + expect(source).toContain("list.addEventListener('keydown'"); + }); + + it('resolves nested SVG click targets through the Element closest path', () => { + const runtime = evaluate( + fragment('function resolveQueueControl', 'function initQueueActions'), + 'resolveQueueControl', + { Element: DelegatedElement } + ); + const control = new DelegatedElement(['[data-queue-action]']); + const svg = new DelegatedElement(); + const path = new DelegatedElement(); + svg.parent = control; + path.parent = svg; + + expect(runtime.exposed.resolveQueueControl(path)).toBe(control); + expect(runtime.exposed.resolveQueueControl({})).toBeNull(); + }); + + it('contains rejected delegated queue actions and reports them without an unhandled rejection', async () => { + const toasts: Array<[string, string]> = []; + const runtime = evaluate( + fragment('async function invokeQueueItemAction', 'function resolveQueueControl'), + 'activateQueueControl', + { + window: { showAppToast: (message: string, kind: string) => toasts.push([message, kind]) }, + UI_TEXT: { queue: queueText }, + invokeQueueFileAction: async () => { throw new Error('viewer rejected'); }, + toggleQueueDetails: () => undefined, + removeFromQueue: async () => { throw new Error('remove rejected'); }, + retryQueueItem: async () => { throw new Error('retry rejected'); } + } + ); + const list = new DelegatedList(); + const item = new DelegatedElement(['.queue-item']); + item.dataset.id = 'dangerous-id'; + item.parent = list; + const remove = new DelegatedElement(['[data-queue-action]']); + remove.dataset.queueAction = 'remove'; + remove.parent = item; + + await runtime.exposed.activateQueueControl(remove); + expect(toasts).toEqual([['Failed', 'warn']]); + }); + + it('preserves an exact Windows path from rendered dataset through delegated click dispatch', async () => { + const rendered = evaluate( + fragment('function renderQueueItemFileActions', 'async function invokeOpenFile'), + 'renderQueueItemFileActions', + { + UI_TEXT: { queue: queueText }, + escapeHtml: (value: unknown) => String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + } + ); + const windowsPath = "C:\\Users\\O'Brien & Söhne\\new cut.mp4"; + const html = rendered.exposed.renderQueueItemFileActions({ + status: 'completed', + outputFiles: [windowsPath], + title: 'A "quoted" title' + }) as string; + + expect(html).not.toContain('onclick='); + expect(html).toContain('data-queue-file-action="open"'); + expect(html).toContain('data-queue-file-action="folder"'); + expect(html).toContain('C:\\Users\\O'Brien & Söhne\\new cut.mp4'); + + const openButtonMatch = html.match(/]+data-queue-file-action="open"[^>]+data-queue-file-path="([^"]+)"/); + expect(openButtonMatch).not.toBeNull(); + const browserDatasetPath = decodeHtmlAttribute(openButtonMatch![1]); + const calls: string[] = []; + const list = new DelegatedList(); + const control = new DelegatedElement(['[data-queue-file-action]']); + const svg = new DelegatedElement(); + const path = new DelegatedElement(); + control.dataset.queueFileAction = 'open'; + control.dataset.queueFilePath = browserDatasetPath; + control.parent = list; + svg.parent = control; + path.parent = svg; + const dispatched = evaluate( + fragment('async function invokeOpenFile', 'async function copyQueueUrl'), + 'initQueueActions', + { + Element: DelegatedElement, + byId: () => list, + window: { + api: { + openFile: async (filePath: string) => { calls.push(filePath); return true; }, + showInFolder: async () => true + } + }, + UI_TEXT: { queue: queueText }, + openChatViewer: async () => undefined, + openEventsViewer: async () => undefined, + toggleQueueDetails: () => undefined, + removeFromQueue: async () => undefined, + retryQueueItem: async () => undefined + } + ); + + dispatched.exposed.initQueueActions(); + list.dispatch('click', path); + await Promise.resolve(); + await Promise.resolve(); + expect(calls).toEqual([windowsPath]); + }); + + it('reports clipboard success only after fulfillment and reports rejection as a warning', async () => { + let resolveWrite: (() => void) | undefined; + const writeText = vi.fn(() => new Promise((resolve) => { resolveWrite = resolve; })); + const toasts: Array<[string, string]> = []; + const runtime = evaluate( + fragment('async function copyQueueUrl', 'function buildQueueFingerprint'), + 'copyQueueUrl', + { + navigator: { clipboard: { writeText } }, + window: { showAppToast: (message: string, kind: string) => toasts.push([message, kind]) }, + UI_TEXT: { queue: queueText } + } + ); + + const pending = runtime.exposed.copyQueueUrl('https://twitch.example/vod') as Promise; + expect(toasts).toEqual([]); + expect(resolveWrite).toBeTypeOf('function'); + (resolveWrite as () => void)(); + await pending; + expect(toasts).toEqual([['URL copied.', 'info']]); + + runtime.navigator.clipboard.writeText = vi.fn(async () => { throw new Error('denied'); }); + await runtime.exposed.copyQueueUrl('https://twitch.example/vod'); + expect(toasts.at(-1)).toEqual(['Could not copy URL.', 'warn']); + }); + + it('reports rejected and negative file open operations through the shared safe wrappers', async () => { + const toasts: Array<[string, string]> = []; + const openFile = vi.fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('open denied')); + const showInFolder = vi.fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('folder denied')); + const runtime = evaluate( + fragment('async function invokeOpenFile', 'async function invokeQueueFileAction'), + 'invokeOpenFile, invokeShowInFolder', + { + window: { + api: { openFile, showInFolder }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + UI_TEXT: { queue: queueText } + } + ); + + await runtime.exposed.invokeOpenFile('C:\\media\\video.mp4'); + await runtime.exposed.invokeOpenFile('C:\\media\\video.mp4'); + await runtime.exposed.invokeShowInFolder('C:\\media\\video.mp4'); + await runtime.exposed.invokeShowInFolder('C:\\media\\video.mp4'); + + expect(toasts).toEqual([ + ['Could not open file.', 'warn'], + ['Could not open file.', 'warn'], + ['Could not open file.', 'warn'], + ['Could not open file.', 'warn'] + ]); + const contextMenuPath = fragment('function showQueueContextMenu', 'async function moveQueueItemTo'); + expect(contextMenuPath).toContain('() => invokeOpenFile(first)'); + expect(contextMenuPath).toContain('() => invokeShowInFolder(first)'); + expect(contextMenuPath).not.toContain('window.api.openFile(first)'); + expect(contextMenuPath).not.toContain('window.api.showInFolder(first)'); + }); + + it('awaits rejected context menu actions through the shared warning path', () => { + const menuPath = fragment('function showQueueContextMenu', 'async function moveQueueItemTo'); + expect(menuPath).toContain("const makeItem = (label: string, onClick: () => void | Promise"); + expect(menuPath).toContain('void invokeQueueActionSafely(onClick)'); + expect(menuPath).toContain('() => moveQueueItemTo(item.id'); + expect(menuPath).toContain('() => retryQueueItem(item.id)'); + expect(menuPath).toContain('() => window.api.openExternal(item.url)'); + expect(menuPath).toContain('() => removeFromQueue(item.id)'); + expect(menuPath).not.toContain('() => { void moveQueueItemTo'); + expect(menuPath).not.toContain('() => { void retryQueueItem'); + expect(menuPath).not.toContain('() => { void window.api.openExternal'); + expect(menuPath).not.toContain('() => { void removeFromQueue'); + }); + + it('removes the exact document listeners before replacing an open context menu', () => { + const lifecyclePath = fragment('let queueContextMenuInitialized', 'function initQueueContextMenu'); + const calls: Array<[string, string, unknown, boolean]> = []; + const firstCleanup = vi.fn(); + const runtime = evaluate( + lifecyclePath, + 'closeQueueContextMenu, installQueueContextMenuDismissal, setActiveCleanup: (cleanup) => { activeQueueContextMenuCleanup = cleanup; }, getActiveCleanup: () => activeQueueContextMenuCleanup', + { + activeQueueContextMenu: null, + activeQueueContextMenuInvoker: null, + document: { + addEventListener: (type: string, listener: unknown, capture: boolean) => calls.push(['add', type, listener, capture]), + removeEventListener: (type: string, listener: unknown, capture: boolean) => calls.push(['remove', type, listener, capture]) + }, + Node: DelegatedElement + } + ); + + const firstMenu = { contains: () => false }; + const installed = runtime.exposed.installQueueContextMenuDismissal(firstMenu, firstCleanup) as (restoreFocus?: boolean) => void; + runtime.exposed.setActiveCleanup(installed); + runtime.exposed.closeQueueContextMenu(true); + + expect(firstCleanup).toHaveBeenCalledWith(true); + const adds = calls.filter(([operation]) => operation === 'add'); + const removes = calls.filter(([operation]) => operation === 'remove'); + expect(adds).toHaveLength(2); + expect(removes).toHaveLength(2); + expect(removes[0]).toEqual(['remove', adds[0][1], adds[0][2], adds[0][3]]); + expect(removes[1]).toEqual(['remove', adds[1][1], adds[1][2], adds[1][3]]); + expect(runtime.exposed.getActiveCleanup()).toBeNull(); + + const secondCleanup = vi.fn(); + const secondInstalled = runtime.exposed.installQueueContextMenuDismissal(firstMenu, secondCleanup) as (restoreFocus?: boolean) => void; + runtime.exposed.setActiveCleanup(secondInstalled); + runtime.exposed.closeQueueContextMenu(false); + expect(secondCleanup).toHaveBeenCalledWith(false); + const allAdds = calls.filter(([operation]) => operation === 'add'); + const allRemoves = calls.filter(([operation]) => operation === 'remove'); + expect(allAdds).toHaveLength(4); + expect(allRemoves).toHaveLength(4); + expect(allRemoves.slice(2)).toEqual(allAdds.slice(2).map(([, type, listener, capture]) => ['remove', type, listener, capture])); + }); + + it('shows terminal and paused states before multipart progress', () => { + const runtime = evaluate( + fragment('function getQueueProgressStatusText', 'function getQueueProgressMetricsText'), + 'getQueueProgressStatusText', + { UI_TEXT: { queue: queueText } } + ); + const status = runtime.exposed.getQueueProgressStatusText; + + expect(status({ status: 'paused', currentPart: 3, totalParts: 8 })).toBe('Paused'); + expect(status({ status: 'completed', currentPart: 8, totalParts: 8 })).toBe('Done'); + expect(status({ status: 'error', currentPart: 3, totalParts: 8, last_error: 'Disk full' })).toBe('Disk full'); + expect(status({ status: 'downloading', currentPart: 3, totalParts: 8, progressStatus: 'Pause pending' })).toBe('Pause pending'); + expect(status({ status: 'downloading', currentPart: 3, totalParts: 8 })).toBe('Part 3/8'); + }); + + it('shows speed and ETA only while an item is actively downloading', () => { + const runtime = evaluate( + fragment('function getQueueProgressMetricsText', 'function toggleQueueSelection'), + 'getQueueProgressMetricsText', + {} + ); + const metrics = runtime.exposed.getQueueProgressMetricsText; + + expect(metrics({ status: 'downloading', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe('12.3% | 4 MB/s | 2m'); + expect(metrics({ status: 'pending', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe(''); + expect(metrics({ status: 'paused', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe(''); + expect(metrics({ status: 'error', progress: 12.34, speed: '4 MB/s', eta: '2m' })).toBe(''); + expect(metrics({ status: 'completed', progress: 100, speed: '4 MB/s', eta: '2m' })).toBe('100%'); + }); + + it('keeps monotonic progress while treating explicit empty telemetry as an authoritative reset', () => { + const mergePath = rendererSource.slice( + rendererSource.indexOf('function mergeQueueState'), + rendererSource.indexOf('function getQueueStateFingerprint') + ); + const runtime = evaluate( + `${mergePath}\n${fragment('function getQueueProgressMetricsText', 'function toggleQueueSelection')}`, + 'mergeQueueState, getQueueProgressMetricsText', + { + queue: [{ + id: 'active', + status: 'downloading', + progress: 70, + speed: '4 MB/s', + eta: '2m', + currentPart: 2, + totalParts: 5, + downloadedBytes: 700, + totalBytes: 1000, + progressStatus: '70%', + recordingHealth: 'ok' + }] + } + ); + const pausePending = runtime.exposed.mergeQueueState([{ + id: 'active', + status: 'downloading', + progress: 10, + speed: '', + eta: '', + currentPart: 0, + totalParts: 0, + downloadedBytes: 0, + totalBytes: 0, + progressStatus: 'Pause pending', + recordingHealth: 'stale' + }]) as Array>; + + expect(pausePending[0]).toMatchObject({ + progress: 70, + speed: '', + eta: '', + currentPart: 0, + totalParts: 0, + downloadedBytes: 0, + totalBytes: 0, + progressStatus: 'Pause pending', + recordingHealth: 'stale' + }); + expect(runtime.exposed.getQueueProgressMetricsText(pausePending[0])).toBe('70.0%'); + + const retrying = runtime.exposed.mergeQueueState([{ + id: 'active', + status: 'downloading', + progress: -1, + speed: '', + eta: '', + progressStatus: 'Retrying in 5 seconds' + }]) as Array>; + expect(retrying[0]).toMatchObject({ + progress: 70, + speed: '', + eta: '', + progressStatus: 'Retrying in 5 seconds', + recordingHealth: 'ok' + }); + expect(runtime.exposed.getQueueProgressMetricsText(retrying[0])).toBe('70.0%'); + + const missingTelemetry = runtime.exposed.mergeQueueState([{ + id: 'active', + status: 'downloading', + progress: 20 + }]) as Array>; + expect(missingTelemetry[0]).toMatchObject({ + progress: 70, + speed: '4 MB/s', + eta: '2m', + currentPart: 2, + totalParts: 5, + downloadedBytes: 700, + totalBytes: 1000, + progressStatus: '70%', + recordingHealth: 'ok' + }); + }); + + it('includes recording health in render invalidation and updates its visible badge in place', () => { + const fingerprints = evaluate( + fragment('function getQueueRenderFingerprint', 'function hasActiveQueueDuplicate'), + 'getQueueRenderFingerprint', + { currentLanguage: 'en', selectedQueueIds: [], expandedQueueIds: new Set() } + ); + const base = { id: 'live-1', status: 'downloading', progress: 1, isLive: true }; + const unknown = fingerprints.exposed.getQueueRenderFingerprint([{ ...base, recordingHealth: 'unknown' }]) as string; + const ok = fingerprints.exposed.getQueueRenderFingerprint([{ ...base, recordingHealth: 'ok' }]) as string; + const stale = fingerprints.exposed.getQueueRenderFingerprint([{ ...base, recordingHealth: 'stale' }]) as string; + expect(new Set([unknown, ok, stale]).size).toBe(3); + + const healthPath = fragment('function syncQueueRecordingHealth', 'function updateQueueItemProgress'); + expect(healthPath).toContain("health === 'ok'"); + expect(healthPath).toContain("health === 'stale'"); + expect(fragment('function updateQueueItemProgress', 'function toggleQueueDetails')).toContain('syncQueueRecordingHealth(el, item)'); + + const runtime = evaluate( + healthPath, + 'syncQueueRecordingHealth', + { + UI_TEXT: { queue: { recordingHealth: { unknown: 'Pending', ok: 'Healthy', stale: 'Stalled' } } }, + document: { createElement: () => new HealthElement() } + } + ); + const root = new HealthElement('queue-item'); + const title = new HealthElement('title'); + const live = new HealthElement('queue-live-badge'); + title.append(live); + root.append(title); + + runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'downloading', recordingHealth: 'unknown' }); + const badge = root.querySelector('.queue-health-dot'); + expect(badge?.className).toBe('queue-health-dot health-unknown'); + expect(badge?.title).toBe('Pending'); + expect(badge?.attributes.get('aria-label')).toBe('Pending'); + + runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'downloading', recordingHealth: 'ok' }); + expect(root.querySelector('.queue-health-dot')).toBe(badge); + expect(badge?.className).toBe('queue-health-dot health-ok'); + expect(badge?.title).toBe('Healthy'); + + runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'downloading', recordingHealth: 'stale' }); + expect(badge?.className).toBe('queue-health-dot health-stale'); + expect(badge?.title).toBe('Stalled'); + + runtime.exposed.syncQueueRecordingHealth(root, { isLive: true, status: 'paused', recordingHealth: 'stale' }); + expect(root.querySelector('.queue-health-dot')).toBeNull(); + + const mergePath = rendererSource.slice( + rendererSource.indexOf('function mergeQueueState'), + rendererSource.indexOf('function getQueueStateFingerprint') + ); + expect(mergePath).toContain('recordingHealth: item.recordingHealth === undefined ? prev.recordingHealth : item.recordingHealth'); + }); + + it('matches progress elements by exact dataset identity without constructing a CSS selector from the queue id', () => { + const progressPath = fragment('function updateQueueItemProgress', 'function toggleQueueDetails'); + expect(progressPath).toContain("querySelectorAll('.queue-item')"); + expect(progressPath).toContain('candidate.dataset.id === progressId'); + expect(progressPath).not.toContain('`[data-id="${'); + expect(progressPath).not.toContain("replace(/\"/g"); + }); + + it('does not offer retry actions while interrupted merge artifacts remain', () => { + expect(source).toContain("queue.some((item) => item.status === 'error' && !item.mergeRecoveryBlocked)"); + expect(source).toContain("const isFailed = item.status === 'error' && !item.mergeRecoveryBlocked"); + expect(source).toContain("item.status === 'error' && !item.mergeRecoveryBlocked ?"); + expect(source).toContain("item.mergeRecoveryBlocked ? 'blocked' : ''"); + }); +}); diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index d28ea51..7f11066 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -14,30 +14,27 @@ function renderQueueItemFileActions(item: QueueItem): string { 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(``); + buttons.push(``); } - buttons.push(``); + buttons.push(``); // 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(``); + buttons.push(``); } // 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(``); + buttons.push(``); } const fileLabel = item.outputFiles.length === 1 @@ -53,18 +50,109 @@ function renderQueueItemFileActions(item: QueueItem): string { } async function invokeOpenFile(filePath: string): Promise { - 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'); + let ok: boolean; + try { + ok = await window.api.openFile(filePath); + } catch { + ok = false; } + if (ok) return; + 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 { - const ok = await window.api.showInFolder(filePath); - if (!ok) { + let ok: boolean; + try { + ok = await window.api.showInFolder(filePath); + } catch { + ok = false; + } + if (ok) return; + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn'); +} + +async function invokeQueueFileAction(action: string, filePath: string, title = ''): Promise { + if (action === 'open') { + await invokeOpenFile(filePath); + } else if (action === 'folder') { + await invokeShowInFolder(filePath); + } else if (action === 'chat') { + await openChatViewer(filePath, title); + } else if (action === 'events') { + await openEventsViewer(filePath, title); + } +} + +let queueActionsInitialized = false; + +async function invokeQueueItemAction(action: string, id: string): Promise { + if (action === 'details') { + toggleQueueDetails(id); + } else if (action === 'remove') { + await removeFromQueue(id); + } else if (action === 'retry') { + await retryQueueItem(id); + } +} + +async function invokeQueueActionSafely(action: () => void | Promise): Promise { + try { + await action(); + } catch { const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; - if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn'); + if (toast) toast(UI_TEXT.queue.failed, 'warn'); + } +} + +async function activateQueueControl(control: HTMLElement): Promise { + await invokeQueueActionSafely(async () => { + const fileAction = control.dataset.queueFileAction; + const filePath = control.dataset.queueFilePath; + if (fileAction && filePath) { + await invokeQueueFileAction(fileAction, filePath, control.dataset.queueFileTitle || ''); + return; + } + + const action = control.dataset.queueAction; + const item = control.closest('.queue-item'); + const id = item?.dataset.id; + if (!action || !id) return; + await invokeQueueItemAction(action, id); + }); +} + +function resolveQueueControl(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + return target.closest('[data-queue-action], [data-queue-file-action]'); +} + +function initQueueActions(): void { + if (queueActionsInitialized) return; + queueActionsInitialized = true; + const list = byId('queueList'); + list.addEventListener('click', (event: MouseEvent) => { + const control = resolveQueueControl(event.target); + if (!control || !list.contains(control)) return; + void activateQueueControl(control); + }); + list.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const control = resolveQueueControl(event.target); + if (!control || !list.contains(control)) return; + event.preventDefault(); + control.click(); + }); +} + +async function copyQueueUrl(url: string): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + try { + await navigator.clipboard.writeText(url); + if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info'); + } catch { + if (toast) toast(UI_TEXT.queue.ctxCopyFailed, 'warn'); } } @@ -101,7 +189,9 @@ function getQueueRenderFingerprint(items: QueueItem[]): string { item.speed || '', item.eta || '', item.progressStatus || '', + item.recordingHealth || '', item.last_error || '', + item.mergeRecoveryBlocked ? 'blocked' : '', item.mergeGroup?.mergePhase || '' ].join(':')); @@ -158,8 +248,15 @@ async function retryQueueItem(id: string): Promise { let queueContextMenuInitialized = false; let activeQueueContextMenu: HTMLElement | null = null; let activeQueueContextMenuInvoker: HTMLElement | null = null; +let activeQueueContextMenuCleanup: ((restoreFocus?: boolean) => void) | null = null; function closeQueueContextMenu(restoreFocus = false): void { + const cleanup = activeQueueContextMenuCleanup; + if (cleanup) { + activeQueueContextMenuCleanup = null; + cleanup(restoreFocus); + return; + } if (!activeQueueContextMenu) return; activeQueueContextMenu.remove(); activeQueueContextMenu = null; @@ -168,6 +265,26 @@ function closeQueueContextMenu(restoreFocus = false): void { if (restoreFocus && invoker?.isConnected) invoker.focus(); } +function installQueueContextMenuDismissal(menu: HTMLElement, cleanupMenu: (restoreFocus: boolean) => void): (restoreFocus?: boolean) => void { + let cleaned = false; + let cleanup: (restoreFocus?: boolean) => void; + const dismissOnClick = (event: MouseEvent) => { + if (event.target instanceof Node && menu.contains(event.target)) return; + cleanup(); + }; + const dismissOnScroll = () => cleanup(); + cleanup = (restoreFocus = false): void => { + if (cleaned) return; + cleaned = true; + document.removeEventListener('mousedown', dismissOnClick, true); + document.removeEventListener('scroll', dismissOnScroll, true); + cleanupMenu(restoreFocus); + }; + document.addEventListener('mousedown', dismissOnClick, true); + document.addEventListener('scroll', dismissOnScroll, true); + return cleanup; +} + function initQueueContextMenu(): void { if (queueContextMenuInitialized) return; queueContextMenuInitialized = true; @@ -203,8 +320,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT menu.className = 'context-menu'; menu.setAttribute('role', 'menu'); - let cleanup = (restoreFocus = false): void => closeQueueContextMenu(restoreFocus); - const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => { + const makeItem = (label: string, onClick: () => void | Promise, disabled = false): HTMLElement => { const el = document.createElement('button'); el.type = 'button'; el.textContent = label; @@ -216,7 +332,8 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT } if (!disabled) { el.addEventListener('click', () => { - try { onClick(); } finally { cleanup(); } + closeQueueContextMenu(); + void invokeQueueActionSafely(onClick); }); } return el; @@ -230,7 +347,7 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT }; const isPending = item.status === 'pending' || item.status === 'paused'; - const isFailed = item.status === 'error'; + const isFailed = item.status === 'error' && !item.mergeRecoveryBlocked; const isCompleted = item.status === 'completed'; const canSelectForMerge = item.status === 'pending' && !item.mergeGroup && !item.isLive; @@ -246,37 +363,29 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT } 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(makeItem(UI_TEXT.queue.ctxMoveTop, () => moveQueueItemTo(item.id, 'top'))); + menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => moveQueueItemTo(item.id, 'bottom'))); menu.appendChild(makeSeparator()); } if (isFailed) { - menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); })); + menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => 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.openFile, () => invokeOpenFile(first))); } - menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); })); + menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => invokeShowInFolder(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(makeItem(UI_TEXT.queue.ctxCopyUrl, () => copyQueueUrl(item.url))); + menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => window.api.openExternal(item.url))); menu.appendChild(makeSeparator()); - menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); })); + menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => removeFromQueue(item.id))); document.body.appendChild(menu); activeQueueContextMenu = menu; @@ -290,20 +399,21 @@ function showQueueContextMenu(x: number, y: number, item: QueueItem, invoker: HT 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 dismissOnScroll = () => cleanup(); - cleanup = (restoreFocus = false): void => { - closeQueueContextMenu(restoreFocus); - document.removeEventListener('mousedown', dismissOnClick, true); - document.removeEventListener('scroll', dismissOnScroll, true); - }; - document.addEventListener('mousedown', dismissOnClick, true); - document.addEventListener('scroll', dismissOnScroll, true); - RendererAccessibility.installMenuKeyboardNavigation(menu, () => cleanup(true)); + let cleanup: (restoreFocus?: boolean) => void; + cleanup = installQueueContextMenuDismissal(menu, (restoreFocus) => { + if (activeQueueContextMenuCleanup === cleanup) activeQueueContextMenuCleanup = null; + if (activeQueueContextMenu === menu) { + activeQueueContextMenu = null; + const currentInvoker = activeQueueContextMenuInvoker; + activeQueueContextMenuInvoker = null; + menu.remove(); + if (restoreFocus && currentInvoker?.isConnected) currentInvoker.focus(); + return; + } + menu.remove(); + }); + activeQueueContextMenuCleanup = cleanup; + RendererAccessibility.installMenuKeyboardNavigation(menu, () => closeQueueContextMenu(true)); RendererAccessibility.focusFirstMenuItem(menu); } @@ -332,14 +442,15 @@ function getQueueProgressStatusText(item: QueueItem): string { return item.last_error; } + if (item.status === 'pending') return UI_TEXT.queue.readyToDownload; + if (item.status === 'paused') return UI_TEXT.queue.statusPaused; + if (item.status === 'completed') return UI_TEXT.queue.done; + if (item.status === 'error') return UI_TEXT.queue.failed; + if (item.status === 'downloading' && item.progressStatus) return item.progressStatus; if (item.currentPart && item.totalParts) { return `${UI_TEXT.queue.part} ${item.currentPart}/${item.totalParts}`; } - - if (item.status === 'pending') return UI_TEXT.queue.readyToDownload; - if (item.status === 'paused') return UI_TEXT.queue.statusPaused; - if (item.status === 'downloading') return item.progressStatus || UI_TEXT.queue.started; - if (item.status === 'completed') return UI_TEXT.queue.done; + if (item.status === 'downloading') return UI_TEXT.queue.started; return UI_TEXT.queue.failed; } @@ -349,8 +460,8 @@ function getQueueProgressMetricsText(item: QueueItem): string { if (item.status === 'downloading' && item.progress > 0) { parts.push(`${Math.max(0, Math.min(100, item.progress)).toFixed(1)}%`); } - if (item.speed) parts.push(item.speed); - if (item.eta) parts.push(item.eta); + if (item.status === 'downloading' && item.speed) parts.push(item.speed); + if (item.status === 'downloading' && item.eta) parts.push(item.eta); return parts.join(' | '); } @@ -394,14 +505,40 @@ async function createMergeGroupFromSelection(): Promise { updateMergeGroupButton(); } +function syncQueueRecordingHealth(el: HTMLElement, item: QueueItem): void { + const current = el.querySelector('.queue-health-dot'); + const health = item.isLive && item.status === 'downloading' ? item.recordingHealth : undefined; + if (!health) { + current?.remove(); + return; + } + + const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' }; + const className = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown'); + const label = labels[health] || ''; + let badge = current; + if (!badge) { + const title = el.querySelector('.title'); + if (!title) return; + badge = document.createElement('span'); + const liveBadge = title.querySelector('.queue-live-badge'); + if (liveBadge) liveBadge.insertAdjacentElement('afterend', badge); + else title.prepend(badge); + } + badge.className = `queue-health-dot ${className}`; + badge.title = label; + badge.setAttribute('aria-label', label); +} + 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; + const progressId = String(progress.id ?? ''); + if (!progressId) return; + const list = byId('queueList'); + const el = Array.from(list.querySelectorAll('.queue-item')) + .find((candidate) => candidate.dataset.id === progressId) || null; if (!el) return; - const item = queue.find(i => i.id === progress.id); + const item = queue.find(i => String(i.id) === progressId); if (!item) return; const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null; @@ -418,6 +555,7 @@ function updateQueueItemProgress(progress: DownloadProgress): void { } if (status) status.textContent = getQueueProgressStatusText(item); if (metrics) metrics.textContent = getQueueProgressMetricsText(item); + syncQueueRecordingHealth(el, item); } function toggleQueueDetails(id: string): void { @@ -488,10 +626,11 @@ function renderQueue(): void { } const list = byId('queueList'); + initQueueActions(); byId('queueCount').textContent = String(queue.length); const retryBtn = byId('btnRetryFailed'); const clearBtn = byId('btnClear'); - const hasFailed = queue.some((item) => item.status === 'error'); + const hasFailed = queue.some((item) => item.status === 'error' && !item.mergeRecoveryBlocked); const hasCompleted = queue.some((item) => item.status === 'completed'); retryBtn.disabled = !hasFailed; clearBtn.disabled = !hasCompleted; @@ -516,7 +655,7 @@ function renderQueue(): void { return; } - list.innerHTML = queue.map((item: QueueItem) => { + list.innerHTML = queue.map((item: QueueItem, itemIndex: number) => { const safeTitle = escapeHtml(item.title || UI_TEXT.vods.untitled); const safeStatusLabel = escapeHtml(getQueueStatusLabel(item)); const safeProgressStatus = escapeHtml(getQueueProgressStatusText(item)); @@ -546,16 +685,17 @@ function renderQueue(): void { const mergeMetaExtra = isMergeGroup ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` : ''; + const detailsId = `queue-details-${itemIndex}`; return ` -
+
${isSelected ? `${selectionPosition}` : ''}
-
${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}
+
${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}
${safeStatusLabel}
- x + x
${safeDate}${mergeMetaExtra}
@@ -565,7 +705,7 @@ function renderQueue(): void { ${safeProgressStatus} ${safeProgressMetrics}
-
+
URL: ${escapeHtml(item.url)}
${escapeHtml(UI_TEXT.queue.detailStreamer)} ${escapeHtml(item.streamer)}
${escapeHtml(UI_TEXT.queue.detailDuration)} ${escapeHtml(item.duration_str)}
@@ -573,7 +713,7 @@ function renderQueue(): void { ${renderQueueItemFileActions(item)}
- ${item.status === 'error' ? `` : ''} + ${item.status === 'error' && !item.mergeRecoveryBlocked ? `` : ''}
`; }).join(''); diff --git a/src/renderer-settings-autosave.test.ts b/src/renderer-settings-autosave.test.ts index 7441019..87ad74a 100644 --- a/src/renderer-settings-autosave.test.ts +++ b/src/renderer-settings-autosave.test.ts @@ -25,6 +25,121 @@ function createInput(value = '', checked = false): Input { return { value, checked }; } +class InteractiveInput { + value = ''; + checked = false; + disabled = false; + textContent = ''; + className = ''; + readonly classList = { + add: (..._tokens: string[]) => undefined, + remove: (..._tokens: string[]) => undefined, + contains: (_token: string) => false, + toggle: (_token: string, force?: boolean) => force ?? false + }; + private readonly listeners = new Map void>>(); + + addEventListener(type: string, listener: () => void): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + dispatch(type: string): void { + for (const listener of this.listeners.get(type) ?? []) listener(); + } + + setAttribute(_name: string, _value: string): void { } + + select(): void { } +} + +type AutosaveRuntime = { + inputs: Map; + saveConfigCalls: Array>; + scheduled: Array<() => void>; +}; + +function createAutosaveRuntime(): AutosaveRuntime { + const inputs = new Map(inputIds.map((id) => [id, new InteractiveInput()])); + for (const id of ['partMinutesLabel', 'downloadPolicyStatus', 'templateLint']) { + inputs.set(id, new InteractiveInput()); + } + const saveConfigCalls: Array> = []; + const scheduled: Array<() => void> = []; + const config = { + download_policy: { throttle: null, windows: [] }, + auto_resume_live_recording: true, + auto_merge_resumed_parts: false, + delete_parts_after_merge: false, + discord_notify_vod_auto_queued: false, + auto_vod_download_poll_minutes: 15, + auto_vod_max_age_hours: 24 + }; + const window = { + api: { + setClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + clearClientSecret: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + setDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + clearDiscordWebhook: () => Promise.resolve({ encryptionAvailable: true, clientSecretConfigured: false, discordWebhookConfigured: false }), + getDownloadPolicyStatus: () => Promise.resolve({ waiting: false, nextStart: null }), + onDownloadPolicyStatus: () => undefined, + saveConfig(payload: Record) { + saveConfigCalls.push(payload); + return Promise.resolve(payload); + } + }, + setTimeout(callback: () => void) { + scheduled.push(callback); + return scheduled.length; + }, + clearTimeout: () => undefined, + addEventListener: () => undefined + }; + const sandbox = { + window, + config, + UI_TEXT: { + status: {}, + static: { + downloadThrottleInvalid: 'Invalid rate', + downloadWindowsInvalid: 'Invalid window', + downloadPolicyReady: 'Ready', + downloadPolicyWaiting: 'Waiting until {time}', + templateLintOk: 'Valid', + templateLintWarn: 'Invalid' + }, + streamers: {} + }, + byId: (id: string) => { + if (!inputs.has(id)) inputs.set(id, new InteractiveInput()); + return inputs.get(id); + }, + collectUnknownTemplatePlaceholders: () => [], + applySidebarLayoutPreference: () => undefined, + formatUiDateTime: (value: string) => value, + document: { + hidden: false, + querySelector: () => null, + getElementById: () => null, + addEventListener: () => undefined + }, + setTimeout, + clearTimeout, + console + }; + const context = vm.createContext(sandbox); + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, + }).outputText; + vm.runInContext(compiled, context); + vm.runInContext('initSettingsAutoSave()', context); + return { inputs, saveConfigCalls, scheduled }; +} + +async function settleAutosave(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string | null) => string { const localeName = language === 'de' ? 'UI_TEXT_DE' : 'UI_TEXT_EN'; const localeSource = fs.readFileSync(path.join(process.cwd(), 'src', `renderer-locale-${language}.ts`), 'utf8'); @@ -39,6 +154,40 @@ function loadRuntimeErrorFormatter(language: 'de' | 'en'): (errorClass: string | } describe('renderer settings autosave orchestration', () => { + it.each([ + ['autoResumeLiveRecordingToggle', 'auto_resume_live_recording', false], + ['autoMergeResumedPartsToggle', 'auto_merge_resumed_parts', true], + ['deletePartsAfterMergeToggle', 'delete_parts_after_merge', true], + ['discordNotifyVodAutoQueuedToggle', 'discord_notify_vod_auto_queued', true] + ] as const)('persists %s through its change listener', async (controlId, configKey, nextValue) => { + const runtime = createAutosaveRuntime(); + const control = runtime.inputs.get(controlId)!; + control.checked = nextValue; + + control.dispatch('change'); + await settleAutosave(); + + expect(runtime.saveConfigCalls).toHaveLength(1); + expect(runtime.saveConfigCalls[0][configKey]).toBe(nextValue); + }); + + it.each([ + ['autoVodPollMinutes', 'auto_vod_download_poll_minutes', '30', 30], + ['autoVodMaxAgeHours', 'auto_vod_max_age_hours', '48', 48] + ] as const)('persists %s through its debounced input listener', async (controlId, configKey, nextValue, expectedValue) => { + const runtime = createAutosaveRuntime(); + const control = runtime.inputs.get(controlId)!; + control.value = nextValue; + + control.dispatch('input'); + expect(runtime.scheduled).toHaveLength(1); + runtime.scheduled[0](); + await settleAutosave(); + + expect(runtime.saveConfigCalls).toHaveLength(1); + expect(runtime.saveConfigCalls[0][configKey]).toBe(expectedValue); + }); + it('persists a pure download policy change through the real autosave fingerprint', async () => { const inputs = new Map(inputIds.map((id) => [id, createInput()])); inputs.get('downloadThrottleMiBps')!.value = '1'; diff --git a/src/renderer-settings.production-path.test.ts b/src/renderer-settings.production-path.test.ts new file mode 100644 index 0000000..f8e7144 --- /dev/null +++ b/src/renderer-settings.production-path.test.ts @@ -0,0 +1,323 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, it } from 'vitest'; + +class FakeClassList { + private readonly values = new Set(); + + add(...tokens: string[]): void { + tokens.forEach((token) => this.values.add(token)); + } + + remove(...tokens: string[]): void { + tokens.forEach((token) => this.values.delete(token)); + } + + contains(token: string): boolean { + return this.values.has(token); + } + + toggle(token: string, force?: boolean): boolean { + const enabled = force ?? !this.values.has(token); + if (enabled) this.values.add(token); + else this.values.delete(token); + return enabled; + } +} + +class FakeElement { + textContent = ''; + value = ''; + checked = false; + disabled = false; + className = ''; + title = ''; + readonly classList = new FakeClassList(); + readonly dataset: Record = {}; + readonly attributes = new Map(); + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + removeAttribute(name: string): void { + this.attributes.delete(name); + } +} + +const settingsSource = readFileSync(join(__dirname, 'renderer-settings.ts'), 'utf8'); + +function sourceFragment(start: string, end: string): string { + const from = settingsSource.indexOf(start); + const to = settingsSource.indexOf(end, from); + if (from < 0 || to < 0) throw new Error(`Missing renderer settings fragment: ${start}`); + return settingsSource.slice(from, to); +} + +function evaluate( + source: string, + context: Record, + exposedNames: string +): Record unknown> { + const compiled = transpileModule(`${source}\nObject.assign(globalThis, { __settingsProductionPath: { ${exposedNames} } });`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }, + }).outputText; + runInNewContext(compiled, context); + return (context as { __settingsProductionPath: Record unknown> }).__settingsProductionPath; +} + +function createElements(...ids: string[]): Map { + return new Map(ids.map((id) => [id, new FakeElement()])); +} + +describe('renderer settings production diagnostics paths', () => { + it('ends every consecutive runtime metrics rejection in the localized error state', async () => { + const elements = createElements('runtimeMetricsOutput'); + const context = { + UI_TEXT: { static: { runtimeMetricsLoading: 'Loading metrics...', runtimeMetricsError: 'Could not load runtime metrics.' } }, + byId: (id: string) => elements.get(id), + window: { api: { getRuntimeMetrics: () => Promise.reject(new Error('IPC unavailable')) } }, + lastRuntimeMetricsOutput: '', + }; + const api = evaluate( + sourceFragment('async function refreshRuntimeMetrics', 'async function exportRuntimeMetrics'), + context, + 'refreshRuntimeMetrics' + ); + + await api.refreshRuntimeMetrics(); + expect(elements.get('runtimeMetricsOutput')?.textContent).toBe('Could not load runtime metrics.'); + + await api.refreshRuntimeMetrics(); + expect(elements.get('runtimeMetricsOutput')?.textContent).toBe('Could not load runtime metrics.'); + }); + + it('invalidates a prior green preflight result when the next IPC check rejects', async () => { + const elements = createElements('btnPreflightRun', 'btnPreflightFix', 'preflightResult', 'healthBadge'); + const context = { + UI_TEXT: { + static: { + preflightChecking: 'Checking...', + preflightRun: 'Run check', + preflightFixing: 'Fixing...', + preflightFix: 'Auto-fix tools', + preflightEmpty: 'No checks run yet.', + preflightError: 'System check failed.', + preflightInternet: 'Internet', + preflightStreamlink: 'Streamlink', + preflightFfmpeg: 'FFmpeg', + preflightFfprobe: 'FFprobe', + preflightPath: 'Download path', + preflightNoInternet: 'No internet connection detected.', + preflightStreamlinkMissing: 'Streamlink is missing or not runnable.', + preflightFfmpegMissing: 'FFmpeg is missing or not runnable.', + preflightFfprobeMissing: 'FFprobe is missing or not runnable.', + preflightDownloadPathNotWritable: 'Download folder is not writable.', + preflightReady: 'Everything is ready.', + healthGood: 'System: Stable', + healthWarn: 'System: Limited', + healthBad: 'System: Problems', + healthUnknown: 'System: Unknown', + }, + }, + byId: (id: string) => elements.get(id), + window: { api: { runPreflight: () => Promise.reject(new Error('IPC unavailable')) } }, + preflightGeneration: 0, + lastPreflightResult: null, + }; + const api = evaluate( + sourceFragment('function renderPreflightButtonLabels', 'function getManagedToolStateLabel'), + context, + 'renderPreflightResult, runPreflight, refreshLocalizedPreflightUi' + ); + api.renderPreflightResult({ + checks: { + internet: true, + streamlink: true, + ffmpeg: true, + ffprobe: true, + downloadPathWritable: true, + }, + }); + + await Promise.resolve(api.runPreflight(false)).catch(() => undefined); + + expect(elements.get('preflightResult')?.textContent).toBe('System check failed.'); + expect(elements.get('healthBadge')?.textContent).toBe('System: Unknown'); + expect(elements.get('healthBadge')?.classList.contains('unknown')).toBe(true); + expect(elements.get('healthBadge')?.classList.contains('good')).toBe(false); + + context.UI_TEXT.static.preflightError = 'System-Check fehlgeschlagen.'; + context.UI_TEXT.static.healthUnknown = 'System: Unbekannt'; + api.refreshLocalizedPreflightUi(); + expect(elements.get('preflightResult')?.textContent).toBe('System-Check fehlgeschlagen.'); + expect(elements.get('healthBadge')?.textContent).toBe('System: Unbekannt'); + }); +}); + +type ImportRuntime = { + context: Record; + elements: Map; + themeButtons: FakeElement[]; + queueLabel: FakeElement; +}; + +function createImportRuntime(nextConfig: Record, initialConfig: Record): ImportRuntime { + const elements = createElements( + 'btnPreflightRun', + 'btnPreflightFix', + 'preflightResult', + 'healthBadge', + 'languageSelect', + 'langOptionDe', + 'langOptionEn', + 'languagePicker', + 'themeSelect', + 'statusText', + 'statusDot', + 'settingsSearchInput', + 'pageTitle' + ); + const themeButtons = ['light', 'twitch', 'system'].map((theme) => { + const button = new FakeElement(); + button.dataset.theme = theme; + return button; + }); + const queueLabel = new FakeElement(); + const body = new FakeElement(); + body.className = `theme-${String(initialConfig.theme ?? 'twitch')}`; + elements.get('languageSelect')!.value = String(initialConfig.language ?? 'en'); + elements.get('themeSelect')!.value = String(initialConfig.theme ?? 'twitch'); + const englishText = { + appName: 'Twitch VOD Manager', + tabs: { settings: 'Settings' }, + static: { + preflightChecking: 'Checking...', + preflightRun: 'Run check', + preflightFixing: 'Fixing...', + preflightFix: 'Auto-fix tools', + preflightEmpty: 'No checks run yet.', + healthUnknown: 'System: Unknown', + configImported: 'Configuration imported.', + }, + queue: { title: 'Queue' }, + }; + const germanText = { + appName: 'Twitch VOD Manager', + tabs: { settings: 'Einstellungen' }, + static: { + preflightChecking: 'Prüfe...', + preflightRun: 'Check ausführen', + preflightFixing: 'Fixe...', + preflightFix: 'Tools reparieren', + preflightEmpty: 'Noch kein Check ausgeführt.', + healthUnknown: 'System: Unbekannt', + configImported: 'Konfiguration importiert.', + }, + queue: { title: 'Warteschlange' }, + }; + const toasts: string[] = []; + const document = { + body, + querySelector: (selector: string) => selector === '.tab-content.active' ? { id: 'settingsTab' } : null, + querySelectorAll: (selector: string) => selector === '#workspaceThemePicker [data-theme]' ? themeButtons : [], + }; + const window = { + api: { + importConfig: () => Promise.resolve({ success: true }), + getConfig: () => Promise.resolve(nextConfig), + saveConfig: () => Promise.resolve(nextConfig), + }, + showAppToast: (message: string) => toasts.push(message), + }; + const context: Record = { + window, + document, + config: { ...initialConfig }, + currentLanguage: initialConfig.language === 'de' ? 'de' : 'en', + UI_TEXT: initialConfig.language === 'de' ? germanText : englishText, + isConnected: false, + currentStreamer: '', + lastLoadedStreamer: '', + lastPreflightResult: null, + preflightFailed: false, + preflightGeneration: 0, + byId: (id: string) => { + if (!elements.has(id)) elements.set(id, new FakeElement()); + return elements.get(id); + }, + setLanguage: (language: string) => { + const normalized = language === 'en' ? 'en' : 'de'; + context.currentLanguage = normalized; + context.UI_TEXT = normalized === 'de' ? germanText : englishText; + return normalized; + }, + localizeCurrentStatusText: (status: string) => status, + updateStatus: () => undefined, + renderQueue: () => { + queueLabel.textContent = (context.UI_TEXT as typeof englishText).queue.title; + }, + renderStreamers: () => undefined, + renderVodGridFromCurrentState: () => undefined, + refreshVodSortSelectLabels: () => undefined, + refreshRuntimeMetrics: () => Promise.resolve(), + refreshAutomationStatusLine: () => Promise.resolve(), + validateFilenameTemplates: () => true, + filterSettings: () => undefined, + syncSettingsFormFromConfig: () => undefined, + scheduleSegmentedIndicatorSync: () => undefined, + }; + return { context, elements, themeButtons, queueLabel }; +} + +function evaluateImportRuntime(runtime: ImportRuntime): Record unknown> { + return evaluate( + [ + sourceFragment('function changeLanguage', 'function getManagedToolStateLabel'), + sourceFragment('async function importConfigFromFile', 'async function resetDownloadedIds'), + sourceFragment('function syncWorkspaceThemePicker', 'function formatRelativeTime'), + ].join('\n'), + runtime.context, + 'importConfigFromFile' + ); +} + +describe('renderer settings config import production path', () => { + it('applies imported language and theme to controls and dependent dynamic content immediately', async () => { + const runtime = createImportRuntime( + { language: 'de', theme: 'light', client_id: 'imported' }, + { language: 'en', theme: 'twitch', client_id: 'current' } + ); + const api = evaluateImportRuntime(runtime); + + await api.importConfigFromFile(); + + expect(runtime.elements.get('languageSelect')?.value).toBe('de'); + expect(runtime.elements.get('langOptionDe')?.classList.contains('active')).toBe(true); + expect(runtime.queueLabel.textContent).toBe('Warteschlange'); + expect(runtime.elements.get('themeSelect')?.value).toBe('light'); + expect((runtime.context.document as { body: FakeElement }).body.className).toBe('theme-light'); + expect(runtime.themeButtons.find((button) => button.dataset.theme === 'light')?.getAttribute('aria-pressed')).toBe('true'); + }); + + it('preserves the current renderer language and theme when imported config omits them', async () => { + const runtime = createImportRuntime( + { client_id: 'imported' }, + { language: 'de', theme: 'light', client_id: 'current' } + ); + const api = evaluateImportRuntime(runtime); + + await api.importConfigFromFile(); + + expect(runtime.context.config).toMatchObject({ client_id: 'imported', language: 'de', theme: 'light' }); + expect(runtime.elements.get('languageSelect')?.value).toBe('de'); + expect(runtime.elements.get('themeSelect')?.value).toBe('light'); + expect((runtime.context.document as { body: FakeElement }).body.className).toBe('theme-light'); + }); +}); diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index ad49216..9bf3906 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -8,6 +8,7 @@ let pendingCredentialsReconnect = false; let lastPersistedSettingsFingerprint = ''; let settingsInputGeneration = 0; let lastPreflightResult: PreflightResult | null = null; +let preflightFailed = false; let preflightGeneration = 0; const SECRET_INPUT_MASK = '••••••••'; let secretStatus: SecretStatus = { @@ -209,10 +210,8 @@ async function refreshRuntimeMetrics(showLoading = true): Promise { lastRuntimeMetricsOutput = nextOutput; } } catch { - if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) { - output.textContent = UI_TEXT.static.runtimeMetricsError; - lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError; - } + output.textContent = UI_TEXT.static.runtimeMetricsError; + lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError; } } @@ -314,11 +313,15 @@ function setSettingsPane(pane: string, source?: HTMLElement): void { } function changeLanguage(lang: string): void { + const normalized = applyRendererLanguage(lang); + void window.api.saveConfig({ language: normalized }); +} + +function applyRendererLanguage(lang: string): LanguageCode { const normalized = setLanguage(lang); byId('languageSelect').value = normalized; updateLanguagePicker(normalized); config.language = normalized; - void window.api.saveConfig({ language: normalized }); const currentStatus = byId('statusText').textContent?.trim() || ''; const statusTone: ConnectionStatusTone = isConnected @@ -349,6 +352,7 @@ function changeLanguage(lang: string): void { refreshLocalizedPreflightUi(); validateFilenameTemplates(); filterSettings(byId('settingsSearchInput').value); + return normalized; } function updateLanguagePicker(lang: string): void { @@ -377,20 +381,33 @@ function renderPreflightButtonLabels(): void { function refreshLocalizedPreflightUi(): void { renderPreflightButtonLabels(); if (lastPreflightResult) renderPreflightResult(lastPreflightResult); + else if (preflightFailed) renderPreflightError(); } function invalidatePreflightResult(): void { preflightGeneration += 1; lastPreflightResult = null; - byId('preflightResult').textContent = UI_TEXT.static.preflightEmpty; + preflightFailed = false; + renderUnknownPreflightState(UI_TEXT.static.preflightEmpty); +} + +function renderUnknownPreflightState(message: string): void { + byId('preflightResult').textContent = message; const badge = byId('healthBadge'); badge.classList.remove('good', 'warn', 'bad', 'unknown'); badge.classList.add('unknown'); badge.textContent = UI_TEXT.static.healthUnknown; } +function renderPreflightError(): void { + lastPreflightResult = null; + preflightFailed = true; + renderUnknownPreflightState(UI_TEXT.static.preflightError); +} + function renderPreflightResult(result: PreflightResult): void { lastPreflightResult = result; + preflightFailed = false; const entries: Array<[string, boolean, string]> = [ [UI_TEXT.static.preflightInternet, result.checks.internet, UI_TEXT.static.preflightNoInternet], [UI_TEXT.static.preflightStreamlink, result.checks.streamlink, UI_TEXT.static.preflightStreamlinkMissing], @@ -432,6 +449,8 @@ async function runPreflight(autoFix = false): Promise { try { const result = await window.api.runPreflight(autoFix); if (generation === preflightGeneration) renderPreflightResult(result); + } catch { + if (generation === preflightGeneration) renderPreflightError(); } finally { btn.disabled = false; renderPreflightButtonLabels(); @@ -662,17 +681,23 @@ async function importConfigFromFile(): Promise { invalidatePreflightResult(); // 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(); + const currentConfig = config; + const importedConfig = await window.api.getConfig(); + const language = typeof importedConfig.language === 'string' + ? importedConfig.language + : typeof currentConfig.language === 'string' + ? currentConfig.language + : currentLanguage; + const theme = typeof importedConfig.theme === 'string' + ? importedConfig.theme + : typeof currentConfig.theme === 'string' + ? currentConfig.theme + : byId('themeSelect').value || 'twitch'; + config = { ...currentConfig, ...importedConfig, language, theme }; if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig(); - if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) { - renderVodGridFromCurrentState(); - } + applyRendererTheme(theme); + applyRendererLanguage(language); } catch { /* ignore — next refresh will catch up */ } - refreshLocalizedPreflightUi(); if (toast) toast(UI_TEXT.static.configImported, 'info'); } else if (result.cancelled) { // User cancelled the dialog — no toast needed. @@ -1131,9 +1156,13 @@ function initSettingsAutoSave(): void { 'downloadChatReplayToggle', 'captureLiveChatToggle', 'logStreamEventsToggle', + 'autoResumeLiveRecordingToggle', + 'autoMergeResumedPartsToggle', + 'deletePartsAfterMergeToggle', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', + 'discordNotifyVodAutoQueuedToggle', 'autoCleanupEnabledToggle', 'autoCleanupTarget', 'autoCleanupAction', @@ -1147,6 +1176,8 @@ function initSettingsAutoSave(): void { 'partsFilenameTemplate', 'defaultClipFilenameTemplate', 'discordWebhookUrl', + 'autoVodPollMinutes', + 'autoVodMaxAgeHours', 'autoCleanupDays', 'downloadThrottleMiBps', 'downloadWindows' @@ -1276,15 +1307,19 @@ function syncWorkspaceThemePicker(theme: string): void { }); } -function selectWorkspaceTheme(theme: string): void { +function applyRendererTheme(theme: string): void { byId('themeSelect').value = theme; + document.body.className = `theme-${theme}`; + config.theme = theme; + syncWorkspaceThemePicker(theme); +} + +function selectWorkspaceTheme(theme: string): void { changeTheme(theme); } function changeTheme(theme: string): void { - document.body.className = `theme-${theme}`; - config.theme = theme; - syncWorkspaceThemePicker(theme); + applyRendererTheme(theme); void window.api.saveConfig({ theme }); } diff --git a/src/renderer-streamers.state-regressions.test.ts b/src/renderer-streamers.state-regressions.test.ts new file mode 100644 index 0000000..a806d74 --- /dev/null +++ b/src/renderer-streamers.state-regressions.test.ts @@ -0,0 +1,1193 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +const streamersSource = readFileSync(join(__dirname, 'renderer-streamers.ts'), 'utf8'); +const rendererSource = readFileSync(join(__dirname, 'renderer.ts'), 'utf8'); + +function loadVodLocale(file: string, variable: string): Record { + const source = readFileSync(join(__dirname, file), 'utf8'); + const context: Record = {}; + const compiled = transpileModule(`${source}\nglobalThis.locale = ${variable}.vods;`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } + }).outputText; + runInNewContext(compiled, context); + return context.locale as Record; +} + +function fragment(source: string, start: string, end: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + if (from < 0 || to < 0) throw new Error(`Missing production fragment: ${start}`); + return source.slice(from, to); +} + +function evaluate>(source: string, names: string, context: T): T & { exposed: Record unknown> } { + const compiled = transpileModule(`${source}\nglobalThis.exposed = { ${names} };`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } + }).outputText; + const globals = context as Record; + globals.window ??= context; + globals.vodBulkOperationInFlight ??= false; + globals.closeVodContextMenu ??= () => undefined; + globals.document ??= { getElementById: () => null }; + const vodTexts = ((globals.UI_TEXT as { vods?: Record } | undefined)?.vods); + if (vodTexts) { + vodTexts.bulkAddedToQueueOne ??= vodTexts.bulkAddedToQueue; + vodTexts.bulkAddDuplicateOne ??= vodTexts.bulkAddDuplicate; + vodTexts.bulkAddInvalidOne ??= vodTexts.bulkAddInvalid; + vodTexts.bulkAddFailedOne ??= vodTexts.bulkAddFailed; + vodTexts.bulkMarkedDownloadedOne ??= vodTexts.bulkMarkedDownloaded; + vodTexts.bulkUnmarkedDownloadedOne ??= vodTexts.bulkUnmarkedDownloaded; + } + globals.globalThis = context; + runInNewContext(compiled, context); + return context as T & { exposed: Record unknown> }; +} + +function deferred(): { promise: Promise; resolve(value: T): void; reject(error: unknown): void } { + let resolvePromise!: (value: T) => void; + let rejectPromise!: (error: unknown) => void; + return { + promise: new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }), + resolve: resolvePromise, + reject: rejectPromise + }; +} + +function createClassList(initial: string[] = []): { add(...tokens: string[]): void; remove(...tokens: string[]): void; contains(token: string): boolean; toggle(token: string, force?: boolean): boolean } { + const values = new Set(initial); + return { + add: (...tokens) => tokens.forEach((token) => values.add(token)), + remove: (...tokens) => tokens.forEach((token) => values.delete(token)), + contains: (token) => values.has(token), + toggle: (token, force) => { + const enabled = force ?? !values.has(token); + if (enabled) values.add(token); + else values.delete(token); + return enabled; + } + }; +} + +class FakeMenuElement { + readonly children: FakeMenuElement[] = []; + readonly style: Record = {}; + readonly listeners = new Map void>>(); + textContent = ''; + className = ''; + type = ''; + isConnected = true; + parentElement: FakeMenuElement | null = null; + + constructor(readonly tagName: string) { } + + appendChild(child: FakeMenuElement): FakeMenuElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + addEventListener(type: string, listener: () => void): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type: string): void { + for (const listener of this.listeners.get(type) ?? []) listener(); + } + + setAttribute(): void { } + + getBoundingClientRect(): { width: number; height: number } { + return { width: 160, height: 200 }; + } + + contains(node: FakeMenuElement): boolean { + return node === this || this.children.some((child) => child.contains(node)); + } + + remove(): void { + if (this.parentElement) { + const index = this.parentElement.children.indexOf(this); + if (index >= 0) this.parentElement.children.splice(index, 1); + } + this.parentElement = null; + this.isConnected = false; + } + + focus(): void { } +} + +function createDeletionRuntime(streamers: string[], currentStreamer: string): Record & { exposed: Record unknown> } { + const grid = {}; + const input = { value: 'filter' }; + const state = { + config: { streamers }, + currentStreamer, + lastLoadedVods: [{ id: 'vod-1', url: 'https://vod/1' }], + lastLoadedStreamer: currentStreamer, + selectedVodUrls: new Set(['https://vod/1']), + selectedVodUrlRevisions: new Map([['https://vod/1', 1]]), + vodSelectionRevision: 1, + pendingScrollRestore: { streamer: currentStreamer, y: 240 }, + vodScrollRestoreTimer: 91, + selectStreamerRequestId: 4, + vodRenderTaskId: 8, + streamerListFilterQuery: '', + UI_TEXT: { + static: { streamerBulkRemoveFiltered: 'Remove {count}', streamerBulkRemoveAll: 'Remove {count}' }, + tabs: { vods: 'VODs' }, + vods: { noneTitle: 'Choose a streamer', noneText: 'Select a streamer to see VODs.' } + }, + confirm: () => true, + byId: (id: string) => id === 'vodGrid' ? grid : input, + document: { getElementById: (id: string) => id === 'streamerListFilter' ? input : null }, + renderStreamers: vi.fn(), + setVodGridEmptyState: vi.fn(), + updateVodFilterCount: vi.fn(), + updateVodBulkBar: vi.fn(), + closeVodContextMenu: vi.fn(), + hideStreamerProfileHeader: vi.fn(), + clearVodHoverPreview: vi.fn(), + clearTimeout: vi.fn(), + setPageTitle: vi.fn(), + api: { + saveConfig: async (patch: Record) => ({ ...state.config, ...patch }) + } + }; + return evaluate( + fragment(streamersSource, 'function onStreamerListFilterChange', 'function normalizeStreamerCacheKey'), + 'bulkRemoveStreamers, removeStreamer', + state + ); +} + +describe('renderer streamer and VOD state regressions', () => { + it('provides grammatically singular bulk-result messages in both locales', () => { + const english = loadVodLocale('renderer-locale-en.ts', 'UI_TEXT_EN'); + const german = loadVodLocale('renderer-locale-de.ts', 'UI_TEXT_DE'); + + expect([ + english.bulkAddedToQueueOne, + english.bulkAddDuplicateOne, + english.bulkAddInvalidOne, + english.bulkAddFailedOne, + english.bulkMarkedDownloadedOne, + english.bulkUnmarkedDownloadedOne, + english.bulkMarkFailedOne + ]).toEqual([ + 'Added 1 VOD to the queue.', + 'This VOD is already in the queue.', + 'This VOD has invalid data and was skipped.', + 'This VOD could not be added and remains selected for retry.', + 'Marked 1 VOD as downloaded.', + 'Removed 1 VOD from the downloaded list.', + 'This VOD could not be updated and remains selected for retry.' + ]); + expect([ + german.bulkAddedToQueueOne, + german.bulkAddDuplicateOne, + german.bulkAddInvalidOne, + german.bulkAddFailedOne, + german.bulkMarkedDownloadedOne, + german.bulkUnmarkedDownloadedOne, + german.bulkMarkFailedOne + ]).toEqual([ + '1 VOD zur Warteschlange hinzugefügt.', + 'Dieses VOD ist bereits in der Warteschlange.', + 'Dieses VOD enthält ungültige Daten und wurde übersprungen.', + 'Dieses VOD konnte nicht hinzugefügt werden und bleibt für einen erneuten Versuch ausgewählt.', + '1 VOD als heruntergeladen markiert.', + 'Markierung von 1 VOD entfernt.', + 'Dieses VOD konnte nicht aktualisiert werden und bleibt für einen erneuten Versuch ausgewählt.' + ]); + }); + + it('clears the prior streamer selection synchronously before connection work starts', async () => { + const connection = deferred(); + const updateVodBulkBar = vi.fn(); + const closeVodContextMenu = vi.fn(); + const grid = { textContent: '', innerHTML: '
alpha
' }; + const context: Record = { + currentStreamer: 'alpha', + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'old' }], + selectedVodUrls: new Set(['https://www.twitch.tv/videos/1']), + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + vodSelectionRevision: 1, + selectStreamerRequestId: 0, + vodRenderTaskId: 7, + vodScrollPositions: {}, + pendingScrollRestore: null, + isConnected: false, + streamerVodCache: new Map(), + clearActiveVodHoverPreview: vi.fn(), + rememberCurrentVodScroll: vi.fn(), + cancelVodScrollRestore: vi.fn(), + closeVodContextMenu, + updateVodBulkBar, + renderStreamers: vi.fn(), + getStreamerDisplayName: (name: string) => name, + byId: () => grid, + connect: async () => { + await connection.promise; + context.isConnected = true; + }, + normalizeStreamerCacheKey: (name: string) => name, + loadStreamerVods: async () => ({ userId: '2', vods: [], updatedAt: 1 }), + renderVODs: vi.fn(), + updateStatus: vi.fn(), + UI_TEXT: { status: { noLogin: 'No login' }, vods: { notFound: 'Not found' } }, + api: {} + }; + const runtime = evaluate( + fragment(streamersSource, 'function renderVodGridLoadingState', 'function createVodEmptyStateIcon'), + 'selectStreamer', + context + ); + + const switching = runtime.exposed.selectStreamer('beta') as Promise; + + expect(Array.from(runtime.selectedVodUrls as Set)).toEqual([]); + expect(updateVodBulkBar).toHaveBeenCalledOnce(); + expect(runtime.lastLoadedStreamer).toBeNull(); + expect(runtime.lastLoadedVods).toEqual([]); + expect(runtime.vodRenderTaskId).toBe(8); + expect(closeVodContextMenu).toHaveBeenCalledOnce(); + expect(grid.innerHTML).toContain('vod-card-skeleton'); + + connection.resolve(); + await switching; + }); + + it('prevents a delayed old render chunk from appending after switching streamers', async () => { + const delayedChunks: Array<() => void> = []; + const inserted: string[] = []; + const grid = { + replaceChildren: vi.fn(), + insertAdjacentHTML: (_position: string, html: string) => inserted.push(html) + }; + const context: Record = { + currentStreamer: 'alpha', + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'one', url: 'https://www.twitch.tv/videos/1' }, { id: 'two', url: 'https://www.twitch.tv/videos/2' }], + selectedVodUrls: new Set(), + selectedVodUrlRevisions: new Map(), + selectStreamerRequestId: 0, + vodRenderTaskId: 0, + vodScrollPositions: {}, + pendingScrollRestore: null, + isConnected: false, + streamerVodCache: new Map(), + VOD_RENDER_CHUNK_SIZE: 1, + vodHideDownloaded: false, + vodFilterQuery: '', + vodSortKey: 'date_desc', + config: { downloaded_vod_ids: [] }, + document: { hidden: false, getElementById: () => grid }, + clearActiveVodHoverPreview: vi.fn(), + rememberCurrentVodScroll: vi.fn(), + cancelVodScrollRestore: vi.fn(), + updateVodBulkBar: vi.fn(), + renderStreamers: vi.fn(), + getStreamerDisplayName: (name: string) => name, + byId: () => grid, + connect: async () => undefined, + normalizeStreamerCacheKey: (name: string) => name, + loadStreamerVods: async () => null, + renderVODs: vi.fn(), + updateStatus: vi.fn(), + setVodGridEmptyState: vi.fn(), + updateVodFilterCount: vi.fn(), + sortVods: (vods: unknown[]) => vods, + filterVodsByQuery: (vods: unknown[]) => vods, + buildVodCardHtml: (vod: { id: string }, streamer: string) => `${streamer}:${vod.id}`, + requestAnimationFrame: vi.fn(), + setTimeout: (callback: () => void) => { delayedChunks.push(callback); return delayedChunks.length; }, + UI_TEXT: { status: { noLogin: 'No login' }, vods: { notFound: 'Not found', noResultsTitle: 'None', noResultsText: 'None' } }, + api: {} + }; + const runtime = evaluate( + `${fragment(streamersSource, 'function renderVodGridLoadingState', 'function createVodEmptyStateIcon')}\n${fragment(streamersSource, 'function renderVodGridFromCurrentState', 'async function refreshVODs')}`, + 'selectStreamer, renderVodGridFromCurrentState', + context + ); + + runtime.exposed.renderVodGridFromCurrentState(); + const switching = runtime.exposed.selectStreamer('beta') as Promise; + delayedChunks.forEach((callback) => callback()); + await switching; + + expect(inserted).toEqual(['alpha:one']); + expect(runtime.lastLoadedStreamer).toBeNull(); + expect(runtime.lastLoadedVods).toEqual([]); + }); + + it('prevents a delayed old bulk completion from rendering VOD state under the new streamer', async () => { + const request = deferred<{ queue: unknown[]; accepted: true; addedId: string }>(); + const renderVodGridFromCurrentState = vi.fn(); + const context: Record = { + currentStreamer: 'alpha', + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }], + selectedVodUrls: new Set(['https://www.twitch.tv/videos/1']), + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + vodSelectionRevision: 1, + selectStreamerRequestId: 0, + vodRenderTaskId: 0, + vodScrollPositions: {}, + pendingScrollRestore: null, + isConnected: false, + streamerVodCache: new Map(), + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add', innerHTML: '' }) }, + clearActiveVodHoverPreview: vi.fn(), + rememberCurrentVodScroll: vi.fn(), + cancelVodScrollRestore: vi.fn(), + updateVodBulkBar: vi.fn(), + renderStreamers: vi.fn(), + getStreamerDisplayName: (name: string) => name, + byId: () => ({ textContent: '', innerHTML: '' }), + connect: async () => undefined, + normalizeStreamerCacheKey: (name: string) => name, + loadStreamerVods: async () => null, + renderVODs: vi.fn(), + updateStatus: vi.fn(), + mergeQueueState: (next: unknown[]) => next, + renderQueue: vi.fn(), + renderVodGridFromCurrentState, + UI_TEXT: { status: { noLogin: 'No login' }, vods: { notFound: 'Not found', bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: 'duplicate', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: 'invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: 'failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { api: { addToQueueWithResult: () => request.promise }, showAppToast: vi.fn() } + }; + const runtime = evaluate( + `${fragment(streamersSource, 'function renderVodGridLoadingState', 'function createVodEmptyStateIcon')}\n${fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion')}`, + 'selectStreamer, bulkAddSelectedVodsToQueue', + context + ); + + const adding = runtime.exposed.bulkAddSelectedVodsToQueue() as Promise; + const switching = runtime.exposed.selectStreamer('beta') as Promise; + request.resolve({ queue: [{ id: 'added' }], accepted: true, addedId: 'added' }); + await Promise.all([adding, switching]); + + expect(renderVodGridFromCurrentState).not.toHaveBeenCalled(); + expect(runtime.lastLoadedStreamer).toBeNull(); + }); + + it.each([ + ['single', async (runtime: ReturnType) => runtime.exposed.removeStreamer('alpha')], + ['bulk', async (runtime: ReturnType) => runtime.exposed.bulkRemoveStreamers()] + ])('fully clears the active streamer after %s removal', async (_mode, remove) => { + const runtime = createDeletionRuntime(['alpha', 'beta'], 'alpha'); + + await remove(runtime); + + expect(runtime.currentStreamer).toBeNull(); + expect(runtime.lastLoadedStreamer).toBeNull(); + expect(runtime.lastLoadedVods).toEqual([]); + expect(Array.from(runtime.selectedVodUrls as Set)).toEqual([]); + expect(runtime.pendingScrollRestore).toBeNull(); + expect(runtime.vodScrollRestoreTimer).toBeNull(); + expect(runtime.clearTimeout).toHaveBeenCalledWith(91); + expect(runtime.selectStreamerRequestId).toBe(5); + expect(runtime.vodRenderTaskId).toBe(9); + expect(runtime.hideStreamerProfileHeader).toHaveBeenCalledOnce(); + expect(runtime.clearVodHoverPreview).toHaveBeenCalledOnce(); + expect(runtime.closeVodContextMenu).toHaveBeenCalledOnce(); + expect(runtime.updateVodBulkBar).toHaveBeenCalledOnce(); + expect(runtime.setVodGridEmptyState).toHaveBeenCalledWith(expect.anything(), 'Choose a streamer', 'Select a streamer to see VODs.'); + expect(runtime.setPageTitle).toHaveBeenCalledWith('VODs'); + }); + + it('hydrates profile display casing synchronously and preserves it when returning to the VOD tab', async () => { + let resolveNames: ((value: Record) => void) | undefined; + const elements = new Map; setAttribute(name: string, value: string): void; removeAttribute(name: string): void }>(); + const element = () => ({ classList: createClassList(), setAttribute: () => undefined, removeAttribute: () => undefined }); + elements.set('cutterTab', element()); + elements.set('vodsTab', element()); + const titles: string[] = []; + const context: Record = { + config: { streamers: ['nightbot'], streamer_display_names: { nightbot: 'NightBot' } }, + currentStreamer: 'nightbot', + renderStreamers: vi.fn(), + api: { getStreamerDisplayNames: () => new Promise>((resolve) => { resolveNames = resolve; }) }, + byId: (id: string) => elements.get(id) ?? element(), + queryAll: () => [], + query: () => element(), + deactivateCutterEditor: () => undefined, + activateCutterEditor: () => undefined, + syncTopNavActiveIndicator: () => undefined, + syncWorkspaceChrome: () => undefined, + scheduleSegmentedIndicatorsSync: () => undefined, + persistActiveTab: () => undefined, + setPageTitle: (title: string) => titles.push(title), + UI_TEXT: { appName: 'Twitch VOD Manager', tabs: { vods: 'VODs', settings: 'Settings' } } + }; + const displayRuntime = evaluate( + fragment(streamersSource, 'const liveStatusByLogin', 'async function initLiveStatusSubscription'), + 'hydrateStreamerDisplayNames, rememberStreamerDisplayName, getStreamerDisplayName', + context + ); + + const hydration = displayRuntime.exposed.hydrateStreamerDisplayNames() as Promise; + expect(displayRuntime.exposed.getStreamerDisplayName('nightbot')).toBe('NightBot'); + expect((displayRuntime.window as Record).getStreamerDisplayName).toBe(displayRuntime.exposed.getStreamerDisplayName); + expect(displayRuntime.renderStreamers).toHaveBeenCalledOnce(); + + const tabRuntime = evaluate( + fragment(rendererSource, 'function showTab', 'function parseDurationToSeconds'), + 'showTab', + displayRuntime + ); + tabRuntime.exposed.showTab('settings'); + tabRuntime.exposed.showTab('vods'); + expect(titles.at(-1)).toBe('NightBot'); + + resolveNames?.({ nightbot: 'NightBot' }); + await hydration; + }); + + it('renders a localized empty state when hide-downloaded removes every VOD', () => { + const emptyState = vi.fn(); + const grid = { replaceChildren: vi.fn(), insertAdjacentHTML: vi.fn() }; + const runtime = evaluate( + fragment(streamersSource, 'function renderVodGridFromCurrentState', 'async function refreshVODs'), + 'renderVodGridFromCurrentState', + { + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://vod/1' }], + vodRenderTaskId: 0, + VOD_RENDER_CHUNK_SIZE: 64, + vodHideDownloaded: true, + vodFilterQuery: '', + vodSortKey: 'date_desc', + config: { downloaded_vod_ids: ['vod-1'] }, + UI_TEXT: { vods: { hideDownloadedEmptyTitle: 'All VODs hidden', hideDownloadedEmptyText: 'Turn off the filter.' } }, + byId: () => grid, + sortVods: (vods: unknown[]) => vods, + filterVodsByQuery: (vods: unknown[]) => vods, + setVodGridEmptyState: emptyState, + updateVodFilterCount: vi.fn(), + buildVodCardHtml: () => '', + clearActiveVodHoverPreview: () => undefined, + document: { hidden: false }, + setTimeout: (callback: () => void) => { callback(); return 1; }, + requestAnimationFrame: (callback: () => void) => { callback(); return 1; } + } + ); + + runtime.exposed.renderVodGridFromCurrentState(); + + expect(emptyState).toHaveBeenCalledWith(grid, 'All VODs hidden', 'Turn off the filter.'); + expect(grid.replaceChildren).not.toHaveBeenCalled(); + }); + + it('stops an active hover preview before replacing cards during a state render', () => { + const order: string[] = []; + const grid = { + replaceChildren: () => order.push('render'), + insertAdjacentHTML: () => order.push('cards') + }; + const runtime = evaluate( + fragment(streamersSource, 'function renderVodGridFromCurrentState', 'async function refreshVODs'), + 'renderVodGridFromCurrentState', + { + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://vod/1' }], + vodRenderTaskId: 0, + VOD_RENDER_CHUNK_SIZE: 64, + vodHideDownloaded: false, + vodFilterQuery: '', + vodSortKey: 'date_desc', + config: { downloaded_vod_ids: [] }, + UI_TEXT: { vods: {} }, + byId: () => grid, + sortVods: (vods: unknown[]) => vods, + filterVodsByQuery: (vods: unknown[]) => vods, + setVodGridEmptyState: vi.fn(), + updateVodFilterCount: vi.fn(), + buildVodCardHtml: () => '
', + clearActiveVodHoverPreview: () => order.push('hover'), + document: { hidden: false }, + setTimeout: (callback: () => void) => { callback(); return 1; }, + requestAnimationFrame: (callback: () => void) => { callback(); return 1; } + } + ); + + runtime.exposed.renderVodGridFromCurrentState(); + + expect(order).toEqual(['hover', 'render', 'cards']); + }); + + it('marks the immutable VOD snapshot and preserves new selections while requests are in flight', async () => { + const firstRequest = deferred<{ success: boolean }>(); + const calls: Array<[string, boolean]> = []; + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1', 'https://www.twitch.tv/videos/2']); + const selectedVodUrlRevisions = new Map([ + ['https://www.twitch.tv/videos/1', 1], + ['https://www.twitch.tv/videos/2', 2] + ]); + const context: Record = { + selectedVodUrls, + selectedVodUrlRevisions, + lastLoadedStreamer: 'alpha', + lastLoadedVods: [ + { id: 'vod-1', url: 'https://www.twitch.tv/videos/1' }, + { id: 'vod-2', url: 'https://www.twitch.tv/videos/2' } + ], + config: {}, + UI_TEXT: { vods: { bulkMarkedDownloaded: 'Marked {count}', bulkUnmarkedDownloaded: 'Unmarked {count}' } }, + window: { + api: { + markVodDownloaded: async (id: string, mark: boolean) => { + calls.push([id, mark]); + if (calls.length === 1) return firstRequest.promise; + return { success: true }; + }, + getConfig: async () => ({ downloaded_vod_ids: ['vod-1', 'vod-2'] }) + }, + showAppToast: vi.fn() + }, + updateVodBulkBar: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + }; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'async function bulkAddSelectedVodsToQueue'), + 'bulkMarkSelectedDownloaded', + context + ); + + const marking = runtime.exposed.bulkMarkSelectedDownloaded(true) as Promise; + context.lastLoadedStreamer = 'beta'; + context.lastLoadedVods = [{ id: 'changed', url: 'https://www.twitch.tv/videos/2' }]; + selectedVodUrls.add('https://www.twitch.tv/videos/3'); + selectedVodUrlRevisions.set('https://www.twitch.tv/videos/3', 3); + firstRequest.resolve({ success: true }); + await marking; + + expect(calls).toEqual([['vod-1', true], ['vod-2', true]]); + expect(Array.from(selectedVodUrls)).toEqual(['https://www.twitch.tv/videos/3']); + }); + + it('keeps failed downloaded-mark selections available for retry', async () => { + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1', 'https://www.twitch.tv/videos/2']); + const toasts: Array<[string, string]> = []; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'async function bulkAddSelectedVodsToQueue'), + 'bulkMarkSelectedDownloaded', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([ + ['https://www.twitch.tv/videos/1', 1], + ['https://www.twitch.tv/videos/2', 2] + ]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [ + { id: 'vod-1', url: 'https://www.twitch.tv/videos/1' }, + { id: 'vod-2', url: 'https://www.twitch.tv/videos/2' } + ], + config: {}, + UI_TEXT: { vods: { bulkMarkedDownloaded: 'Marked {count}', bulkUnmarkedDownloaded: 'Unmarked {count}', bulkMarkFailed: '{count} failed', bulkMarkFailedOne: 'one failed', bulkMarkResult: '{updated}/{failed}' } }, + window: { + api: { + markVodDownloaded: async (id: string) => { + if (id === 'vod-2') throw new Error('persist failed'); + return { success: true }; + }, + getConfig: async () => ({ downloaded_vod_ids: ['vod-1'] }) + }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + updateVodBulkBar: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkMarkSelectedDownloaded(true); + + expect(Array.from(selectedVodUrls)).toEqual(['https://www.twitch.tv/videos/2']); + expect(toasts).toEqual([['1/1', 'warn']]); + }); + + it('reports false and missing-VOD mark results as failures without clearing their selections', async () => { + const selectedVodUrls = new Set([ + 'https://www.twitch.tv/videos/1', + 'https://www.twitch.tv/videos/missing' + ]); + const toasts: Array<[string, string]> = []; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'async function bulkAddSelectedVodsToQueue'), + 'bulkMarkSelectedDownloaded', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([ + ['https://www.twitch.tv/videos/1', 1], + ['https://www.twitch.tv/videos/missing', 2] + ]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1' }], + config: {}, + UI_TEXT: { vods: { bulkMarkedDownloaded: 'Marked {count}', bulkUnmarkedDownloaded: 'Unmarked {count}', bulkMarkFailed: '{count} failed', bulkMarkFailedOne: 'one failed', bulkMarkResult: '{updated}/{failed}' } }, + window: { + api: { + markVodDownloaded: async () => ({ success: false }), + getConfig: vi.fn() + }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + updateVodBulkBar: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkMarkSelectedDownloaded(true); + + expect(Array.from(selectedVodUrls)).toEqual([ + 'https://www.twitch.tv/videos/1', + 'https://www.twitch.tv/videos/missing' + ]); + expect(toasts).toEqual([['2 failed', 'warn']]); + }); + + it('allows only one VOD bulk operation at a time and locks every bulk action button synchronously', async () => { + const request = deferred<{ success: boolean }>(); + const buttons = new Map([ + ['vodBulkAddBtn', { disabled: false }], + ['vodBulkMarkBtn', { disabled: false }], + ['vodBulkUnmarkBtn', { disabled: false }] + ]); + const markVodDownloaded = vi.fn(() => request.promise); + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1']); + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'async function bulkAddSelectedVodsToQueue'), + 'bulkMarkSelectedDownloaded', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1' }], + vodBulkOperationInFlight: false, + config: {}, + document: { getElementById: (id: string) => buttons.get(id) ?? null }, + UI_TEXT: { vods: { bulkMarkedDownloaded: 'Marked {count}', bulkMarkedDownloadedOne: 'Marked one', bulkUnmarkedDownloaded: 'Unmarked {count}', bulkUnmarkedDownloadedOne: 'Unmarked one', bulkMarkFailed: '{count} failed', bulkMarkFailedOne: 'one failed', bulkMarkResult: '{updated}/{failed}' } }, + window: { + api: { markVodDownloaded, getConfig: async () => ({}) }, + showAppToast: vi.fn() + }, + updateVodBulkBar: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + const marking = runtime.exposed.bulkMarkSelectedDownloaded(true) as Promise; + const unmarking = runtime.exposed.bulkMarkSelectedDownloaded(false) as Promise; + + expect(markVodDownloaded).toHaveBeenCalledOnce(); + expect(Array.from(buttons.values()).map((button) => button.disabled)).toEqual([true, true, true]); + + request.resolve({ success: true }); + await Promise.all([marking, unmarking]); + + expect(Array.from(buttons.values()).map((button) => button.disabled)).toEqual([false, false, false]); + }); + + it('does not count a backend-rejected duplicate as a bulk queue success', async () => { + const toasts: Array<[string, string]> = []; + const button = { disabled: false, textContent: 'Add' }; + const existingQueue = [{ id: 'existing', url: 'https://www.twitch.tv/videos/1', streamer: 'alpha', date: '2026-08-13' }]; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls: new Set(['https://www.twitch.tv/videos/1']), + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }], + queue: existingQueue, + document: { getElementById: () => button }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { + api: { addToQueueWithResult: async () => ({ queue: existingQueue, accepted: false, reason: 'duplicate' }) }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(toasts).toEqual([['1 duplicates', 'warn']]); + expect(Array.from(runtime.selectedVodUrls as Set)).toEqual([]); + }); + + it('counts only a new matching queue identity and reports mixed bulk results', async () => { + const toasts: Array<[string, string]> = []; + const button = { disabled: false, textContent: 'Add' }; + const initial = [{ id: 'existing', url: 'https://old', streamer: 'alpha', date: '2026-08-10' }]; + const afterFirst = [...initial, { id: 'unrelated', url: 'https://other', streamer: 'beta', date: '2026-08-13' }]; + const afterSecond = [...afterFirst, { id: 'accepted', url: 'https://www.twitch.tv/videos/2', streamer: 'alpha', date: '2026-08-12' }]; + const responses = [ + { queue: afterFirst, accepted: false, reason: 'duplicate' }, + { queue: afterSecond, accepted: true, addedId: 'accepted' } + ]; + let activeCalls = 0; + let maximumActiveCalls = 0; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls: new Set(['https://www.twitch.tv/videos/1', 'https://www.twitch.tv/videos/2']), + selectedVodUrlRevisions: new Map([ + ['https://www.twitch.tv/videos/1', 1], + ['https://www.twitch.tv/videos/2', 2] + ]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [ + { id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }, + { id: 'vod-2', url: 'https://www.twitch.tv/videos/2', title: 'Two', created_at: '2026-08-12', duration: '2h' } + ], + queue: initial, + document: { getElementById: () => button }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { + api: { + addToQueueWithResult: async () => { + activeCalls += 1; + maximumActiveCalls = Math.max(maximumActiveCalls, activeCalls); + await new Promise((resolve) => setImmediate(resolve)); + activeCalls -= 1; + return responses.shift(); + } + }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(toasts).toEqual([['1/1/0/0', 'warn']]); + expect(maximumActiveCalls).toBe(1); + expect(Array.from(runtime.selectedVodUrls as Set)).toEqual([]); + }); + + it('uses immutable VOD and streamer snapshots across sequential bulk requests', async () => { + const firstRequest = deferred<{ queue: unknown[]; accepted: boolean; addedId?: string }>(); + const calls: Array> = []; + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1', 'https://www.twitch.tv/videos/2']); + const context: Record = { + selectedVodUrls, + selectedVodUrlRevisions: new Map([ + ['https://www.twitch.tv/videos/1', 1], + ['https://www.twitch.tv/videos/2', 2] + ]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [ + { id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'Original one', created_at: '2026-08-13', duration: '1h' }, + { id: 'vod-2', url: 'https://www.twitch.tv/videos/2', title: 'Original two', created_at: '2026-08-12', duration: '2h' } + ], + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { + api: { + addToQueueWithResult: async (payload: Record) => { + calls.push(payload); + if (calls.length === 1) return firstRequest.promise; + return { queue: [{ id: 'one' }, { id: 'two' }], accepted: true, addedId: 'two' }; + } + }, + showAppToast: vi.fn() + }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + }; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + context + ); + + const adding = runtime.exposed.bulkAddSelectedVodsToQueue() as Promise; + context.lastLoadedStreamer = 'beta'; + context.lastLoadedVods = [ + { id: 'vod-2', url: 'https://www.twitch.tv/videos/2', title: 'Mutated two', created_at: '2026-08-11', duration: '9h' } + ]; + firstRequest.resolve({ queue: [{ id: 'one' }], accepted: true, addedId: 'one' }); + await adding; + + expect(calls).toEqual([ + { url: 'https://www.twitch.tv/videos/1', title: 'Original one', date: '2026-08-13', streamer: 'alpha', duration_str: '1h' }, + { url: 'https://www.twitch.tv/videos/2', title: 'Original two', date: '2026-08-12', streamer: 'alpha', duration_str: '2h' } + ]); + }); + + it('preserves selections made while a bulk request is in flight', async () => { + const request = deferred<{ queue: unknown[]; accepted: boolean; addedId?: string }>(); + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1']); + const selectedVodUrlRevisions = new Map([['https://www.twitch.tv/videos/1', 1]]); + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls, + selectedVodUrlRevisions, + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }], + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { api: { addToQueueWithResult: () => request.promise }, showAppToast: vi.fn() }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + const adding = runtime.exposed.bulkAddSelectedVodsToQueue() as Promise; + selectedVodUrls.delete('https://www.twitch.tv/videos/1'); + selectedVodUrlRevisions.delete('https://www.twitch.tv/videos/1'); + selectedVodUrls.add('https://www.twitch.tv/videos/1'); + selectedVodUrlRevisions.set('https://www.twitch.tv/videos/1', 2); + selectedVodUrls.add('https://www.twitch.tv/videos/2'); + selectedVodUrlRevisions.set('https://www.twitch.tv/videos/2', 3); + request.resolve({ queue: [{ id: 'one' }], accepted: true, addedId: 'one' }); + await adding; + + expect(Array.from(selectedVodUrls)).toEqual([ + 'https://www.twitch.tv/videos/1', + 'https://www.twitch.tv/videos/2' + ]); + }); + + it('does not let a delayed bulk response replace newer queue-event membership or terminal state', async () => { + const request = deferred<{ queue: unknown[]; accepted: true; addedId: string }>(); + const before = [{ id: 'active', url: 'https://old', streamer: 'alpha', date: '2026-08-10', status: 'downloading', progress: 70 }]; + const newer = [ + { id: 'active', url: 'https://old', streamer: 'alpha', date: '2026-08-10', status: 'completed', progress: 100 }, + { id: 'event-only', url: 'https://event', streamer: 'beta', date: '2026-08-13', status: 'pending', progress: 0 } + ]; + const stale = [ + { id: 'active', url: 'https://old', streamer: 'alpha', date: '2026-08-10', status: 'downloading', progress: 70 }, + { id: 'added', url: 'https://www.twitch.tv/videos/1', streamer: 'alpha', date: '2026-08-13', status: 'pending', progress: 0 } + ]; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls: new Set(['https://www.twitch.tv/videos/1']), + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }], + queue: before, + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { api: { addToQueueWithResult: () => request.promise }, showAppToast: vi.fn() }, + mergeQueueState: vi.fn((next: unknown[]) => next), + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + const adding = runtime.exposed.bulkAddSelectedVodsToQueue() as Promise; + runtime.queue = newer; + request.resolve({ queue: stale, accepted: true, addedId: 'added' }); + await adding; + + expect(runtime.queue).toBe(newer); + expect(runtime.mergeQueueState).not.toHaveBeenCalled(); + }); + + it('keeps failed selections for retry and reports failures separately from duplicates', async () => { + const toasts: Array<[string, string]> = []; + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1']); + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }], + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { + api: { addToQueueWithResult: async () => { throw new Error('persist failed'); } }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(Array.from(selectedVodUrls)).toEqual(['https://www.twitch.tv/videos/1']); + expect(toasts).toEqual([['1 failed', 'warn']]); + }); + + it('keeps persistence-rejected selections for retry and reports them as failures', async () => { + const toasts: Array<[string, string]> = []; + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1']); + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }], + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { + api: { addToQueueWithResult: async () => ({ queue: [], accepted: false, reason: 'persistence-failed' }) }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(Array.from(selectedVodUrls)).toEqual(['https://www.twitch.tv/videos/1']); + expect(toasts).toEqual([['1 failed', 'warn']]); + }); + + it.each(['shutting-down', 'access-denied'] as const)('stops the batch after %s without replacing the visible queue and keeps unattempted selections for retry', async (reason) => { + const calls: string[] = []; + const toasts: Array<[string, string]> = []; + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1', 'https://www.twitch.tv/videos/2']); + const visibleQueue = [{ id: 'existing', url: 'https://existing', status: 'downloading', progress: 42 }]; + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([ + ['https://www.twitch.tv/videos/1', 1], + ['https://www.twitch.tv/videos/2', 2] + ]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [ + { id: 'vod-1', url: 'https://www.twitch.tv/videos/1', title: 'One', created_at: '2026-08-13', duration: '1h' }, + { id: 'vod-2', url: 'https://www.twitch.tv/videos/2', title: 'Two', created_at: '2026-08-12', duration: '2h' } + ], + queue: visibleQueue, + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: 'duplicate', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: 'invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: 'failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { + api: { + addToQueueWithResult: async (vod: { url: string }) => { + calls.push(vod.url); + return { queue: [], accepted: false, reason }; + } + }, + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(calls).toEqual(['https://www.twitch.tv/videos/1']); + expect(Array.from(selectedVodUrls)).toEqual([ + 'https://www.twitch.tv/videos/1', + 'https://www.twitch.tv/videos/2' + ]); + expect(runtime.queue).toBe(visibleQueue); + expect(toasts).toEqual([['2 failed', 'warn']]); + }); + + it('removes stale selections without VOD data and reports them as invalid', async () => { + const toasts: Array<[string, string]> = []; + const selectedVodUrls = new Set(['https://www.twitch.tv/videos/1']); + const addToQueueWithResult = vi.fn(); + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([['https://www.twitch.tv/videos/1', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [], + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { api: { addToQueueWithResult }, showAppToast: (message: string, kind: string) => toasts.push([message, kind]) }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(addToQueueWithResult).not.toHaveBeenCalled(); + expect(Array.from(selectedVodUrls)).toEqual([]); + expect(toasts).toEqual([['1 invalid', 'warn']]); + }); + + it('reports backend-rejected invalid VOD snapshots separately', async () => { + const toasts: Array<[string, string]> = []; + const addToQueueWithResult = vi.fn(async () => ({ queue: [], accepted: false, reason: 'invalid' })); + const selectedVodUrls = new Set(['https://invalid.example/vod']); + const runtime = evaluate( + fragment(streamersSource, 'function removeVodSelectionIfUnchanged', 'interface VodGridMotion'), + 'bulkAddSelectedVodsToQueue', + { + selectedVodUrls, + selectedVodUrlRevisions: new Map([['https://invalid.example/vod', 1]]), + lastLoadedStreamer: 'alpha', + lastLoadedVods: [{ id: 'vod-1', url: 'https://invalid.example/vod', title: 'One', created_at: '2026-08-13', duration: '1h' }], + queue: [], + document: { getElementById: () => ({ disabled: false, textContent: 'Add' }) }, + UI_TEXT: { vods: { bulkAdding: 'Adding', bulkAddedToQueue: 'Added {count}', bulkAddDuplicate: '{count} duplicates', bulkAddDuplicateOne: '1 duplicates', bulkAddInvalid: '{count} invalid', bulkAddInvalidOne: '1 invalid', bulkAddFailed: '{count} failed', bulkAddFailedOne: '1 failed', bulkAddResult: '{added}/{duplicates}/{invalid}/{failed}' } }, + window: { api: { addToQueueWithResult }, showAppToast: (message: string, kind: string) => toasts.push([message, kind]) }, + mergeQueueState: (next: unknown[]) => next, + updateVodBulkBar: vi.fn(), + renderQueue: vi.fn(), + renderVodGridFromCurrentState: vi.fn() + } + ); + + await runtime.exposed.bulkAddSelectedVodsToQueue(); + + expect(addToQueueWithResult).toHaveBeenCalledOnce(); + expect(Array.from(selectedVodUrls)).toEqual([]); + expect(toasts).toEqual([['1 invalid', 'warn']]); + }); + + it('reports VOD clipboard success only after fulfillment and rejection as a warning', async () => { + let resolveWrite: (() => void) | undefined; + const toasts: Array<[string, string]> = []; + const body = new FakeMenuElement('body'); + const clipboard = { + writeText: vi.fn(() => new Promise((resolve) => { resolveWrite = resolve; })) + }; + const context: Record = { + config: { downloaded_vod_ids: [] }, + document: { + body, + createElement: (tagName: string) => new FakeMenuElement(tagName), + addEventListener: () => undefined, + removeEventListener: () => undefined + }, + Node: FakeMenuElement, + HTMLElement: FakeMenuElement, + navigator: { clipboard }, + innerWidth: 1280, + innerHeight: 720, + api: { openExternal: () => Promise.resolve() }, + UI_TEXT: { + vods: { + ctxOpenOnTwitch: 'Open', + ctxCopyUrl: 'Copy', + ctxCopiedUrl: 'Copied', + ctxCopyFailed: 'Copy failed', + trimButton: 'Trim', + addQueue: 'Queue', + ctxUnmarkDownloaded: 'Unmark', + ctxMarkDownloaded: 'Mark' + } + }, + RendererAccessibility: { installMenuKeyboardNavigation: () => undefined, focusFirstMenuItem: () => undefined }, + openClipDialog: () => undefined, + addToQueue: () => Promise.resolve(), + toggleVodDownloadedMark: () => Promise.resolve(), + showAppToast: (message: string, kind: string) => toasts.push([message, kind]) + }; + const runtime = evaluate( + fragment(streamersSource, 'let activeVodContextMenu', 'async function toggleVodDownloadedMark'), + 'showVodContextMenu', + context + ); + const vod = { id: 'vod-1', url: 'https://vod/1', title: 'One', date: '2026-08-13', streamer: 'alpha', duration: '1h' }; + + runtime.exposed.showVodContextMenu(10, 10, vod, null); + body.children[0].children[1].dispatch('click'); + expect(toasts).toEqual([]); + resolveWrite?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(toasts).toEqual([['Copied', 'info']]); + + clipboard.writeText = vi.fn(async () => { throw new Error('denied'); }); + runtime.exposed.showVodContextMenu(10, 10, vod, null); + body.children[0].children[1].dispatch('click'); + await new Promise((resolve) => setImmediate(resolve)); + expect(toasts.at(-1)).toEqual(['Copy failed', 'warn']); + }); + + it('removes VOD context-menu document listeners when closed outside their local callback', () => { + const added: Array<[string, (...args: unknown[]) => void, boolean]> = []; + const removed: Array<[string, (...args: unknown[]) => void, boolean]> = []; + const body = new FakeMenuElement('body'); + const context: Record = { + config: { downloaded_vod_ids: [] }, + document: { + body, + createElement: (tagName: string) => new FakeMenuElement(tagName), + addEventListener: (type: string, listener: (...args: unknown[]) => void, capture: boolean) => added.push([type, listener, capture]), + removeEventListener: (type: string, listener: (...args: unknown[]) => void, capture: boolean) => removed.push([type, listener, capture]) + }, + Node: FakeMenuElement, + HTMLElement: FakeMenuElement, + navigator: { clipboard: { writeText: () => Promise.resolve() } }, + innerWidth: 1280, + innerHeight: 720, + api: { openExternal: () => Promise.resolve() }, + UI_TEXT: { + vods: { + ctxOpenOnTwitch: 'Open', ctxCopyUrl: 'Copy', ctxCopiedUrl: 'Copied', ctxCopyFailed: 'Failed', + trimButton: 'Trim', addQueue: 'Queue', ctxUnmarkDownloaded: 'Unmark', ctxMarkDownloaded: 'Mark' + } + }, + RendererAccessibility: { installMenuKeyboardNavigation: () => undefined, focusFirstMenuItem: () => undefined }, + openClipDialog: () => undefined, + addToQueue: () => Promise.resolve(), + toggleVodDownloadedMark: () => Promise.resolve() + }; + const runtime = evaluate( + fragment(streamersSource, 'let activeVodContextMenu', 'async function toggleVodDownloadedMark'), + 'showVodContextMenu, closeVodContextMenu', + context + ); + + runtime.exposed.showVodContextMenu(10, 10, { id: 'vod-1', url: 'https://vod/1', title: 'One', date: '2026-08-13', streamer: 'alpha', duration: '1h' }, null); + runtime.exposed.closeVodContextMenu(); + + expect(added.map(([type, _listener, capture]) => [type, capture])).toEqual([['mousedown', true], ['scroll', true]]); + expect(removed.map(([type, _listener, capture]) => [type, capture])).toEqual([['mousedown', true], ['scroll', true]]); + expect(removed[0][1]).toBe(added[0][1]); + expect(removed[1][1]).toBe(added[1][1]); + }); +}); diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 131aafd..59e991c 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -29,6 +29,12 @@ function scheduleStreamerActiveIndicatorSync(): void { const liveStatusByLogin = new Map(); const streamerDisplayNames = new Map(); +function getStreamerDisplayName(login: string): string { + return streamerDisplayNames.get(login.trim().toLowerCase()) || login; +} + +(window as unknown as { getStreamerDisplayName: typeof getStreamerDisplayName }).getStreamerDisplayName = getStreamerDisplayName; + function rememberStreamerDisplayName(login: string, displayName: string): void { const normalizedLogin = login.trim().toLowerCase(); const normalizedDisplayName = displayName.trim(); @@ -44,6 +50,14 @@ function rememberStreamerDisplayName(login: string, displayName: string): void { (window as unknown as { rememberStreamerDisplayName: typeof rememberStreamerDisplayName }).rememberStreamerDisplayName = rememberStreamerDisplayName; +function renderHydratedStreamerDisplayNames(): void { + if (currentStreamer) { + const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; + if (typeof setTitle === 'function') setTitle(getStreamerDisplayName(currentStreamer)); + } + renderStreamers(); +} + async function hydrateStreamerDisplayNames(): Promise { const configuredNames = config.streamer_display_names || {}; let changed = false; @@ -55,12 +69,13 @@ async function hydrateStreamerDisplayNames(): Promise { changed = true; } } + if (changed) { + renderHydratedStreamerDisplayNames(); + changed = false; + } const streamers = (config.streamers ?? []) as string[]; - if (streamers.length === 0) { - if (changed) renderStreamers(); - return; - } + if (streamers.length === 0) return; try { const resolvedNames = await window.api.getStreamerDisplayNames(streamers); @@ -74,14 +89,7 @@ async function hydrateStreamerDisplayNames(): Promise { } } catch { } - if (changed) { - if (currentStreamer) { - const displayName = streamerDisplayNames.get(currentStreamer.toLowerCase()); - const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; - if (displayName && typeof setTitle === 'function') setTitle(displayName); - } - renderStreamers(); - } + if (changed) renderHydratedStreamerDisplayNames(); } (window as unknown as { hydrateStreamerDisplayNames: typeof hydrateStreamerDisplayNames }).hydrateStreamerDisplayNames = hydrateStreamerDisplayNames; @@ -131,6 +139,9 @@ const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; // on streamer switch (selection is per-streamer mental model). NOT persisted // because a stale selection across reloads is more confusing than helpful. const selectedVodUrls = new Set(); +const selectedVodUrlRevisions = new Map(); +let vodSelectionRevision = 0; +let vodBulkOperationInFlight = false; let vodGridDelegationInitialized = false; // Hide-downloaded toggle: when enabled, the VOD grid skips entries whose @@ -397,6 +408,7 @@ let streamerListFilterQuery = ''; const VOD_SCROLL_POSITIONS_KEY = 'twitch-vod-manager:vod-scroll-positions'; let vodScrollPositions: Record = {}; let pendingScrollRestore: { streamer: string; y: number } | null = null; +let vodScrollRestoreTimer: number | null = null; function loadVodScrollPositions(): void { try { @@ -523,7 +535,7 @@ function showStreamerContextMenu(event: MouseEvent, streamer: string): void { const menu = document.createElement('div'); menu.className = 'streamer-context-menu'; menu.setAttribute('role', 'menu'); - menu.setAttribute('aria-label', streamerDisplayNames.get(streamer.toLowerCase()) || streamer); + menu.setAttribute('aria-label', getStreamerDisplayName(streamer)); const appendAction = (action: 'auto' | 'vod' | 'record', label: string, active: boolean, handler: () => void): void => { const button = document.createElement('button'); @@ -643,7 +655,7 @@ function renderStreamers(): void { const nameSpan = document.createElement('span'); nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : ''); - nameSpan.textContent = streamerDisplayNames.get(streamer.toLowerCase()) || streamer; + nameSpan.textContent = getStreamerDisplayName(streamer); const removeSpan = document.createElement('span'); removeSpan.className = 'remove'; removeSpan.textContent = 'x'; @@ -710,6 +722,38 @@ function onStreamerListFilterChange(): void { renderStreamers(); } +function clearActiveVodHoverPreview(): void { + const clear = (window as unknown as { clearVodHoverPreview?: () => void }).clearVodHoverPreview; + if (typeof clear === 'function') clear(); +} + +function cancelVodScrollRestore(): void { + pendingScrollRestore = null; + if (vodScrollRestoreTimer === null) return; + window.clearTimeout(vodScrollRestoreTimer); + vodScrollRestoreTimer = null; +} + +function clearActiveStreamerSelection(): void { + selectStreamerRequestId += 1; + vodRenderTaskId += 1; + currentStreamer = null; + lastLoadedVods = []; + lastLoadedStreamer = null; + cancelVodScrollRestore(); + selectedVodUrls.clear(); + selectedVodUrlRevisions.clear(); + clearActiveVodHoverPreview(); + closeVodContextMenu(); + const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; + if (typeof hide === 'function') hide(); + updateVodBulkBar(); + updateVodFilterCount(0, 0); + setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText); + const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; + if (typeof setTitle === 'function') setTitle(UI_TEXT.tabs.vods); +} + async function bulkRemoveStreamers(): Promise { const all = (config.streamers ?? []) as string[]; if (all.length === 0) return; @@ -726,9 +770,7 @@ async function bulkRemoveStreamers(): Promise { config.streamers = remaining; config = await window.api.saveConfig({ streamers: remaining }); if (currentStreamer && targets.includes(currentStreamer)) { - currentStreamer = null; - const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; - if (typeof hide === 'function') hide(); + clearActiveStreamerSelection(); } streamerListFilterQuery = ''; const input = document.getElementById('streamerListFilter') as HTMLInputElement | null; @@ -821,16 +863,8 @@ async function addStreamer(): Promise { async function removeStreamer(name: string): Promise { config.streamers = (config.streamers ?? []).filter((s: string) => s !== name); config = await window.api.saveConfig({ streamers: config.streamers }); + if (currentStreamer === name) clearActiveStreamerSelection(); renderStreamers(); - - if (currentStreamer !== name) { - return; - } - - currentStreamer = null; - const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader; - if (typeof hide === 'function') hide(); - setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText); } function normalizeStreamerCacheKey(name: string): string { @@ -904,13 +938,40 @@ function startStreamerBackgroundRefresh(): void { }, STREAMER_BACKGROUND_REFRESH_MS); } +function renderVodGridLoadingState(): void { + byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => ` +
+
+
+
+
+
+
+
+ `).join(''); +} + async function selectStreamer(name: string, forceRefresh = false): Promise { + clearActiveVodHoverPreview(); // Save where we were on the OLD streamer before navigating away. rememberCurrentVodScroll(); + cancelVodScrollRestore(); const requestId = ++selectStreamerRequestId; const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name; + if (currentStreamer !== name) { + vodRenderTaskId += 1; + lastLoadedStreamer = null; + lastLoadedVods = []; + closeVodContextMenu(); + renderVodGridLoadingState(); + if (selectedVodUrls.size > 0) { + selectedVodUrls.clear(); + selectedVodUrlRevisions.clear(); + updateVodBulkBar(); + } + } currentStreamer = name; // Schedule a scroll-restore once the VOD grid renders. The actual // restore runs after renderVODs replaces the grid. @@ -918,7 +979,7 @@ async function selectStreamer(name: string, forceRefresh = false): Promise pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null; renderStreamers(); const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle; - const displayName = streamerDisplayNames.get(name.toLowerCase()) || name; + const displayName = getStreamerDisplayName(name); if (typeof setTitle === 'function') setTitle(displayName); else byId('pageTitle').textContent = displayName; @@ -946,16 +1007,7 @@ async function selectStreamer(name: string, forceRefresh = false): Promise if (cached) { renderVODs(cached.vods, name); } else { - byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => ` -
-
-
-
-
-
-
-
- `).join(''); + renderVodGridLoadingState(); } const loaded = await loadStreamerVods(name, forceRefresh); @@ -1003,6 +1055,7 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateCha // Clear bulk-selection on streamer switch — selection is per-streamer if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) { selectedVodUrls.clear(); + selectedVodUrlRevisions.clear(); updateVodBulkBar(); } const motion = animateChanges ? captureVodGridMotion() : undefined; @@ -1016,7 +1069,9 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateCha if (pendingScrollRestore && pendingScrollRestore.streamer === streamer) { const target = pendingScrollRestore; pendingScrollRestore = null; - window.setTimeout(() => { + vodScrollRestoreTimer = window.setTimeout(() => { + vodScrollRestoreTimer = null; + if (lastLoadedStreamer !== target.streamer) return; const grid = document.getElementById('vodGrid'); if (!grid) return; const scrollable = (grid.closest('.content') as HTMLElement | null) || grid; @@ -1095,8 +1150,16 @@ function setVodCardSelection(card: HTMLElement, selected: boolean): void { if (!checkbox || !url) return; checkbox.checked = selected; card.classList.toggle('selected', selected); - if (selected) selectedVodUrls.add(url); - else selectedVodUrls.delete(url); + if (selected !== selectedVodUrls.has(url)) { + vodSelectionRevision += 1; + if (selected) { + selectedVodUrls.add(url); + selectedVodUrlRevisions.set(url, vodSelectionRevision); + } else { + selectedVodUrls.delete(url); + selectedVodUrlRevisions.delete(url); + } + } updateVodBulkBar(); } @@ -1108,8 +1171,12 @@ function toggleVodCardSelection(card: HTMLElement): void { let activeVodContextMenu: HTMLElement | null = null; let activeVodContextMenuInvoker: HTMLElement | null = null; +let activeVodContextMenuCleanup: (() => void) | null = null; function closeVodContextMenu(restoreFocus = false): void { + const cleanup = activeVodContextMenuCleanup; + activeVodContextMenuCleanup = null; + cleanup?.(); if (!activeVodContextMenu) return; activeVodContextMenu.remove(); activeVodContextMenu = null; @@ -1118,6 +1185,16 @@ function closeVodContextMenu(restoreFocus = false): void { if (restoreFocus && invoker?.isConnected) invoker.focus(); } +async function copyVodUrl(url: string): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + try { + await navigator.clipboard.writeText(url); + if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info'); + } catch { + if (toast) toast(UI_TEXT.vods.ctxCopyFailed, 'warn'); + } +} + function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: HTMLElement | null): void { closeVodContextMenu(); @@ -1132,7 +1209,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: ); const isMarkedDownloaded = downloadedIds.has(ctx.id); - let cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus); + const cleanup = (restoreFocus = false): void => closeVodContextMenu(restoreFocus); const makeItem = (label: string, onClick: () => void): HTMLElement => { const el = document.createElement('button'); el.type = 'button'; @@ -1149,11 +1226,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: void window.api.openExternal(ctx.url); })); menu.appendChild(makeItem(UI_TEXT.vods.ctxCopyUrl, () => { - try { - void navigator.clipboard.writeText(ctx.url); - const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; - if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info'); - } catch { /* ignore */ } + void copyVodUrl(ctx.url); })); menu.appendChild(makeItem(UI_TEXT.vods.trimButton, () => { openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration); @@ -1185,8 +1258,7 @@ function showVodContextMenu(x: number, y: number, ctx: VodCardContext, invoker: cleanup(); }; const dismissOnScroll = () => cleanup(); - cleanup = (restoreFocus = false): void => { - closeVodContextMenu(restoreFocus); + activeVodContextMenuCleanup = () => { document.removeEventListener('mousedown', dismissOnClick, true); document.removeEventListener('scroll', dismissOnScroll, true); }; @@ -1219,10 +1291,36 @@ function updateVodBulkBar(): void { function clearVodSelection(): void { if (selectedVodUrls.size === 0) return; selectedVodUrls.clear(); + selectedVodUrlRevisions.clear(); updateVodBulkBar(); if (lastLoadedStreamer) renderVodGridFromCurrentState(); } +function removeVodSelectionIfUnchanged(url: string, revision: number | undefined): void { + if (!selectedVodUrls.has(url) || selectedVodUrlRevisions.get(url) !== revision) return; + selectedVodUrls.delete(url); + selectedVodUrlRevisions.delete(url); +} + +function setVodBulkActionsDisabled(disabled: boolean): void { + for (const id of ['vodBulkAddBtn', 'vodBulkMarkBtn', 'vodBulkUnmarkBtn']) { + const button = document.getElementById(id) as HTMLButtonElement | null; + if (button) button.disabled = disabled; + } +} + +function beginVodBulkOperation(): boolean { + if (vodBulkOperationInFlight) return false; + vodBulkOperationInFlight = true; + setVodBulkActionsDisabled(true); + return true; +} + +function endVodBulkOperation(): void { + vodBulkOperationInFlight = false; + setVodBulkActionsDisabled(false); +} + async function toggleAutoRecord(streamer: string): Promise { const current = ((config.auto_record_streamers as string[]) || []).slice(); const idx = current.indexOf(streamer); @@ -1283,76 +1381,137 @@ async function triggerLiveRecording(streamer: string): Promise { async function bulkMarkSelectedDownloaded(mark: boolean): Promise { const urls = Array.from(selectedVodUrls); if (urls.length === 0) return; + if (!beginVodBulkOperation()) return; + const vods = new Map(lastLoadedVods.map((vod) => [vod.url, { id: vod.id }])); + const selectionRevisions = new Map(urls.map((url) => [url, selectedVodUrlRevisions.get(url)])); - let updated = 0; - for (const url of urls) { - const vod = lastLoadedVods.find((v) => v.url === url); - if (!vod || !vod.id) continue; - try { - const result = await window.api.markVodDownloaded(vod.id, mark); - if (result?.success) updated++; - } catch { /* keep going */ } - } + try { + let updated = 0; + let failed = 0; + for (const url of urls) { + const vod = vods.get(url); + if (!vod || !vod.id) { + failed++; + continue; + } + try { + const result = await window.api.markVodDownloaded(vod.id, mark); + if (result?.success) { + updated++; + removeVodSelectionIfUnchanged(url, selectionRevisions.get(url)); + } else { + failed++; + } + } catch { + failed++; + } + } - if (updated === 0) return; + updateVodBulkBar(); + if (updated > 0) { + try { config = await window.api.getConfig(); } catch { /* ignore */ } + if (lastLoadedStreamer) renderVodGridFromCurrentState(); + } - try { config = await window.api.getConfig(); } catch { /* ignore */ } - selectedVodUrls.clear(); - updateVodBulkBar(); - if (lastLoadedStreamer) renderVodGridFromCurrentState(); - - const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; - if (toast) { - const template = mark ? UI_TEXT.vods.bulkMarkedDownloaded : UI_TEXT.vods.bulkUnmarkedDownloaded; - toast(template.replace('{count}', String(updated)), 'info'); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast && updated > 0 && failed === 0) { + const template = updated === 1 + ? (mark ? UI_TEXT.vods.bulkMarkedDownloadedOne : UI_TEXT.vods.bulkUnmarkedDownloadedOne) + : (mark ? UI_TEXT.vods.bulkMarkedDownloaded : UI_TEXT.vods.bulkUnmarkedDownloaded); + toast(template.replace('{count}', String(updated)), 'info'); + } else if (toast && updated === 0 && failed > 0) { + const template = failed === 1 ? UI_TEXT.vods.bulkMarkFailedOne : UI_TEXT.vods.bulkMarkFailed; + toast(template.replace('{count}', String(failed)), 'warn'); + } else if (toast && updated > 0 && failed > 0) { + toast(UI_TEXT.vods.bulkMarkResult + .replace('{updated}', String(updated)) + .replace('{failed}', String(failed)), 'warn'); + } + } finally { + endVodBulkOperation(); } } async function bulkAddSelectedVodsToQueue(): Promise { const urls = Array.from(selectedVodUrls); if (urls.length === 0 || !lastLoadedStreamer) return; + if (!beginVodBulkOperation()) return; const streamer = lastLoadedStreamer; + const vods = new Map(lastLoadedVods.map((vod) => [vod.url, { + url: vod.url, + title: vod.title, + date: vod.created_at, + streamer, + duration_str: vod.duration + }])); + const selectionRevisions = new Map(urls.map((url) => [url, selectedVodUrlRevisions.get(url)])); const btn = document.getElementById('vodBulkAddBtn') as HTMLButtonElement | null; const originalText = btn?.textContent || ''; - if (btn) { - btn.disabled = true; - btn.textContent = UI_TEXT.vods.bulkAdding; - } + if (btn) btn.textContent = UI_TEXT.vods.bulkAdding; - let added = 0; - let skipped = 0; - for (const url of urls) { - const vod = lastLoadedVods.find((v) => v.url === url); - if (!vod) { skipped++; continue; } - try { - queue = await window.api.addToQueue({ - url: vod.url, - title: vod.title, - date: vod.created_at, - streamer, - duration_str: vod.duration - }); - added++; - } catch { - skipped++; + try { + let added = 0; + let duplicates = 0; + let invalid = 0; + let failed = 0; + for (const [index, url] of urls.entries()) { + const vod = vods.get(url); + if (!vod) { + invalid++; + removeVodSelectionIfUnchanged(url, selectionRevisions.get(url)); + continue; + } + try { + const result = await window.api.addToQueueWithResult(vod); + if (result.accepted) { + added++; + } else if (result.reason === 'duplicate') { + duplicates++; + } else if (result.reason === 'invalid') { + invalid++; + } else { + failed++; + if (result.reason === 'shutting-down' || result.reason === 'access-denied') { + failed += urls.length - index - 1; + break; + } + continue; + } + removeVodSelectionIfUnchanged(url, selectionRevisions.get(url)); + } catch { + failed++; + } } - } - selectedVodUrls.clear(); - if (btn) { - btn.disabled = false; - btn.textContent = originalText; - } - updateVodBulkBar(); - renderQueue(); - renderVodGridFromCurrentState(); + updateVodBulkBar(); + renderQueue(); + if (lastLoadedStreamer === streamer) renderVodGridFromCurrentState(); - const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; - if (toast && added > 0) { - toast(UI_TEXT.vods.bulkAddedToQueue.replace('{count}', String(added)), 'info'); - } else if (toast && skipped > 0) { - toast(UI_TEXT.vods.bulkAddSkipped, 'warn'); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const categoryCount = Number(added > 0) + Number(duplicates > 0) + Number(invalid > 0) + Number(failed > 0); + if (toast && categoryCount === 1 && added > 0) { + const template = added === 1 ? UI_TEXT.vods.bulkAddedToQueueOne : UI_TEXT.vods.bulkAddedToQueue; + toast(template.replace('{count}', String(added)), 'info'); + } else if (toast && categoryCount === 1 && duplicates > 0) { + const template = duplicates === 1 ? UI_TEXT.vods.bulkAddDuplicateOne : UI_TEXT.vods.bulkAddDuplicate; + toast(template.replace('{count}', String(duplicates)), 'warn'); + } else if (toast && categoryCount === 1 && invalid > 0) { + const template = invalid === 1 ? UI_TEXT.vods.bulkAddInvalidOne : UI_TEXT.vods.bulkAddInvalid; + toast(template.replace('{count}', String(invalid)), 'warn'); + } else if (toast && categoryCount === 1 && failed > 0) { + const template = failed === 1 ? UI_TEXT.vods.bulkAddFailedOne : UI_TEXT.vods.bulkAddFailed; + toast(template.replace('{count}', String(failed)), 'warn'); + } else if (toast && categoryCount > 1) { + toast(UI_TEXT.vods.bulkAddResult + .replace('{added}', String(added)) + .replace('{duplicates}', String(duplicates)) + .replace('{invalid}', String(invalid)) + .replace('{failed}', String(failed)), 'warn'); + } + } finally { + if (btn) btn.textContent = originalText; + endVodBulkOperation(); } } @@ -1398,6 +1557,7 @@ function animateVodGridMotion(motion: VodGridMotion): void { } function renderVodGridFromCurrentState(motion?: VodGridMotion): void { + clearActiveVodHoverPreview(); if (!lastLoadedStreamer) return; const grid = byId('vodGrid'); @@ -1421,6 +1581,12 @@ function renderVodGridFromCurrentState(motion?: VodGridMotion): void { : sorted; const filtered = filterVodsByQuery(sortedAndHidden, vodFilterQuery); + if (filtered.length === 0 && vodHideDownloaded && sortedAndHidden.length === 0 && !vodFilterQuery.trim()) { + setVodGridEmptyState(grid, UI_TEXT.vods.hideDownloadedEmptyTitle, UI_TEXT.vods.hideDownloadedEmptyText); + updateVodFilterCount(0, total); + return; + } + if (filtered.length === 0 && vodFilterQuery.trim()) { setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); updateVodFilterCount(0, total); diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 3369b05..534cbb5 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -168,6 +168,11 @@ function applyLanguageToStaticUI(): void { setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle); setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder); setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse); + setText('cutterNewVideoText', UI_TEXT.cutter.newVideo); + setAriaLabel('cutterOpenProjectBtn', UI_TEXT.cutter.openProject); + setTitle('cutterOpenProjectBtn', UI_TEXT.cutter.openProject); + setAriaLabel('cutterSaveProjectBtn', UI_TEXT.cutter.saveProject); + setTitle('cutterSaveProjectBtn', UI_TEXT.cutter.saveProject); setText('commandPaletteTitle', UI_TEXT.static.commandPaletteTitle); setAriaLabel('commandPaletteInput', UI_TEXT.static.commandPaletteAria); setAriaLabel('commandPaletteList', UI_TEXT.static.commandPaletteResultsAria); @@ -193,6 +198,19 @@ function applyLanguageToStaticUI(): void { setText('cutterVideoTrackLabel', UI_TEXT.cutter.videoTrack); setText('cutterAudioTrackLabel', UI_TEXT.cutter.audioTrack); setText('cutterAudioEmpty', UI_TEXT.cutter.noAudio); + setText('cutterRecoveryText', UI_TEXT.cutter.recoveryFound); + setText('cutterRecoveryRestoreBtn', UI_TEXT.cutter.recoverProject); + setText('cutterRecoveryDiscardBtn', UI_TEXT.cutter.discardProject); + setText('cutterExportProfileLabel', UI_TEXT.cutter.exportProfileLabel); + setText('cutterExportEncoderLabel', UI_TEXT.cutter.exportEncoderLabel); + setText('cutterAudioStreamLabel', UI_TEXT.cutter.audioStreamLabel); + setText('cutterProfileQualityOption', UI_TEXT.cutter.profileQuality); + setText('cutterProfileBalancedOption', UI_TEXT.cutter.profileBalanced); + setText('cutterProfileFastOption', UI_TEXT.cutter.profileFast); + setText('cutterProfileArchiveOption', UI_TEXT.cutter.profileArchive); + setText('cutterEncoderSoftwareOption', UI_TEXT.cutter.encoderSoftware); + setText('cutterAudioStreamEmptyOption', UI_TEXT.cutter.noAudio); + setText('cutterSpeedNormalBtn', UI_TEXT.cutter.speedNormal); setText('cutterLoadingLabel', UI_TEXT.cutter.loadingMedia); setText('cutterSpeedLabel', UI_TEXT.cutter.speedLabel); setAriaLabel('cutterPlayBtn', UI_TEXT.cutter.play); @@ -325,6 +343,7 @@ function applyLanguageToStaticUI(): void { setText('btnPreflightRun', UI_TEXT.static.preflightRun); setText('btnPreflightFix', UI_TEXT.static.preflightFix); setText('preflightResult', UI_TEXT.static.preflightEmpty); + if (typeof refreshLocalizedPreflightUi === 'function') refreshLocalizedPreflightUi(); setText('managedToolsTitle', UI_TEXT.static.managedToolsTitle); setText('btnRefreshManagedTools', UI_TEXT.static.managedToolsRefresh); setText('btnRepairManagedTools', UI_TEXT.static.managedToolsRepair); @@ -460,6 +479,7 @@ function applyLanguageToStaticUI(): void { } if (typeof updateCutterPlayUi === 'function') updateCutterPlayUi(); if (typeof updateCutterMuteUi === 'function') updateCutterMuteUi(); + if (typeof refreshCutterLocalizedUi === 'function') refreshCutterLocalizedUi(); if (typeof renderCutterEditor === 'function') renderCutterEditor(); } diff --git a/src/renderer-updates.production-path.test.ts b/src/renderer-updates.production-path.test.ts index 5212073..39a455a 100644 --- a/src/renderer-updates.production-path.test.ts +++ b/src/renderer-updates.production-path.test.ts @@ -4,30 +4,561 @@ import { runInNewContext } from 'node:vm'; import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; import { describe, expect, test } from 'vitest'; -function sourceFragment(start: string, end: string): string { - const source = readFileSync(join(__dirname, 'renderer-updates.ts'), 'utf8'); - const from = source.indexOf(start); - const to = source.indexOf(end, from); - if (from < 0 || to < 0) throw new Error('Missing renderer updates production fragment'); - return source.slice(from, to); +type UpdateInfoFixture = { + version?: string; + releaseName?: string; + releaseDate?: string; + releaseNotes?: string; +}; + +type DownloadProgressFixture = { + percent: number; + transferred: number; + total: number; +}; + +class FakeClassList { + private readonly values = new Set(); + + add(...tokens: string[]): void { + tokens.forEach((token) => this.values.add(token)); + } + + remove(...tokens: string[]): void { + tokens.forEach((token) => this.values.delete(token)); + } + + contains(token: string): boolean { + return this.values.has(token); + } + + toggle(token: string, force?: boolean): boolean { + const enabled = force ?? !this.values.has(token); + if (enabled) this.values.add(token); + else this.values.delete(token); + return enabled; + } } -function evaluate(source: string, context: Record): { rememberUpdateInfo: (info?: { version?: string } | null) => unknown } { +class FakeElement { + readonly classList = new FakeClassList(); + readonly dataset: Record = {}; + readonly style: Record = {}; + readonly attributes = new Map(); + readonly children: FakeElement[] = []; + hidden = false; + disabled = false; + textContent = ''; + innerHTML = ''; + title = ''; + + constructor(readonly id: string, private readonly document: FakeDocument) { } + + get childNodes(): FakeElement[] { + return this.children; + } + + appendChild(child: FakeElement): FakeElement { + if (child.id === 'fragment') { + child.children.forEach((entry) => this.children.push(entry)); + return child; + } + this.children.push(child); + return child; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + removeAttribute(name: string): void { + this.attributes.delete(name); + } + + addEventListener(): void { } + + matches(): boolean { + return false; + } + + focus(): void { + this.document.activeElement = this; + } +} + +class FakeDocument { + readonly body = new FakeElement('body', this); + activeElement: FakeElement = this.body; + activeNavigationItem: FakeElement | null = null; + + constructor(private readonly elements: Map) { } + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName, this); + } + + createTextNode(text: string): FakeElement { + const node = new FakeElement('text', this); + node.textContent = text; + return node; + } + + createDocumentFragment(): FakeElement { + return new FakeElement('fragment', this); + } + + querySelector(selector: string): T | null { + if (selector === '.top-nav-item[aria-current="page"]') { + return this.activeNavigationItem as T | null; + } + return null; + } + + addEventListener(): void { } +} + +interface UpdateCallbacks { + checking: () => void; + available: (info: UpdateInfoFixture) => void; + notAvailable: () => void; + progress: (progress: DownloadProgressFixture) => void; + downloaded: (info: UpdateInfoFixture) => void; + error: (payload: { message?: string; kind: 'check' | 'download'; version?: string }) => void; +} + +interface ProductionApi { + rememberUpdateInfo(info?: UpdateInfoFixture | null): UpdateInfoFixture | null; + checkUpdate(): Promise; + downloadUpdate(): void; + postponeWorkspaceUpdatePopover(): void; + dismissWorkspaceUpdatePopover(): void; + getState(): { + updateBannerState: string; + updateDownloadInProgress: boolean; + workspaceUpdatePopoverPostponed: boolean; + }; +} + +interface Runtime { + api: ProductionApi; + callbacks: UpdateCallbacks; + document: FakeDocument; + elements: Map; + notifications: Array<{ message: string; type: string }>; + download: { + resolve(result?: Record): void; + reject(error: Error): void; + }; + check: { + resolve(result?: Record): void; + reject(error: Error): void; + }; +} + +const elementIds = [ + 'checkUpdateBtn', + 'workspaceUpdateButton', + 'updateBanner', + 'workspaceUpdateLabel', + 'updateText', + 'workspaceUpdateLater', + 'workspaceUpdateDismiss', + 'updateProgress', + 'updateProgressBar', + 'updateProgressGauge', + 'updateButton', + 'updateModal', + 'updateModalTitle', + 'updateModalMessage', + 'updateModalDismissBtn', + 'updateModalConfirmBtn', + 'updateModalSkipBtn', + 'updateChangelogLabel', + 'updateChangelogEmpty', + 'updateModalMeta', + 'updateChangelogCard', + 'updateChangelogPanel', + 'updateChangelogContent', + 'updateChangelogToggle', +]; + +function createRuntime(): Runtime { + const elements = new Map(); + const document = new FakeDocument(elements); + elementIds.forEach((id) => elements.set(id, new FakeElement(id, document))); + const activeNavigationItem = new FakeElement('activeNavigationItem', document); + activeNavigationItem.setAttribute('aria-current', 'page'); + document.activeNavigationItem = activeNavigationItem; + const callbacks = {} as UpdateCallbacks; + const notifications: Array<{ message: string; type: string }> = []; + let resolveDownload!: (result?: Record) => void; + let rejectDownload!: (error: Error) => void; + let resolveCheck!: (result?: Record) => void; + let rejectCheck!: (error: Error) => void; + const downloadPromise = new Promise | undefined>((resolve, reject) => { + resolveDownload = resolve; + rejectDownload = reject; + }); + const checkPromise = new Promise | undefined>((resolve, reject) => { + resolveCheck = resolve; + rejectCheck = reject; + }); + const context: Record = { + console, + document, + updateReady: false, + UI_TEXT: { + static: { checkUpdates: 'Check for updates' }, + updates: { + checking: 'Checking...', + installNow: 'Install now', + downloadNow: 'Download now', + downloading: 'Downloading...', + downloadLabel: 'Download', + ready: 'ready to install', + available: 'available', + checkFailed: 'Update check failed.', + downloadFailed: 'Update download failed.', + downloadInProgress: 'Update download is already running.', + readyToInstall: 'Update is ready to install.', + checkInProgress: 'Update check is already running.', + latest: 'You are on the latest version.', + modalReadyTitle: 'Ready', + modalAvailableTitle: 'Available', + modalReadyMessage: 'Version {version} is ready.', + modalAvailableMessage: 'Version {version} is available.', + modalDismiss: 'Later', + modalInstallConfirm: 'Install', + modalDownloadConfirm: 'Download', + modalSkipVersion: 'Skip', + releasedLabel: 'Release', + changelogLabel: 'Changelog', + noChangelog: 'No changelog', + hideChangelog: 'Hide changelog', + showChangelog: 'Show changelog', + }, + }, + RendererAccessibility: { + openDialog: (id: string) => elements.get(id)?.classList.add('show'), + closeDialog: (id: string) => elements.get(id)?.classList.remove('show'), + }, + getIntlLocale: () => 'en-US', + safeLocalStorageGet: () => '', + safeLocalStorageSet: () => undefined, + safeLocalStorageRemove: () => undefined, + alert: (message: string) => notifications.push({ message, type: 'warn' }), + requestAnimationFrame: (callback: () => void) => callback(), + setTimeout, + clearTimeout, + }; + const windowApi = { + checkUpdate: () => checkPromise, + downloadUpdate: () => downloadPromise, + installUpdate: () => Promise.resolve(), + onUpdateChecking: (callback: () => void) => { callbacks.checking = callback; }, + onUpdateAvailable: (callback: (info: UpdateInfoFixture) => void) => { callbacks.available = callback; }, + onUpdateNotAvailable: (callback: () => void) => { callbacks.notAvailable = callback; }, + onUpdateDownloadProgress: (callback: (progress: DownloadProgressFixture) => void) => { callbacks.progress = callback; }, + onUpdateDownloaded: (callback: (info: UpdateInfoFixture) => void) => { callbacks.downloaded = callback; }, + onUpdateError: (callback: (payload: { message?: string; kind: 'check' | 'download'; version?: string }) => void) => { callbacks.error = callback; }, + }; + context.api = windowApi; + context.showAppToast = (message: string, type = 'info') => notifications.push({ message, type }); + context.window = context; context.globalThis = context; - const compiled = transpileModule(`${source}\nObject.assign(globalThis, { __updatesProductionPath: { rememberUpdateInfo } });`, { + context.byId = (id: string) => { + const element = elements.get(id); + if (!element) throw new Error(`Missing element ${id}`); + return element; + }; + const source = readFileSync(join(__dirname, 'renderer-updates.ts'), 'utf8'); + const exposed = ` + Object.assign(globalThis, { + __updatesProductionPath: { + rememberUpdateInfo, + checkUpdate, + downloadUpdate, + postponeWorkspaceUpdatePopover, + dismissWorkspaceUpdatePopover, + getState: () => ({ updateBannerState, updateDownloadInProgress, workspaceUpdatePopoverPostponed }) + } + }); + `; + const compiled = transpileModule(`${source}\n${exposed}`, { compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 }, }).outputText; runInNewContext(compiled, context); - return (context as { __updatesProductionPath: { rememberUpdateInfo: (info?: { version?: string } | null) => unknown } }).__updatesProductionPath; + + return { + api: context.__updatesProductionPath as ProductionApi, + callbacks, + document, + elements, + notifications, + download: { resolve: resolveDownload, reject: rejectDownload }, + check: { resolve: resolveCheck, reject: rejectCheck }, + }; +} + +async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); } describe('renderer update production paths', () => { test('does not create an update state without a version', () => { - const api = evaluate(sourceFragment('function rememberUpdateInfo', 'function getActiveUpdateInfo'), { - latestUpdateVersion: '', - latestUpdateInfo: null, - }); + const runtime = createRuntime(); - expect(api.rememberUpdateInfo({})).toBeNull(); + expect(runtime.api.rememberUpdateInfo({})).toBeNull(); + }); + + test('renders localized pending copy without a fabricated version', () => { + const runtime = createRuntime(); + + runtime.api.downloadUpdate(); + + expect(runtime.elements.get('updateText')?.textContent).toBe('Downloading...'); + }); + + test('moves focus to the current navigation control after Later hides the update trigger', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.elements.get('workspaceUpdateLater')?.focus(); + + runtime.api.postponeWorkspaceUpdatePopover(); + + expect(runtime.document.activeElement).toBe(runtime.document.activeNavigationItem); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false); + }); + + test('moves focus to the current navigation control after Dismiss hides the update trigger', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.elements.get('workspaceUpdateDismiss')?.focus(); + + runtime.api.dismissWorkspaceUpdatePopover(); + + expect(runtime.document.activeElement).toBe(runtime.document.activeNavigationItem); + expect(runtime.elements.get('updateBanner')?.hidden).toBe(true); + }); + + test('keeps dismissed download progress hidden until the ready state is reached', () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + runtime.api.dismissWorkspaceUpdatePopover(); + + runtime.callbacks.progress({ percent: 50, transferred: 1024 * 1024, total: 2 * 1024 * 1024 }); + + expect(runtime.api.getState().updateBannerState).toBe('downloading'); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false); + expect(runtime.elements.get('updateText')?.textContent).toBe('Download: 1.0 / 2.0 MB (50%)'); + expect(runtime.elements.get('updateProgressGauge')?.getAttribute('aria-valuenow')).toBe('50'); + + runtime.callbacks.downloaded({ version: '1.2.3' }); + + expect(runtime.api.getState().updateBannerState).toBe('ready'); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(true); + }); + + test('deduplicates a main error followed by a rejected download operation', async () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + + runtime.callbacks.error({ kind: 'download' }); + runtime.download.reject(new Error('download failed')); + await flushPromises(); + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']); + }); + + test('deduplicates a main error followed by the production error result', async () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + + runtime.callbacks.error({ kind: 'download' }); + runtime.download.resolve({ error: true }); + await flushPromises(); + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']); + }); + + test('deduplicates a typed check error followed by the IPC error result', async () => { + const runtime = createRuntime(); + const pending = runtime.api.checkUpdate(); + + runtime.callbacks.error({ kind: 'check' }); + runtime.check.resolve({ error: true }); + await pending; + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update check failed.']); + }); + + test('reports a blocked manual check as an active download', async () => { + const runtime = createRuntime(); + const pending = runtime.api.checkUpdate(); + + runtime.check.resolve({ checking: true, skipped: 'downloading' }); + await pending; + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download is already running.']); + }); + + test('keeps download failure deduplication scoped to its operation while a new check begins', async () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + + runtime.callbacks.error({ kind: 'download' }); + runtime.callbacks.checking(); + runtime.download.reject(new Error('download failed')); + await flushPromises(); + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']); + }); + + test('reports a later independent error without relying on a checking event', async () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + runtime.callbacks.error({ kind: 'download' }); + runtime.download.resolve({ error: true }); + await flushPromises(); + + runtime.callbacks.error({ kind: 'check' }); + + expect(runtime.notifications.map(({ message }) => message)).toEqual([ + 'Update download failed.', + 'Update check failed.', + ]); + }); + + test('ignores stale check events while a download is active', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.api.downloadUpdate(); + runtime.callbacks.checking(); + runtime.callbacks.available({ version: '1.2.4' }); + runtime.callbacks.notAvailable(); + + runtime.callbacks.error({ kind: 'check' }); + + expect(runtime.notifications).toEqual([]); + expect(runtime.api.getState().updateBannerState).toBe('downloading'); + expect(runtime.api.getState().updateDownloadInProgress).toBe(true); + }); + + test('reports a download failure when only the main error channel fires', () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + + runtime.callbacks.error({ kind: 'download' }); + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']); + }); + + test('ignores a download terminal for another version', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.api.downloadUpdate(); + + runtime.callbacks.error({ kind: 'download', version: '1.2.4' }); + + expect(runtime.notifications).toEqual([]); + expect(runtime.api.getState().updateBannerState).toBe('downloading'); + expect(runtime.api.getState().updateDownloadInProgress).toBe(true); + }); + + test('reports a download failure when only the rejected operation channel fires', async () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + + runtime.download.reject(new Error('download failed')); + await flushPromises(); + + expect(runtime.notifications.map(({ message }) => message)).toEqual(['Update download failed.']); + }); + + test('reports a later independent check error after a handled download failure', async () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + runtime.download.reject(new Error('download failed')); + await flushPromises(); + + runtime.callbacks.checking(); + runtime.callbacks.error({ kind: 'check' }); + + expect(runtime.notifications.map(({ message }) => message)).toEqual([ + 'Update download failed.', + 'Update check failed.', + ]); + }); + + test('restores the available state after download failure without reopening a dismissed popover', async () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.api.downloadUpdate(); + runtime.api.dismissWorkspaceUpdatePopover(); + + runtime.download.reject(new Error('download failed')); + await flushPromises(); + + expect(runtime.api.getState().updateBannerState).toBe('available'); + expect(runtime.api.getState().workspaceUpdatePopoverPostponed).toBe(true); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false); + }); + + test('restores and reveals the available state after an ordinary download failure', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.api.downloadUpdate(); + + runtime.callbacks.error({ kind: 'download', version: '1.2.3' }); + + expect(runtime.api.getState().updateBannerState).toBe('available'); + expect(runtime.api.getState().workspaceUpdatePopoverPostponed).toBe(false); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(true); + }); + + test('leaves downloading state after an error when no update version is cached', () => { + const runtime = createRuntime(); + runtime.api.downloadUpdate(); + runtime.callbacks.progress({ percent: 25, transferred: 512, total: 2048 }); + + runtime.callbacks.error({ kind: 'download' }); + + expect(runtime.api.getState().updateBannerState).toBe('idle'); + expect(runtime.api.getState().updateDownloadInProgress).toBe(false); + expect(runtime.elements.get('updateBanner')?.hidden).toBe(true); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false); + expect(runtime.elements.get('updateProgress')?.classList.contains('is-hidden')).toBe(true); + expect(runtime.elements.get('updateProgressBar')?.style.width).toBe('0%'); + expect(runtime.elements.get('updateProgressGauge')?.getAttribute('aria-valuenow')).toBe('0'); + }); + + test('does not reopen a Later update when an unrelated check later fails', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.api.postponeWorkspaceUpdatePopover(); + + runtime.callbacks.checking(); + runtime.callbacks.error({ kind: 'check' }); + + expect(runtime.api.getState().updateBannerState).toBe('available'); + expect(runtime.api.getState().workspaceUpdatePopoverPostponed).toBe(true); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false); + }); + + test('does not reopen a dismissed update when an unrelated check later fails', () => { + const runtime = createRuntime(); + runtime.callbacks.available({ version: '1.2.3' }); + runtime.api.dismissWorkspaceUpdatePopover(); + + runtime.callbacks.checking(); + runtime.callbacks.error({ kind: 'check' }); + + expect(runtime.api.getState().updateBannerState).toBe('idle'); + expect(runtime.elements.get('updateBanner')?.hidden).toBe(true); + expect(runtime.elements.get('updateBanner')?.classList.contains('show')).toBe(false); }); }); diff --git a/src/renderer-updates.ts b/src/renderer-updates.ts index 2d8cb43..b91b080 100644 --- a/src/renderer-updates.ts +++ b/src/renderer-updates.ts @@ -1,5 +1,6 @@ let updateCheckInProgress = false; let updateDownloadInProgress = false; +let updateDownloadOperation: { failureHandled: boolean } | null = null; let manualUpdateCheckPending = false; let manualUpdateOutcomeHandled = false; let latestUpdateVersion = ''; @@ -137,6 +138,10 @@ function showUpdateBanner(): void { syncWorkspaceUpdateState(updateBannerState); } +function focusWorkspaceAfterUpdateHidden(): void { + document.querySelector('.top-nav-item[aria-current="page"]')?.focus(); +} + function hideUpdateBanner(): void { updateBannerState = 'idle'; workspaceUpdatePopoverPostponed = false; @@ -163,12 +168,17 @@ function postponeWorkspaceUpdatePopover(): void { banner.classList.remove('show'); banner.classList.add('popover-dismissed'); byId('workspaceUpdateButton').setAttribute('aria-expanded', 'false'); - byId('workspaceUpdateButton').focus(); + focusWorkspaceAfterUpdateHidden(); } function dismissWorkspaceUpdatePopover(): void { + if (updateBannerState === 'downloading') { + postponeWorkspaceUpdatePopover(); + return; + } + hideUpdateBanner(); - byId('workspaceUpdateButton').focus(); + focusWorkspaceAfterUpdateHidden(); } for (const eventName of ['mouseenter', 'mouseleave', 'focusin', 'focusout']) { @@ -217,11 +227,13 @@ function setUpdateBannerAvailableUi(info: UpdateInfo, reveal = true): void { syncWorkspaceUpdateState('available'); } -function setDownloadPendingUi(): void { +function setDownloadPendingUi(reveal = true): void { updateReady = false; updateBannerState = 'downloading'; - workspaceUpdatePopoverPostponed = false; - byId('updateBanner').classList.remove('popover-dismissed'); + if (reveal) { + workspaceUpdatePopoverPostponed = false; + byId('updateBanner').classList.remove('popover-dismissed'); + } showUpdateBanner(); const button = byId('updateButton'); @@ -236,7 +248,9 @@ function setDownloadPendingUi(): void { byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(pendingPct))); if (!latestDownloadProgress) { - byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`; + byId('updateText').textContent = latestUpdateVersion + ? `Version ${latestUpdateVersion} ${UI_TEXT.updates.downloading}` + : UI_TEXT.updates.downloading; } syncWorkspaceUpdateState('downloading'); } @@ -246,6 +260,7 @@ function setDownloadReadyUi(info?: UpdateInfo): void { if (!activeInfo) return; updateReady = true; updateDownloadInProgress = false; + updateDownloadOperation = null; updateBannerState = 'ready'; workspaceUpdatePopoverPostponed = false; byId('updateBanner').classList.remove('popover-dismissed'); @@ -497,7 +512,7 @@ function refreshUpdateUiTexts(): void { const totalMb = (latestDownloadProgress.total / 1024 / 1024).toFixed(1); byId('updateText').textContent = `${UI_TEXT.updates.downloadLabel}: ${mb} / ${totalMb} MB (${latestDownloadProgress.percent.toFixed(0)}%)`; } else { - setDownloadPendingUi(); + setDownloadPendingUi(false); } } else if (updateBannerState === 'ready' && latestUpdateInfo) { setDownloadReadyUi(latestUpdateInfo); @@ -534,12 +549,13 @@ async function checkUpdate(): Promise { const result = await window.api.checkUpdate(); if (result?.error) { + const alreadyHandled = manualUpdateOutcomeHandled; shouldOpenUpdateModalOnAvailable = false; manualUpdateOutcomeHandled = true; manualUpdateCheckPending = false; updateCheckInProgress = false; setCheckButtonCheckingState(false); - notifyUpdate(UI_TEXT.updates.checkFailed, 'warn'); + if (!alreadyHandled) notifyUpdate(UI_TEXT.updates.checkFailed, 'warn'); return; } @@ -559,6 +575,16 @@ async function checkUpdate(): Promise { return; } + if (skippedReason === 'downloading') { + shouldOpenUpdateModalOnAvailable = false; + manualUpdateOutcomeHandled = true; + manualUpdateCheckPending = false; + updateCheckInProgress = false; + setCheckButtonCheckingState(false); + notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info'); + return; + } + if (skippedReason === 'in-progress' || skippedReason === 'throttled' || skippedReason === 'timed-out') { shouldOpenUpdateModalOnAvailable = false; manualUpdateOutcomeHandled = true; @@ -589,6 +615,27 @@ async function checkUpdate(): Promise { } } +function handleUpdateDownloadFailure(operation: { failureHandled: boolean }): void { + if (operation.failureHandled) { + return; + } + + operation.failureHandled = true; + if (operation !== updateDownloadOperation) { + return; + } + + updateDownloadOperation = null; + updateDownloadInProgress = false; + latestDownloadProgress = null; + if (latestUpdateInfo) { + setUpdateBannerAvailableUi(latestUpdateInfo, false); + } else { + hideUpdateBanner(); + } + notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn'); +} + function downloadUpdate(): void { if (updateReady) { dismissUpdateModal(); @@ -602,17 +649,15 @@ function downloadUpdate(): void { } updateDownloadInProgress = true; + const operation = { failureHandled: false }; + updateDownloadOperation = operation; latestDownloadProgress = null; dismissUpdateModal(); setDownloadPendingUi(); void window.api.downloadUpdate().then((result) => { if (result?.error) { - updateDownloadInProgress = false; - if (latestUpdateInfo) { - setUpdateBannerAvailableUi(latestUpdateInfo); - } - notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn'); + handleUpdateDownloadFailure(operation); return; } @@ -629,15 +674,12 @@ function downloadUpdate(): void { notifyUpdate(UI_TEXT.updates.downloadInProgress, 'info'); } }).catch(() => { - updateDownloadInProgress = false; - if (latestUpdateInfo) { - setUpdateBannerAvailableUi(latestUpdateInfo); - } - notifyUpdate(UI_TEXT.updates.downloadFailed, 'warn'); + handleUpdateDownloadFailure(operation); }); } window.api.onUpdateChecking(() => { + if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return; updateCheckInProgress = true; if (manualUpdateCheckPending) { setCheckButtonCheckingState(true); @@ -645,6 +687,7 @@ window.api.onUpdateChecking(() => { }); window.api.onUpdateAvailable((info: UpdateInfo) => { + if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return; const activeInfo = rememberUpdateInfo(info); updateCheckInProgress = false; updateReady = false; @@ -681,6 +724,7 @@ window.api.onUpdateAvailable((info: UpdateInfo) => { window.api.onUpdateNotAvailable(() => { + if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return; updateCheckInProgress = false; setCheckButtonCheckingState(false); manualUpdateOutcomeHandled = true; @@ -726,20 +770,29 @@ window.api.onUpdateDownloaded((info: UpdateInfo) => { openUpdateModal(activeInfo); }); -window.api.onUpdateError(() => { +window.api.onUpdateError((payload) => { + if (payload.kind === 'check') { + if (updateDownloadInProgress || updateBannerState === 'downloading' || updateReady || updateBannerState === 'ready') return; + updateCheckInProgress = false; + manualUpdateCheckPending = false; + manualUpdateOutcomeHandled = true; + shouldOpenUpdateModalOnAvailable = false; + setCheckButtonCheckingState(false); + notifyUpdate(UI_TEXT.updates.checkFailed, 'warn'); + return; + } + + const operation = updateDownloadOperation; + if (!updateDownloadInProgress || operation === null) return; + const activeVersion = (latestUpdateInfo?.version || latestUpdateVersion || '').trim(); + if (payload.version && activeVersion && payload.version !== activeVersion) return; updateCheckInProgress = false; - const wasDownloading = updateDownloadInProgress; - updateDownloadInProgress = false; manualUpdateCheckPending = false; manualUpdateOutcomeHandled = true; shouldOpenUpdateModalOnAvailable = false; setCheckButtonCheckingState(false); - if (!updateReady && latestUpdateInfo) { - setUpdateBannerAvailableUi(latestUpdateInfo); - } - - notifyUpdate(wasDownloading ? UI_TEXT.updates.downloadFailed : UI_TEXT.updates.checkFailed, 'warn'); + handleUpdateDownloadFailure(operation); }); document.addEventListener('keydown', (event) => { diff --git a/src/renderer-vod-hover.lifecycle.test.ts b/src/renderer-vod-hover.lifecycle.test.ts new file mode 100644 index 0000000..6fb6473 --- /dev/null +++ b/src/renderer-vod-hover.lifecycle.test.ts @@ -0,0 +1,242 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +class FakeClassList { + private readonly values = new Set(); + + add(...tokens: string[]): void { + tokens.forEach((token) => this.values.add(token)); + } + + remove(...tokens: string[]): void { + tokens.forEach((token) => this.values.delete(token)); + } + + contains(token: string): boolean { + return this.values.has(token); + } +} + +class FakeElement { + readonly classList = new FakeClassList(); + readonly dataset: Record = {}; + readonly style: Record = {}; + readonly children: FakeElement[] = []; + readonly listeners = new Map void>>(); + className = ''; + parentElement: FakeElement | null = null; + + constructor(readonly tagName: string) { } + + appendChild(child: FakeElement): FakeElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + addEventListener(type: string, listener: (event: { target: FakeElement; relatedTarget: FakeElement | null }) => void): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type: string, target: FakeElement, relatedTarget: FakeElement | null): void { + for (const listener of this.listeners.get(type) ?? []) listener({ target, relatedTarget }); + } + + closest(selector: string): FakeElement | null { + if (selector === '.vod-card' && this.className.split(/\s+/).includes('vod-card')) return this; + return this.parentElement?.closest(selector) ?? null; + } + + contains(node: FakeElement): boolean { + return node === this || this.children.some((child) => child.contains(node)); + } + + remove(): void { + if (!this.parentElement) return; + const index = this.parentElement.children.indexOf(this); + if (index >= 0) this.parentElement.children.splice(index, 1); + this.parentElement = null; + } + + querySelector(selector: string): FakeElement | null { + if (selector === '.vod-thumb-wrap') return this.children.find((child) => child.className === 'vod-thumb-wrap') ?? null; + return null; + } + + getBoundingClientRect(): { width: number; height: number } { + return { width: 320, height: 180 }; + } +} + +describe('renderer VOD hover lifecycle', () => { + it('does not restart an active preview while moving between elements of the same card', async () => { + const source = readFileSync(join(__dirname, 'renderer-vod-hover.ts'), 'utf8'); + const grid = new FakeElement('section'); + const card = createCard(); + const title = new FakeElement('h3'); + card.appendChild(title); + grid.appendChild(card); + const context = createHoverContext([Promise.resolve(storyboard())], grid); + evaluateHover(source, context); + const bind = (context.window as Record).ensureVodHoverHandlersBound as (() => void); + bind(); + + grid.dispatch('mouseover', card.children[0], null); + context.scheduledTimers.shift()?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(context.intervalCount).toBe(1); + expect(context.scheduledTimers).toEqual([]); + + grid.dispatch('mouseover', title, card.children[0]); + + expect(context.scheduledTimers).toEqual([]); + expect(context.intervalCount).toBe(1); + }); + + it('does not let an older same-ID fetch steal the newer card activation', async () => { + const source = readFileSync(join(__dirname, 'renderer-vod-hover.ts'), 'utf8'); + const requests = [deferredStoryboard(), deferredStoryboard()]; + const cardA = createCard(); + const cardB = createCard(); + const context = createHoverContext(requests.map((request) => request.promise)); + const exposed = evaluateHover(source, context); + + exposed.scheduleHoverPreview(cardA, 'vod-1'); + context.scheduledTimers.shift()?.(); + exposed.clearHoverPreview(); + exposed.scheduleHoverPreview(cardB, 'vod-1'); + context.scheduledTimers.shift()?.(); + + requests[0].resolve(storyboard()); + await new Promise((resolve) => setImmediate(resolve)); + expect(cardA.children[0].children).toEqual([]); + + requests[1].resolve(storyboard()); + await new Promise((resolve) => setImmediate(resolve)); + expect(cardA.children[0].children).toEqual([]); + expect(cardB.children[0].children).toHaveLength(1); + expect(context.intervalCount).toBe(1); + }); + + it('exports cleanup that cancels the interval, removes the preview and invalidates a queued activation frame', async () => { + const source = readFileSync(join(__dirname, 'renderer-vod-hover.ts'), 'utf8'); + const scheduledFrames: Array<() => void> = []; + const scheduledTimers: Array<() => void> = []; + const clearedIntervals: number[] = []; + const card = new FakeElement('article'); + const thumbnail = new FakeElement('div'); + thumbnail.className = 'vod-thumb-wrap'; + card.appendChild(thumbnail); + const context: Record = { + document: { + readyState: 'loading', + addEventListener: () => undefined, + getElementById: () => null, + createElement: (tagName: string) => new FakeElement(tagName) + }, + HTMLElement: FakeElement, + requestAnimationFrame: (callback: () => void) => { scheduledFrames.push(callback); return scheduledFrames.length; }, + setTimeout: (callback: () => void) => { scheduledTimers.push(callback); return scheduledTimers.length; }, + setInterval: () => 73, + clearInterval: (id: number) => clearedIntervals.push(id), + api: { + getVodStoryboard: vi.fn(async () => ({ + framesInSprite: 4, + cols: 2, + rows: 2, + cellWidth: 160, + cellHeight: 90, + spriteDataUrl: 'data:image/jpeg;base64,preview', + frameDataUrls: [] + })) + } + }; + context.window = context; + const compiled = transpileModule(`${source}\nglobalThis.exposed = { scheduleHoverPreview };`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } + }).outputText; + runInNewContext(compiled, context); + const exposed = (context as { exposed: { scheduleHoverPreview(card: FakeElement, vodId: string): void } }).exposed; + exposed.scheduleHoverPreview(card, 'vod-1'); + scheduledTimers.shift()?.(); + await new Promise((resolve) => setImmediate(resolve)); + const cleanup = (context.window as Record).clearVodHoverPreview as (() => void) | undefined; + expect(cleanup).toBeTypeOf('function'); + cleanup?.(); + scheduledFrames.forEach((callback) => callback()); + + expect(clearedIntervals).toEqual([73]); + expect(card.classList.contains('preview-active')).toBe(false); + expect(thumbnail.children[0]?.style.opacity).toBe('0'); + }); +}); + +function storyboard(): Record { + return { + framesInSprite: 4, + cols: 2, + rows: 2, + cellWidth: 160, + cellHeight: 90, + spriteDataUrl: 'data:image/jpeg;base64,preview', + frameDataUrls: [] + }; +} + +function deferredStoryboard(): { promise: Promise>; resolve(value: Record): void } { + let resolvePromise!: (value: Record) => void; + return { + promise: new Promise((resolve) => { resolvePromise = resolve; }), + resolve: resolvePromise + }; +} + +function createCard(): FakeElement { + const card = new FakeElement('article'); + card.className = 'vod-card'; + card.dataset.vodId = 'vod-1'; + const thumbnail = new FakeElement('div'); + thumbnail.className = 'vod-thumb-wrap'; + card.appendChild(thumbnail); + return card; +} + +interface HoverTestContext extends Record { + scheduledTimers: Array<() => void>; + intervalCount: number; +} + +function createHoverContext(requests: Array>>, grid: FakeElement | null = null): HoverTestContext { + const scheduledTimers: Array<() => void> = []; + const context: HoverTestContext = { + scheduledTimers, + intervalCount: 0, + document: { + readyState: 'loading', + addEventListener: () => undefined, + getElementById: () => grid, + createElement: (tagName: string) => new FakeElement(tagName) + }, + HTMLElement: FakeElement, + requestAnimationFrame: () => 1, + setTimeout: (callback: () => void) => { scheduledTimers.push(callback); return scheduledTimers.length; }, + setInterval: () => { context.intervalCount += 1; return context.intervalCount; }, + clearInterval: () => undefined, + api: { getVodStoryboard: vi.fn(() => requests.shift()) } + }; + context.window = context; + return context; +} + +function evaluateHover(source: string, context: HoverTestContext): { scheduleHoverPreview(card: FakeElement, vodId: string): void; clearHoverPreview(): void } { + const compiled = transpileModule(`${source}\nglobalThis.exposed = { scheduleHoverPreview, clearHoverPreview };`, { + compilerOptions: { module: ModuleKind.None, target: ScriptTarget.ES2022 } + }).outputText; + runInNewContext(compiled, context); + return (context as unknown as { exposed: { scheduleHoverPreview(card: FakeElement, vodId: string): void; clearHoverPreview(): void } }).exposed; +} diff --git a/src/renderer-vod-hover.ts b/src/renderer-vod-hover.ts index 1277728..8ce008d 100644 --- a/src/renderer-vod-hover.ts +++ b/src/renderer-vod-hover.ts @@ -18,6 +18,7 @@ interface ActiveHover { const vodStoryboardClientCache = new Map(); let activeHover: ActiveHover | null = null; let pendingHoverVodId: string | null = null; +let hoverRequestGeneration = 0; const HOVER_DEBOUNCE_MS = 220; const FRAME_INTERVAL_MS = 600; @@ -49,6 +50,8 @@ function ensureVodHoverHandlersBound(): void { const target = e.target as HTMLElement | null; const card = target?.closest('.vod-card') as HTMLElement | null; if (!card) return; + const related = e.relatedTarget as HTMLElement | null; + if (related && card.contains(related)) return; const vodId = card.dataset.vodId; if (!vodId) return; scheduleHoverPreview(card, vodId); @@ -68,15 +71,17 @@ function ensureVodHoverHandlersBound(): void { function scheduleHoverPreview(card: HTMLElement, vodId: string): void { if (pendingHoverVodId === vodId) return; pendingHoverVodId = vodId; + const generation = ++hoverRequestGeneration; // 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); + if (pendingHoverVodId !== vodId || generation !== hoverRequestGeneration) return; + void activateHoverPreview(card, vodId, generation); }, HOVER_DEBOUNCE_MS); } function clearHoverPreview(): void { + hoverRequestGeneration += 1; pendingHoverVodId = null; if (!activeHover) return; window.clearInterval(activeHover.intervalId); @@ -88,9 +93,9 @@ function clearHoverPreview(): void { activeHover = null; } -async function activateHoverPreview(card: HTMLElement, vodId: string): Promise { +async function activateHoverPreview(card: HTMLElement, vodId: string, generation: number): Promise { // Stale-guard: user might have moved off the card in the debounce window. - if (pendingHoverVodId !== vodId) return; + if (pendingHoverVodId !== vodId || generation !== hoverRequestGeneration) return; let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId); if (storyboard === undefined) { @@ -103,7 +108,7 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise { card.classList.add('preview-active'); }); - let frameIdx = 1; const intervalId = window.setInterval(() => { advanceFrame(frameIdx); @@ -178,9 +180,13 @@ async function activateHoverPreview(card: HTMLElement, vodId: string): Promise { + if (activeHover?.overlay === overlay) card.classList.add('preview-active'); + }); } (window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound; +(window as unknown as { clearVodHoverPreview: typeof clearHoverPreview }).clearVodHoverPreview = clearHoverPreview; // Bind once the grid exists. Tab switches don't re-create the grid, so // one-time binding via DOMContentLoaded is enough. diff --git a/src/renderer.ts b/src/renderer.ts index 5d0438e..0dfc10d 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1069,13 +1069,14 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] { return { ...item, progress: bestProgress, - speed: item.speed || prev.speed, - eta: item.eta || prev.eta, - currentPart: item.currentPart || prev.currentPart, - totalParts: item.totalParts || prev.totalParts, - downloadedBytes: item.downloadedBytes || prev.downloadedBytes, - totalBytes: item.totalBytes || prev.totalBytes, - progressStatus: item.progressStatus || prev.progressStatus + speed: item.speed === undefined ? prev.speed : item.speed, + eta: item.eta === undefined ? prev.eta : item.eta, + currentPart: item.currentPart === undefined ? prev.currentPart : item.currentPart, + totalParts: item.totalParts === undefined ? prev.totalParts : item.totalParts, + downloadedBytes: item.downloadedBytes === undefined ? prev.downloadedBytes : item.downloadedBytes, + totalBytes: item.totalBytes === undefined ? prev.totalBytes : item.totalBytes, + progressStatus: item.progressStatus === undefined ? prev.progressStatus : item.progressStatus, + recordingHealth: item.recordingHealth === undefined ? prev.recordingHealth : item.recordingHealth }; }); } @@ -1291,7 +1292,7 @@ function showTab(tab: string): void { // 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 + ? getStreamerDisplayName(currentStreamer) : (titles[tab] || UI_TEXT.appName); setPageTitle(pageTitleText); @@ -1547,7 +1548,7 @@ function getTemplateVariableDocs(): TemplateVariableDoc[] { { placeholder: '{part_padded}', description: text('Teilnummer mit 2 Stellen', 'Part number padded to 2 digits'), exampleTemplate: '{part_padded}' }, { placeholder: '{trim_start}', description: text('Startzeit des Ausschnitts', 'Trim start time'), exampleTemplate: '{trim_start}' }, { placeholder: '{trim_end}', description: text('Endzeit des Ausschnitts', 'Trim end time'), exampleTemplate: '{trim_end}' }, - { placeholder: '{trim_length}', description: text('Lange des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' }, + { placeholder: '{trim_length}', description: text('Länge des Ausschnitts', 'Trimmed duration'), exampleTemplate: '{trim_length}' }, { placeholder: '{length}', description: text('Gesamtdauer', 'Total duration'), exampleTemplate: '{length}' }, { placeholder: '{ext}', description: text('Dateiendung', 'File extension'), exampleTemplate: '{ext}' }, { placeholder: '{random_string}', description: text('Zufallsstring (8 Zeichen)', 'Random string (8 chars)'), exampleTemplate: '{random_string}' }, diff --git a/src/style-modules.production-path.test.ts b/src/style-modules.production-path.test.ts new file mode 100644 index 0000000..fbdd173 --- /dev/null +++ b/src/style-modules.production-path.test.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { isRendererReloadTarget } from './main/dev-reload'; + +const styleFiles = [ + 'styles.css', + 'styles-workflows.css', + 'styles-overlays.css', + 'workspace.css', + 'workspace-refinements.css', +]; + +describe('production style modules', () => { + test('preserves the complete stylesheet byte sequence across module boundaries', () => { + const content = Buffer.from(styleFiles + .map((fileName) => readFileSync(join(__dirname, fileName), 'utf8')) + .join('') + .replace(/\r\n/g, '\n')); + const digest = createHash('sha256').update(content).digest('hex'); + + expect(digest).toBe('143fb0cc3e6c2ca3f04ded7e3b83175db424b34b6ca9ed4ef32e337b4a60a898'); + }); + + test('derives the Windows hot-development executable version from package metadata', () => { + const script = readFileSync(join(__dirname, '../scripts/dev.mjs'), 'utf8'); + + expect(script).toContain("readFileSync(resolve(rootDirectory, 'package.json'), 'utf8')"); + expect(script).toMatch(/version:\s*developmentAppVersion/); + expect(script).not.toMatch(/version:\s*['"]\d+\.\d+\.\d+['"]/); + }); + + test('reloads every production stylesheet during hot development', () => { + for (const fileName of styleFiles) { + expect(isRendererReloadTarget(fileName), fileName).toBe(true); + } + expect(isRendererReloadTarget('future-workspace-surface.css')).toBe(true); + }); + + test('loads and packages every stylesheet in cascade order', () => { + const html = readFileSync(join(__dirname, 'index.html'), 'utf8'); + const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')) as { + build?: { files?: string[] }; + }; + const links = Array.from(html.matchAll(/ match[1]); + + expect(links).toEqual(styleFiles); + for (const fileName of styleFiles) { + expect(packageJson.build?.files).toContain(`src/${fileName}`); + } + expect(packageJson.build?.files).toContain('!dist/main/dev-executable.js'); + expect(packageJson.build?.files).toContain('!dist/main/index.js'); + expect(packageJson.build?.files).toContain('!dist/types.js'); + }); + + test('animates queue progress only while downloading and visibly marks paused items', () => { + const styles = readFileSync(join(__dirname, 'styles-workflows.css'), 'utf8'); + const baseShimmer = styles.match(/\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + const activeShimmer = styles.match(/\.status\.downloading\s*~\s*\.queue-main\s+\.queue-progress-bar::after\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + const pausedStatus = styles.match(/\.queue-item\s+\.status\.paused\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + + expect(baseShimmer).toMatch(/display:\s*none/); + expect(baseShimmer).toMatch(/animation:\s*none/); + expect(activeShimmer).toMatch(/display:\s*block/); + expect(activeShimmer).toMatch(/animation:\s*queue-progress-shimmer/); + expect(pausedStatus).toMatch(/background:/); + expect(pausedStatus).toMatch(/box-shadow:/); + }); +}); diff --git a/src/styles-overlays.css b/src/styles-overlays.css new file mode 100644 index 0000000..cf132c4 --- /dev/null +++ b/src/styles-overlays.css @@ -0,0 +1,1421 @@ + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.65); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + animation: modal-overlay-fade 0.2s ease-out; +} + +.modal-overlay.show { + display: flex; +} + +@keyframes modal-overlay-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: 14px; + padding: 25px 28px; + width: 90%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(145, 70, 255, 0.10); + animation: modal-pop 0.22s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; +} + +@keyframes modal-pop { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal h2 { + margin-bottom: 18px; + font-size: 18px; + font-weight: 600; + letter-spacing: -0.2px; + color: var(--text); +} + +.modal-close { + position: absolute; + top: 14px; + right: 14px; + width: 30px; + height: 30px; + background: transparent; + border: 1px solid var(--border-soft); + border-radius: 8px; + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.12s; +} + +.modal-close:hover { + color: #fff; + background: rgba(255, 70, 70, 0.18); + border-color: rgba(255, 70, 70, 0.55); +} + +.modal-close:focus-visible { + outline: none; + border-color: rgba(255, 70, 70, 0.6); + box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6); +} + +.modal-close:active { + transform: scale(0.92); +} + +.slider-group { + margin-bottom: 20px; +} + +.slider-group label { + display: block; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 13px; +} + +/* ============================================ + RANGE SLIDER — Twitch-purple track + thumb + ============================================ + Track gets a subtle purple-tint behind a darker base so the slider + reads as part of the same family as the queue progress bar. Thumb + is a 16px purple circle with a hover halo. */ +.slider-group input[type="range"], +.modal input[type="range"] { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: linear-gradient(90deg, rgba(145, 70, 255, 0.18) 0%, rgba(20, 20, 24, 0.95) 100%); + border-radius: 999px; + outline: none; + cursor: pointer; + transition: box-shadow 0.18s; +} + +.slider-group input[type="range"]:focus-visible, +.modal input[type="range"]:focus-visible { + box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.22); +} + +.slider-group input[type="range"]::-webkit-slider-thumb, +.modal input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--accent); + border: 2px solid #fff; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); + transition: transform 0.15s, box-shadow 0.15s; +} + +.slider-group input[type="range"]::-moz-range-thumb, +.modal input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--accent); + border: 2px solid #fff; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); + transition: transform 0.15s, box-shadow 0.15s; +} + +.slider-group input[type="range"]:hover::-webkit-slider-thumb, +.modal input[type="range"]:hover::-webkit-slider-thumb { + background: var(--accent-hover); + transform: scale(1.15); + box-shadow: 0 3px 12px rgba(145, 70, 255, 0.55); +} + +.slider-group input[type="range"]:hover::-moz-range-thumb, +.modal input[type="range"]:hover::-moz-range-thumb { + background: var(--accent-hover); + transform: scale(1.15); + box-shadow: 0 3px 12px rgba(145, 70, 255, 0.55); +} + +/* ============================================ + NUMBER INPUT — hide OS spinners, rely on keyboard / scroll + ============================================ + The default Webkit spinners are a tiny gray arrow stack that always + reads as "unfinished input field" no matter the theme. Hidden across + all number inputs; users type or use arrow keys. Spinner-on-hover + pattern could come back as a custom thing later if needed. */ +input[type="number"] { + -moz-appearance: textfield; +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; +} + +.clip-time-display { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-family: monospace; + font-size: 14px; +} + +.clip-info-row { + background: var(--bg-main); + padding: 12px 15px; + border-radius: 6px; + margin-bottom: 15px; + text-align: center; +} + +.clip-info-row .label { + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 4px; +} + +.clip-info-row .value { + font-size: 18px; + font-weight: 600; + color: var(--success); +} + +.clip-info-row .value.error { + color: var(--error); +} + +.part-number-group { + margin-bottom: 20px; +} + +.part-number-group input { + width: 100px; + background: var(--bg-main); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 8px 12px; + color: var(--text); + font-size: 14px; +} + +.part-number-group small { + display: block; + margin-top: 5px; + color: var(--text-secondary); + font-size: 11px; +} + +.modal-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.modal-actions button { + flex: 1; +} + +.template-guide-modal { + max-width: 860px; +} + +.template-guide-intro { + color: var(--text-secondary); + margin-bottom: 14px; + line-height: 1.5; +} + +.template-guide-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.template-guide-actions .btn-secondary { + padding: 8px 12px; + min-width: 140px; +} + +.template-guide-actions .btn-secondary.active { + background: var(--accent); + color: #fff; + border-color: transparent; +} + +.template-guide-label { + display: block; + margin-bottom: 6px; + font-size: 13px; + color: var(--text-secondary); +} + +.template-guide-input { + width: 100%; + font-family: Consolas, "Courier New", monospace; + margin-bottom: 10px; +} + +.template-guide-preview-box { + background: rgba(0, 0, 0, 0.22); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 10px; + margin-bottom: 14px; +} + +.template-guide-preview-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.template-guide-output { + font-family: Consolas, "Courier New", monospace; + color: var(--text); + word-break: break-word; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + padding: 8px; +} + +.template-guide-context { + margin-top: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.template-guide-vars-title { + margin: 0 0 8px; + font-size: 14px; +} + +.template-guide-table-wrap { + max-height: 280px; + overflow: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + margin-bottom: 12px; +} + +.template-guide-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.template-guide-table th, +.template-guide-table td { + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 8px; + vertical-align: top; +} + +.template-guide-table tbody tr:last-child td { + border-bottom: 0; +} + +.template-guide-table td:first-child, +.template-guide-table td:last-child { + font-family: Consolas, "Courier New", monospace; +} + +.template-guide-footer { + display: flex; + justify-content: flex-end; +} + +.app-toast { + position: fixed; + right: 18px; + bottom: 16px; + z-index: 2200; + max-width: min(90vw, 520px); + background: linear-gradient(135deg, rgba(28, 28, 34, 0.98), rgba(20, 20, 24, 0.98)); + color: #e6e6ea; + border: 1px solid rgba(255, 255, 255, 0.10); + border-left: 3px solid var(--accent); + border-radius: 10px; + padding: 12px 16px 12px 14px; + font-size: 13px; + line-height: 1.45; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.12); + opacity: 0; + transform: translateX(20px); + pointer-events: none; + transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); + backdrop-filter: blur(8px); +} + +.app-toast.show { + opacity: 1; + transform: translateX(0); +} + +.app-toast.warn { + border-left-color: var(--warning); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 167, 38, 0.25); +} + +/* ============================================ + STREAMER SECTION COUNTER + ============================================ + Tiny "X · Y live" line next to the "Streamer" section heading. + Updated by renderStreamers on every redraw. */ +.streamer-section-counter { + font-size: 11px; + color: var(--text-secondary); + font-weight: 400; + letter-spacing: 0.2px; +} + +/* Empty-state hint inside the sidebar streamer list (no streamers + added yet). Subtler than the full-page .empty-state — fits the + narrow sidebar context. */ +.streamer-list-empty { + padding: 12px 14px; + margin: 4px 8px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + border: 1px dashed var(--border-soft); + border-radius: 6px; + text-align: center; + background: rgba(255, 255, 255, 0.02); +} + +/* ============================================ + VOD DURATION BADGE — Twitch-style pill on the thumbnail + ============================================ + Sits inside .vod-thumb-wrap so the absolute positioning anchors + to the thumbnail bounds, not the whole card (which would push + the badge past the action buttons at the bottom — regression + reported in 4.6.44 screenshot). */ +.vod-thumb-wrap { + position: relative; + line-height: 0; +} + +.vod-thumb-wrap .vod-thumbnail { + display: block; +} + +.vod-duration-badge { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.78); + color: #fff; + font-size: 11px; + font-weight: 600; + padding: 3px 7px; + border-radius: 3px; + z-index: 1; + letter-spacing: 0.3px; + backdrop-filter: blur(2px); + pointer-events: none; +} + +.vod-card:hover .vod-duration-badge { + background: rgba(0, 0, 0, 0.88); +} + +.vod-card.preview-active .vod-downloaded-badge { + opacity: 0; + transition: opacity 0.2s; +} + +/* ============================================ + CHAT VIEWER — Twitch-chat-like message rows + ============================================ + Replaces the inline-style chat row inside the chat-viewer modal + with proper class-based styling. Renderer still uses inline + per-message colour for the username (driven by Twitch's IRC color + metadata). */ +.chat-viewer-row { + box-sizing: border-box; + display: flex; + align-items: center; + height: 29px; + padding: 4px 8px; + line-height: 1.55; + border-radius: 4px; + transition: background 0.12s; + font-size: 13px; + overflow: hidden; + white-space: nowrap; +} + +.chat-viewer-virtual-canvas { + position: relative; + min-height: 100%; +} + +.chat-viewer-virtual-rows { + position: absolute; + inset: 0 0 auto; + width: 100%; +} + +.chat-viewer-row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.chat-viewer-row[aria-selected="true"], +.event-viewer-row[aria-selected="true"] { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.viewer-detail-modal { + display: none; + position: fixed; + inset: 0; + z-index: 2200; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, 0.66); +} + +.viewer-detail-modal.show { + display: flex; +} + +.viewer-detail-dialog { + width: min(760px, 100%); + max-height: min(680px, 100%); + display: flex; + flex-direction: column; + border: 1px solid var(--border-soft); + border-radius: 10px; + background: var(--bg-card); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); +} + +.viewer-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid var(--border-soft); +} + +.viewer-detail-header h2 { + margin: 0; + font-size: 16px; +} + +.viewer-detail-close { + width: 32px; + height: 32px; + border: 0; + border-radius: 6px; + color: var(--text); + background: transparent; + font-size: 24px; + line-height: 1; +} + +.viewer-detail-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.viewer-detail-text { + overflow: auto; + padding: 18px; + color: var(--text); + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.chat-viewer-row .chat-viewer-time { + color: var(--text-secondary); + margin-right: 8px; + font-size: 10px; + opacity: 0.7; + font-family: 'Segoe UI Mono', 'Consolas', monospace; +} + +.chat-viewer-row .chat-viewer-user { + font-weight: 700; + margin-right: 4px; + color: var(--accent); +} + +.chat-viewer-row > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-viewer-row .chat-viewer-tag { + color: var(--accent); + font-style: italic; + font-size: 11px; + margin-right: 6px; + background: rgba(145, 70, 255, 0.12); + padding: 1px 6px; + border-radius: 3px; + border: 1px solid rgba(145, 70, 255, 0.3); +} + +.chat-viewer-row.is-system { + background: rgba(145, 70, 255, 0.05); + border-left: 2px solid rgba(145, 70, 255, 0.45); + padding-left: 10px; +} + +/* ============================================ + EVENTS VIEWER — timeline rows + ============================================ + Per-event-type colours live here via [data-type] attribute + selectors so the renderer just stamps the type and the CSS + handles the palette. Add a new event type by extending this + block, not the renderer. */ +.event-viewer-row { + box-sizing: border-box; + display: flex; + align-items: center; + height: 36px; + padding: 8px 10px; + border-bottom: 1px solid var(--border-soft); + font-size: 12px; + gap: 8px; + overflow: hidden; + white-space: nowrap; +} + +.event-viewer-virtual-canvas { + position: relative; + min-height: 100%; +} + +.event-viewer-virtual-rows { + position: absolute; + inset: 0 0 auto; + width: 100%; +} + +.event-viewer-row:last-child { + border-bottom: none; +} + +.event-viewer-time { + color: var(--text-secondary); + flex: 0 0 auto; + font-family: 'Consolas', 'Segoe UI Mono', monospace; +} + +/* Empty state inside the events-viewer modal — shown when an events + file exists but contains no parsed entries. */ +.event-viewer-empty { + color: var(--text-secondary); + padding: 12px; + text-align: center; +} + +.event-viewer-tag { + font-weight: 600; + flex: 0 0 auto; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.3px; + font-size: 11px; + padding: 2px 7px; + border-radius: 3px; + background: rgba(145, 70, 255, 0.10); + border: 1px solid rgba(145, 70, 255, 0.25); +} + +.event-viewer-tag[data-type="recording_start"] { + color: #00c853; + background: rgba(0, 200, 83, 0.10); + border-color: rgba(0, 200, 83, 0.30); +} + +.event-viewer-tag[data-type="recording_end"] { + color: #9146ff; + background: rgba(145, 70, 255, 0.10); + border-color: rgba(145, 70, 255, 0.30); +} + +.event-viewer-tag[data-type="recording_resume"] { + color: #2196f3; + background: rgba(33, 150, 243, 0.10); + border-color: rgba(33, 150, 243, 0.30); +} + +.event-viewer-tag[data-type="title_change"] { + color: #ffab00; + background: rgba(255, 171, 0, 0.10); + border-color: rgba(255, 171, 0, 0.30); +} + +.event-viewer-tag[data-type="game_change"] { + color: #ff4444; + background: rgba(255, 68, 68, 0.10); + border-color: rgba(255, 68, 68, 0.30); +} + +.event-viewer-detail { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +/* ============================================ + STREAMER PROFILE HEADER + ============================================ + Polished channel-page-style header that shows up above the VOD grid + when a streamer is selected. Modeled on Twitch's own profile header + for instant familiarity, but trimmed for the desktop-app context. */ +.streamer-profile-header { + position: sticky; + top: -25px; /* negate the .content top padding so the header pins flush with the visible top edge */ + z-index: 100; + display: block; + padding: 0; + margin-top: -2px; + margin-bottom: 14px; + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: 12px; + overflow: hidden; + animation: profile-fade-in 0.32s ease-out; + isolation: isolate; /* new stacking context so VODs below cannot leak above */ + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35); +} + +/* Dimming gradient sits ABOVE the banner-bg but BELOW the content row. + Gives the banner room to breathe while keeping name + bio readable. */ +.streamer-profile-header::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(15, 15, 18, 0.55) 0%, rgba(15, 15, 18, 0.78) 100%); + z-index: 1; + pointer-events: none; +} + +.streamer-profile-row { + position: relative; + z-index: 2; + display: flex; + gap: 18px; + align-items: center; + padding: 18px 22px; +} + +.streamer-profile-banner-bg { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + filter: blur(10px) saturate(1.35); + opacity: 1; + pointer-events: none; + z-index: 0; + transform: scale(1.12); /* hide the blur edge bleed inside the rounded corner clip */ +} + +@keyframes profile-fade-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.streamer-profile-header.is-live::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: 12px; + box-shadow: inset 0 0 0 1px rgba(233, 25, 22, 0.4); +} + +.streamer-profile-avatar-wrap { + position: relative; + flex-shrink: 0; + cursor: pointer; + transition: transform 0.2s; +} + +.streamer-profile-avatar-wrap:hover { + transform: scale(1.04); +} + +.streamer-profile-avatar-wrap:focus-visible { + outline: none; + border-radius: 50%; + box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.55); +} + +.streamer-profile-live-card:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(233, 25, 22, 0.55), 0 6px 22px rgba(233, 25, 22, 0.20); +} + +.streamer-profile-avatar { + width: 88px; + height: 88px; + border-radius: 50%; + object-fit: cover; + background: var(--bg-elevated); + border: 3px solid rgba(145, 70, 255, 0.6); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30); +} + +.streamer-profile-avatar.is-live { + border-color: #e91916; + animation: profile-live-ring 1.6s ease-in-out infinite; +} + +@keyframes profile-live-ring { + 0%, 100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.5), 0 4px 18px rgba(0, 0, 0, 0.30); } + 50% { box-shadow: 0 0 0 8px rgba(233, 25, 22, 0), 0 4px 18px rgba(0, 0, 0, 0.30); } +} + +.streamer-profile-avatar-fallback { + width: 88px; + height: 88px; + border-radius: 50%; + background: linear-gradient(135deg, #9146ff, #00c853); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + font-weight: 700; + border: 3px solid rgba(145, 70, 255, 0.6); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30); +} + +.streamer-profile-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.streamer-profile-name-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.streamer-profile-display-name { + font-size: 22px; + font-weight: 700; + color: var(--text); + line-height: 1.1; + letter-spacing: -0.2px; +} + +.streamer-profile-login { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.streamer-profile-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.streamer-profile-badge.partner { + background: rgba(145, 70, 255, 0.18); + color: #9146ff; + border: 1px solid rgba(145, 70, 255, 0.5); +} + +.streamer-profile-badge.affiliate { + background: rgba(0, 200, 83, 0.15); + color: #00c853; + border: 1px solid rgba(0, 200, 83, 0.45); +} + +.streamer-profile-badge.live { + background: #e91916; + color: #fff; + border: 1px solid #e91916; + animation: profile-live-blink 1.6s ease-in-out infinite; +} + +.streamer-profile-badge.live::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: #fff; +} + +@keyframes profile-live-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.75; } +} + +.streamer-profile-bio { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + margin-top: 2px; +} + +.streamer-profile-live-info { + font-size: 13px; + color: var(--text); + background: rgba(233, 25, 22, 0.08); + border-left: 3px solid #e91916; + padding: 6px 10px; + border-radius: 0 4px 4px 0; + margin-top: 4px; +} + +.streamer-profile-live-info strong { + color: #ff6b6b; + font-weight: 600; +} + +.streamer-profile-stats { + display: flex; + gap: 18px; + flex-wrap: wrap; + margin-top: 6px; +} + +.streamer-profile-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.streamer-profile-stat strong { + color: var(--text); + font-weight: 600; + font-size: 13px; +} + +.streamer-profile-stat svg { + width: 14px; + height: 14px; + opacity: 0.7; +} + +.streamer-profile-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.streamer-profile-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + color: var(--text); + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.18s; + text-decoration: none; + white-space: nowrap; +} + +.streamer-profile-btn:hover { + background: rgba(145, 70, 255, 0.18); + border-color: rgba(145, 70, 255, 0.6); + color: var(--text); + transform: translateY(-1px); +} + +.streamer-profile-btn.primary { + background: #9146ff; + border-color: #9146ff; + color: #fff; +} + +.streamer-profile-btn.primary:hover { + background: #a970ff; + border-color: #a970ff; + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4); +} + +/* Focus-visible for the profile action buttons (Record now, Open on + Twitch, Refresh). Default variant gets a purple ring; the primary + variant already has a purple background so it gets the inner-white + + outer-purple double ring used elsewhere for purple-bg buttons. */ +.streamer-profile-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); + border-color: rgba(145, 70, 255, 0.6); +} + +.streamer-profile-btn.primary:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); +} + +/* Skeleton loading state — switches the profile-header from its + regular block layout to a flex row so the avatar + body sit + side-by-side. The element itself was previously flipped via inline + .style.display='flex' in renderStreamerProfileSkeleton(). */ +.streamer-profile-skeleton { + display: flex; +} + +.streamer-profile-skeleton .streamer-profile-skel-block { + background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%); + background-size: 200% 100%; + animation: profile-skel-shimmer 1.4s linear infinite; + border-radius: 4px; +} + +/* Pre-shaped skeleton block variants — each matches one of the + real-profile-card slots so the loading silhouette previews the + final layout. Replaces inline width/height/border-radius declarations. */ +.streamer-profile-skel-block.avatar { + width: 88px; + height: 88px; + border-radius: 50%; + flex-shrink: 0; +} + +.streamer-profile-skel-block.name { + width: 180px; + height: 24px; +} + +.streamer-profile-skel-block.badge { + width: 90px; + height: 18px; + border-radius: 10px; +} + +.streamer-profile-skel-block.subtitle { + width: 60%; + height: 14px; + margin-top: 6px; +} + +.streamer-profile-skel-stats { + margin-top: 8px; +} + +@keyframes profile-skel-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (max-width: 720px) { + .streamer-profile-row { + flex-direction: column; + align-items: flex-start; + } + .streamer-profile-actions { + flex-direction: row; + width: 100%; + } +} + +/* ============================================ + LIVE PREVIEW CARD — inside the profile header + ============================================ */ +.streamer-profile-live-card { + position: relative; + z-index: 1; + display: flex; + gap: 14px; + margin: 0 14px 14px; + padding: 12px; + background: rgba(233, 25, 22, 0.10); + border: 1px solid rgba(233, 25, 22, 0.5); + border-radius: 10px; + cursor: pointer; + transition: transform 0.18s, box-shadow 0.18s, background 0.18s; + animation: profile-fade-in 0.4s ease-out; +} + +.streamer-profile-live-card:hover { + transform: translateY(-2px); + background: rgba(233, 25, 22, 0.16); + box-shadow: 0 6px 22px rgba(233, 25, 22, 0.20); +} + +.streamer-profile-live-thumb { + width: 240px; + height: 135px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; + background: #000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} + +.streamer-profile-live-thumb-fallback { + width: 240px; + height: 135px; + border-radius: 6px; + flex-shrink: 0; + background: linear-gradient(135deg, #2a0a0a, #1a0606); + display: flex; + align-items: center; + justify-content: center; + color: rgba(233, 25, 22, 0.5); +} + +.streamer-profile-live-thumb-fallback svg { + width: 48px; + height: 48px; +} + +.streamer-profile-live-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; +} + +.streamer-profile-live-badge-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.streamer-profile-live-viewers { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text); + font-weight: 600; +} + +.streamer-profile-live-viewers svg { + width: 14px; + height: 14px; + opacity: 0.85; +} + +.streamer-profile-live-title { + font-size: 16px; + font-weight: 600; + color: var(--text); + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.streamer-profile-live-game { + font-size: 13px; + color: var(--text-secondary); +} + +.streamer-profile-live-rec-btn { + margin-top: 6px; + align-self: flex-start; + background: #e91916 !important; + border-color: #e91916 !important; +} + +.streamer-profile-live-rec-btn:hover { + background: #ff3733 !important; + border-color: #ff3733 !important; + box-shadow: 0 4px 14px rgba(233, 25, 22, 0.4); +} + +@media (max-width: 720px) { + .streamer-profile-live-card { flex-direction: column; } + .streamer-profile-live-thumb, + .streamer-profile-live-thumb-fallback { width: 100%; height: 180px; } +} + +/* ============================================ + VOD HOVER PREVIEW — storyboard sprite cycling + ============================================ + Overlay sits as a direct child of .vod-card, positioned over the + thumbnail's bounding box. Width matches the card; aspect-ratio + 16/9 anchors the height to align with the thumbnail. */ +.vod-storyboard-preview { + /* Position + size werden vollstaendig per JS gesetzt (siehe + renderer-vod-hover.ts). Wir geben hier nur Visual + Stacking. */ + position: absolute; + background-repeat: no-repeat; + opacity: 0; + transition: opacity 0.22s ease-out; + pointer-events: none; + z-index: 2; + border-radius: 8px 8px 0 0; + overflow: hidden; +} + +.vod-card.preview-active .vod-storyboard-preview { + opacity: 1; +} + +.vod-card.preview-active .vod-thumbnail { + filter: brightness(0.92); + transition: filter 0.3s; +} + +.vod-storyboard-preview::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%); + pointer-events: none; +} + +/* ============================================ + REDUCED MOTION — respect OS-level user preference + ============================================ + Users who set "Reduce motion" in their OS accessibility settings + (Windows: Settings > Accessibility > Visual Effects > Animation + effects; macOS: System Settings > Accessibility > Display > Reduce + motion) get animations and transitions effectively disabled. + + Suppresses things like the empty-state-float loop, the btn-icon-spin + on Refresh hover, the vod-bulk-bar slide-in, the storyboard fade-in, + and the multitude of transition: all 0.2s declarations — anything + that involves motion. Critical for users with vestibular disorders + and a baseline accessibility expectation in 2025. */ +/* Generic hide utility. Use when an element's visible-state display + differs (button = inline-block, bulk-bar = flex, etc.) so a single + class can hide any of them without per-element .shown modifiers. + The !important wins over the base class's display declaration. */ +.is-hidden { + display: none !important; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ============================================ + CONTEXT MENU — generic right-click menu base + ============================================ + Used by both the queue row context menu (renderer-queue.ts) and the + VOD card context menu (renderer-streamers.ts). left/top stay inline + on the container (set per-click); everything else lives here. */ +.context-menu { + position: fixed; + z-index: 9999; + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + padding: 4px; + min-width: 200px; +} + +.context-menu-item { + display: block; + width: 100%; + border: 0; + background: transparent; + text-align: left; + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text); + border-radius: 4px; + transition: background 0.12s; +} + +.context-menu-item:hover:not(.disabled) { + background: rgba(145, 70, 255, 0.15); +} + +.context-menu-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.context-menu-item.disabled { + color: var(--text-secondary); + opacity: 0.55; + cursor: not-allowed; +} + +.context-menu-separator { + height: 1px; + margin: 4px 6px; + background: var(--border-soft); +} + +/* Output-row appended to the queue-item detail panel when a job + completed. Lists the file actions (Open file / Show in folder / + View chat / View events) followed by a tiny secondary-colour file + label. */ +.queue-output-row { + display: flex; + gap: 6px; + margin-top: 6px; + flex-wrap: wrap; + align-items: center; +} + +.queue-output-label { + color: var(--text-secondary); + font-size: 11px; + word-break: break-all; +} + +/* Command Palette (Pillar 5 — added in 5.1.0-alpha.1) */ +.command-palette { + max-width: 540px; + width: 90%; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cp-title { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0; +} + +.cp-input { + width: 100%; + padding: 10px 12px; + font-size: 16px; + background: var(--bg-main); + color: var(--text); + border: 1px solid var(--border-soft); + border-radius: 4px; + outline: none; +} + +.cp-input:focus { + border-color: var(--accent); +} + +.cp-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--border-soft); + border-radius: 4px; +} + +.cp-list:empty { + display: none; +} + +.cp-item { + padding: 8px 12px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + color: var(--text); + border-bottom: 1px solid var(--border-soft); +} + +.cp-item:last-child { + border-bottom: none; +} + +.cp-item:hover, +.cp-item.cp-active { + background: var(--accent); + color: #fff; +} + +.cp-item-label { + flex: 1; + font-size: 14px; +} + +.cp-item-hint { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.cp-item:hover .cp-item-hint, +.cp-item.cp-active .cp-item-hint { + color: rgba(255, 255, 255, 0.85); +} + +.cp-hint { + margin: 0; + font-size: 11px; + color: var(--text-secondary); + text-align: right; +} diff --git a/src/styles-workflows.css b/src/styles-workflows.css new file mode 100644 index 0000000..1ced0ad --- /dev/null +++ b/src/styles-workflows.css @@ -0,0 +1,3618 @@ + +/* Queue Section */ +.queue-section { + border-top: 1px solid rgba(255,255,255,0.1); + padding: 15px; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + gap: 8px; +} + +.queue-title { + font-size: 13px; + font-weight: 600; +} + +.queue-count { + background: var(--accent); + color: white; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; +} + +.queue-list { + flex: 1; + overflow-y: auto; + min-height: 60px; +} + +.health-badge { + font-size: 10px; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid transparent; + white-space: nowrap; +} + +.health-badge.good { + background: rgba(0, 200, 83, 0.2); + border-color: rgba(0, 200, 83, 0.45); + color: #93efb9; +} + +.health-badge.warn { + background: rgba(255, 171, 0, 0.2); + border-color: rgba(255, 171, 0, 0.45); + color: #ffd98e; +} + +.health-badge.bad, +.health-badge.unknown { + background: rgba(255, 68, 68, 0.2); + border-color: rgba(255, 68, 68, 0.45); + color: #ffaaaa; +} + +.queue-item { + position: relative; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 8px; + background: var(--bg-card); + border-radius: 6px; + margin-bottom: 6px; + font-size: 12px; + border-left: 3px solid transparent; + transition: border-color 0.2s, background 0.2s; +} + +.queue-item:has(.status.downloading) { + border-left-color: var(--accent); + background: rgba(145, 70, 255, 0.06); +} + +.queue-item:has(.status.paused) { + border-left-color: #4aa3ff; + background: rgba(74, 163, 255, 0.07); +} + +.queue-item:has(.status.error) { + border-left-color: var(--error); +} + +.queue-item:has(.status.completed) { + border-left-color: var(--success); + opacity: 0.85; +} + +.queue-item .status { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-secondary); + flex-shrink: 0; + margin-top: 4px; +} + +.queue-item .status.pending { background: var(--warning); box-shadow: 0 0 6px rgba(255, 167, 38, 0.5); } +.queue-item .status.downloading { background: var(--accent); animation: pulse 1s infinite; box-shadow: 0 0 8px rgba(145, 70, 255, 0.6); } +.queue-item .status.paused { background: #4aa3ff; box-shadow: 0 0 6px rgba(74, 163, 255, 0.55); } +.queue-item .status.completed { background: var(--success); box-shadow: 0 0 6px rgba(0, 200, 83, 0.5); } +.queue-item .status.error { background: var(--error); box-shadow: 0 0 6px rgba(255, 70, 70, 0.5); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.queue-item .title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.queue-detail-label { + color: var(--text-secondary); + font-weight: 500; + margin-right: 4px; +} + +.queue-retry-btn { + background: transparent; + border: 1px solid var(--border-soft); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 8px; + font-size: 14px; + line-height: 1; + align-self: center; + transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.12s; +} + +.queue-retry-btn:hover { + background: rgba(145, 70, 255, 0.18); + border-color: rgba(145, 70, 255, 0.55); + color: #fff; +} + +.queue-retry-btn:active { + transform: scale(0.92); +} + +.queue-retry-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); + border-color: rgba(145, 70, 255, 0.55); +} + +.queue-main { + flex: 1; + min-width: 0; +} + +.queue-title-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 16px; +} + +.queue-status-label { + flex-shrink: 0; + font-size: 10px; + color: var(--text-secondary); + line-height: 16px; +} + +.queue-meta { + font-size: 10px; + color: var(--text-secondary); + margin-top: 2px; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-progress-wrap { + height: 5px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.10); + position: relative; +} + +.queue-progress-bar { + height: 100%; + width: 0; + background: linear-gradient(90deg, #168f4a 0%, var(--success) 100%); + transition: width 0.3s ease; + position: relative; + overflow: hidden; +} + +.queue-progress-bar::after { + content: ''; + display: none; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.35) 50%, transparent 100%); + transform: translateX(-100%); + animation: none; +} + +.status.downloading ~ .queue-main .queue-progress-bar::after { + display: block; + animation: queue-progress-shimmer 1.8s ease-in-out infinite; +} + +@keyframes queue-progress-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.queue-progress-info { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 3px; + font-size: 10px; + color: var(--text-secondary); + line-height: 14px; +} + +.queue-progress-status, +.queue-progress-metrics { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-progress-status.is-starting { + animation: queue-starting-pulse 1.1s ease-in-out infinite; +} + +@keyframes queue-starting-pulse { + 0%, 100% { opacity: 0.62; } + 50% { opacity: 1; } +} + +.queue-progress-metrics { + margin-left: auto; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.queue-item .remove { + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + flex: 0 0 16px; + cursor: pointer; + color: var(--error); + opacity: 0.7; + font-size: 11px; + line-height: 16px; +} + +.queue-item .remove:hover { + opacity: 1; +} + +.queue-item[draggable="true"] { + cursor: grab; +} + +.queue-item[draggable="true"]:active { + cursor: grabbing; +} + +.queue-item.dragging { + opacity: 0.4; +} + +.queue-details { + display: none; + font-size: 10px; + color: var(--text-secondary); + padding: 4px 0; + word-break: break-all; +} + +.queue-details.expanded { + display: block; +} + +.queue-details div { + margin-bottom: 2px; +} + +.queue-selection-order { + position: absolute; + z-index: 2; + top: -5px; + left: -5px; + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border: 2px solid var(--bg-panel); + border-radius: 50%; + background: var(--success); + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + user-select: none; +} + +.queue-item.merge-selected { + box-shadow: inset 0 0 0 1px rgba(0, 200, 83, 0.45); +} + +.queue-item .title:focus-visible { + outline: none; + border-radius: 3px; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45); +} + +.queue-item.merge-group { + border-left: 3px solid var(--accent); +} + +.merge-group-icon { + vertical-align: middle; + margin-right: 2px; + opacity: 0.8; +} + +.btn-merge-group { + background: var(--accent); + color: var(--bg-primary); +} + +.btn-merge-group:hover { + opacity: 0.9; +} + +.queue-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-shrink: 0; +} + +.stats-bar { + padding: 6px 15px; + font-size: 10px; + color: var(--text-secondary); + border-top: 1px solid rgba(255,255,255,0.1); + flex-shrink: 0; +} + +.btn { + flex: 1; + padding: 5px 8px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 12px; + transition: all 0.2s; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn:disabled:hover { + background: inherit; +} + +.btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); +} + +.btn-start:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(0, 200, 83, 0.65); +} + +.btn-start.downloading:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(229, 70, 70, 0.65); +} + +.btn-retry { + background: #2a3344; + color: #d9e4f7; +} + +.btn-retry:hover { + background: #33405a; +} + +.btn-start { + background: var(--success); + color: white; +} + +.btn-start:hover { + background: #00a844; +} + +.btn-start.downloading { + background: var(--error); +} + +.btn-clear { + background: var(--bg-card); + color: var(--text-secondary); +} + +.btn-clear:hover { + background: #2a2a2e; +} + +/* Main Content */ +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + padding: 20px 30px; + border-bottom: 1px solid rgba(255,255,255,0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 22px; + font-weight: 600; +} + +.header-actions { + display: flex; + align-items: center; + gap: 15px; +} + +.header-search { + display: flex; + gap: 8px; +} + +.header-search input { + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: 8px 12px; + color: var(--text); + font-size: 13px; + width: 200px; +} + +.header-search input::placeholder { + color: var(--text-secondary); +} + +.header-search button { + background: var(--accent); + border: none; + border-radius: 6px; + color: white; + padding: 8px 14px; + cursor: pointer; + font-size: 16px; + font-weight: 700; + transition: background 0.18s, transform 0.12s, box-shadow 0.18s; + line-height: 1; +} + +.header-search button:hover { + background: var(--accent-hover); + box-shadow: 0 4px 14px rgba(145, 70, 255, 0.35); +} + +.header-search button:active { + transform: scale(0.94); +} + +.header-search button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); +} + +.header-search input:focus-visible { + outline: none; + border-color: rgba(145, 70, 255, 0.6); + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.35); +} + +.btn-icon { + background: var(--bg-card); + border: 1px solid var(--border-soft); + border-radius: 6px; + color: var(--text); + padding: 8px 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + transition: background 0.18s, border-color 0.18s, transform 0.12s, box-shadow 0.18s; +} + +.btn-icon:hover { + background: rgba(145, 70, 255, 0.12); + border-color: rgba(145, 70, 255, 0.45); + color: #fff; +} + +.btn-icon:hover svg { + animation: btn-icon-spin 0.6s ease-out; +} + +.btn-icon:active { + transform: scale(0.96); +} + +.btn-icon:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); + border-color: rgba(145, 70, 255, 0.55); +} + +@keyframes btn-icon-spin { + from { transform: rotate(0deg); } + to { transform: rotate(180deg); } +} + +.content { + flex: 1; + overflow-y: auto; + padding: 25px 30px; +} + +/* Tabs */ +.tab-content { + display: none; +} + +.tab-content.active { + display: flex; + flex-direction: column; + min-height: 100%; +} + +/* VOD Grid */ +.vod-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + flex: 1; +} + +.vod-grid:has(.empty-state) { + display: flex; + align-items: center; + justify-content: center; +} + +.vod-card { + background: var(--bg-card); + border-radius: 8px; + overflow: hidden; + transition: transform 0.22s ease-out, box-shadow 0.22s ease-out, border-color 0.22s; + cursor: pointer; + position: relative; + border: 1px solid transparent; + /* Flex-Column + stretch (grid default) macht alle Cards einer Reihe + gleich hoch. Die Actions unten kriegen margin-top:auto und docken + damit am Boden an — egal ob der Titel 1 oder 2 Zeilen hat. Vorher + sass der Button bei 1-Zeilen-Titeln hoeher als bei 2-Zeilen-Nachbarn. */ + display: flex; + flex-direction: column; +} + +.vod-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.35); + border-color: rgba(145, 70, 255, 0.35); +} + +.vod-card:focus-visible { + outline: none; + border-color: rgba(145, 70, 255, 0.7); + box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.35); +} + +/* The bulk-select checkbox overlaid on each VOD thumbnail top-left. + Positioned absolutely so it sits over the artwork without affecting + the cards flex/info layout. + WICHTIG: Selektor MUSS hoehere Spezifitaet haben als die globale + `input[type="checkbox"]` Regel (0,0,1,1), sonst gewinnt deren + `position: relative` + `width/height:16px` und die Checkbox wird zum + in-flow Flex-Item -> belegt eine 16px-Reihe oben in der Card und + schiebt das Thumbnail runter (grauer Balken ueber jedem VOD-Bild, + gemeldet in 5.0.14). `input[type="checkbox"].vod-select-checkbox` + = (0,0,2,1) schlaegt die globale Regel sauber. */ +input[type="checkbox"].vod-select-checkbox { + position: absolute; + top: 8px; + left: 8px; + width: 18px; + height: 18px; + cursor: pointer; + z-index: 2; +} + +.vod-card.selected { + box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25); +} + +.vod-downloaded-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 200, 83, 0.92); + color: white; + border-radius: 50%; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + z-index: 2; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.vod-card.already-downloaded .vod-thumbnail { + opacity: 0.6; +} + +#cutterPreview.drag-over { + outline: 2px dashed var(--accent); + outline-offset: -8px; + background: rgba(145, 70, 255, 0.08); +} + +.streamer-item.dragging { + opacity: 0.4; +} + +.streamer-rec { + margin-right: 6px; + color: #ff4444; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + padding: 2px 5px; + border: 1px solid rgba(255, 68, 68, 0.4); + border-radius: 3px; + background: transparent; + transition: background 0.15s; +} + +.streamer-rec:hover { + background: rgba(255, 68, 68, 0.15); +} + +.streamer-auto { + margin-left: auto; + margin-right: 4px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + padding: 2px 5px; + border: 1px solid var(--border-soft); + border-radius: 3px; + background: transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.streamer-auto.active { + color: #00c853; + border-color: rgba(0, 200, 83, 0.45); + background: rgba(0, 200, 83, 0.10); +} + +.streamer-auto:hover { + background: rgba(0, 200, 83, 0.18); + color: #00c853; +} + +.streamer-vod { + margin-right: 4px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + padding: 2px 5px; + border: 1px solid var(--border-soft); + border-radius: 3px; + background: transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.streamer-vod.active { + color: #2196f3; + border-color: rgba(33, 150, 243, 0.45); + background: rgba(33, 150, 243, 0.10); +} + +.streamer-vod:hover { + background: rgba(33, 150, 243, 0.18); + color: #2196f3; +} + +.queue-health-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + box-shadow: 0 0 4px currentColor; +} + +.queue-health-dot.health-ok { + background: #00c853; + color: #00c853; + animation: queue-health-pulse 2s ease-in-out infinite; +} + +.queue-health-dot.health-stale { + background: #ffab00; + color: #ffab00; + animation: queue-health-flash 1s ease-in-out infinite; +} + +.queue-health-dot.health-unknown { + background: var(--text-secondary); + color: var(--text-secondary); + box-shadow: none; +} + +@keyframes queue-health-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +@keyframes queue-health-flash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.queue-live-badge { + display: inline-block; + background: #ff4444; + color: white; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 1px 5px; + border-radius: 3px; + vertical-align: middle; + animation: queue-live-pulse 1.5s ease-in-out infinite; +} + +@keyframes queue-live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.vod-thumbnail { + width: 100%; + aspect-ratio: 16/9; + background: #333; + object-fit: cover; +} + +.vod-info { + padding: 12px 15px; +} + +.vod-title { + font-weight: 600; + font-size: 14px; + margin-bottom: 6px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; +} + +.vod-meta { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +.vod-actions { + padding: 10px 15px 15px; + display: flex; + gap: 8px; + /* Dockt am Card-Boden an, sodass Trim/Queue-Buttons ueber alle Cards + einer Reihe auf gleicher Hoehe liegen — unabhaengig von Titel-Zeilen. */ + margin-top: auto; +} + +.vod-btn { + flex: 1; + padding: 8px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-size: 12px; + transition: all 0.2s; +} + +.vod-btn.primary { + background: #1f7a43; + color: white; +} + +.vod-btn.primary:hover { + background: #186638; +} + +.vod-btn.secondary { + background: rgba(255,255,255,0.1); + color: var(--text); +} + +.vod-btn.secondary:hover { + background: rgba(255,255,255,0.15); +} + +/* Focus-visible for the per-card action buttons (Trim, Queue, etc.). The + primary variant already has a purple background — use the inner-white + + outer-purple double ring so the focus indicator stays visible + against the button's own colour. */ +.vod-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); +} + +.vod-btn.primary:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(31, 122, 67, 0.65); +} + +/* Settings */ +.settings-card { + background: var(--bg-card); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} + +/* Centred-narrow settings card — used for the standalone Clips Info + card where the content (a short list of supported URL formats) reads + better at a constrained width than across the full main column. */ +.settings-card.centered { + max-width: 600px; + margin: 20px auto; +} + +.settings-card h3 { + font-size: 16px; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 8px; +} + +/* Subsection heading inside a settings card — used when a single card + bundles two logical groups (Storage → Auto-Cleanup) and the second + needs its own miniature heading after a divider. */ +.settings-card h4 { + margin: 0 0 8px 0; + font-size: 14px; +} + +/* Horizontal divider inside settings cards — soft single line, balanced + vertical breathing room, no default browser shading. */ +.settings-card hr { + border: none; + border-top: 1px solid var(--border-soft); + margin: 16px 0; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-group input:not([type="checkbox"]):not([type="radio"]), +.form-group select, +.form-stack input:not([type="checkbox"]):not([type="radio"]), +.form-stack select { + width: 100%; + /* background-color (nicht background shorthand) — sonst wuerden + background-image (Chevron-SVG), background-repeat, background-size + und background-position aus der globalen `select`-Regel resettet, + was zu tiled Chevrons im Dropdown gefuehrt hat. */ + background-color: var(--bg-main); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 10px 12px; + color: var(--text); + font-size: 14px; +} + +.form-group input:not([type="checkbox"]):not([type="radio"]):focus, +.form-group select:focus, +.form-stack input:not([type="checkbox"]):not([type="radio"]):focus, +.form-stack select:focus { + outline: none; + border-color: var(--accent); +} + +.form-group input:not([type="checkbox"]):not([type="radio"]):disabled, +.form-group select:disabled, +.form-stack input:not([type="checkbox"]):not([type="radio"]):disabled, +.form-stack select:disabled { + opacity: 0.55; + cursor: not-allowed; + color: rgba(239, 239, 241, 0.7); +} + +.input-disabled { + opacity: 0.65; +} + +.form-group input[type="checkbox"], +.form-group input[type="radio"] { + /* width:auto wuerde Checkbox auf 0/1px kollabieren, weil + appearance:none + kein Content. Wir wollen die 16x16 aus der + globalen Regel — daher explicit width:16px hier nochmal, damit + die Klassen-Specificity nicht den globalen Wert ueberschreibt. */ + width: 16px; +} + +.language-picker { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.lang-option { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 6px; + background: var(--bg-main); + color: var(--text); + padding: 9px 10px; + cursor: pointer; + font-size: 13px; +} + +.lang-option:hover { + border-color: rgba(255,255,255,0.26); +} + +.lang-option:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); +} + +.lang-option.active { + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(145, 70, 255, 0.2); +} + +/* Active + focused — combine the pressed-state border with the + thicker focus halo so keyboard users still see which one was + focused even when it's also the currently-selected language. */ +.lang-option.active:focus-visible { + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); +} + +.flag-icon { + width: 16px; + height: 12px; + border-radius: 2px; + border: 1px solid rgba(0,0,0,0.35); + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.flag-de { + background: linear-gradient(to bottom, #111 0 33.33%, #dd0000 33.33% 66.66%, #ffce00 66.66% 100%); +} + +.flag-en { + width: 18px; + background: none; + opacity: 1; +} + +.form-row { + display: flex; + gap: 10px; +} + +/* Settings-card header row: card title + right-aligned refresh button. + Used by System-Check, Storage and similar cards where an h3 lives in + a form-row with a button pinned to the far right. The descendant h3 + margin reset kills the inline style="margin:0" that those headings + used to carry. */ +.form-row.section-header { + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.form-row.section-header h3 { + margin: 0; +} + +/* Right-side action cluster inside a section-header — keeps a label + and a button (or two) together as a single unit so the section-header + parent's justify-content:space-between can pin the cluster to the + right while the h3 stays at the left. */ +.section-header-actions { + display: flex; + gap: 8px; + align-items: center; +} + +/* Plain centred form-row with bottom margin — the most common + form-row shape in Settings (button + button + inline-toggle, or + number-input + sublabel). Replaces three duplicated inline copies + of the same align-items:center; margin-bottom:10px declaration. */ +.form-row.aligned { + align-items: center; + margin-bottom: 10px; +} + +/* Search/filter tool-row variant — wraps on narrow widths so the + select / input cluster collapses gracefully. Used by the Archive + search row (input + 3 selects + button). */ +.form-row.search-bar { + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; + align-items: center; +} + +.log-panel { + background: #11151c; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + padding: 10px; + max-height: 220px; + overflow: auto; + white-space: pre-wrap; + color: #b8c7df; + font-size: 12px; + line-height: 1.35; +} + +.form-row input { + flex: 1; +} + +.btn-primary { + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + padding: 10px 20px; + cursor: pointer; + font-weight: 600; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-primary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); +} + +.btn-primary:disabled { + background: var(--text-secondary); + cursor: not-allowed; +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 10px 20px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.22); +} + +.btn-secondary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); +} + +.btn-secondary:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* ============================================ + COMPACT / UTILITY BUTTONS + ============================================ + .btn-pill — small action buttons used in toolbars + bulk-bars. + Comes in default (transparent), primary (purple), success (green). + Replaces the inline-style blocks the renderer was rolling for each + bulk action button. */ +.btn-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s; + line-height: 1.2; +} + +.btn-pill:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-color: rgba(255, 255, 255, 0.18); +} + +.btn-pill:active:not(:disabled) { + transform: translateY(1px); +} + +.btn-pill:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); +} + +.btn-pill.primary:focus-visible, +.btn-pill.success:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); +} + +.btn-pill.danger:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6); +} + +.btn-pill:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-pill.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); + font-weight: 600; +} + +.btn-pill.primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); + color: #fff; + box-shadow: 0 4px 14px rgba(145, 70, 255, 0.35); +} + +.btn-pill.success { + background: #00c853; + color: #fff; + border-color: #00c853; + font-weight: 600; +} + +.btn-pill.success:hover:not(:disabled) { + background: #00e676; + border-color: #00e676; + box-shadow: 0 4px 14px rgba(0, 200, 83, 0.35); +} + +.btn-pill.danger { + background: transparent; + color: #ff6b6b; + border-color: rgba(255, 107, 107, 0.4); +} + +.btn-pill.danger:hover:not(:disabled) { + background: rgba(255, 107, 107, 0.12); + border-color: rgba(255, 107, 107, 0.7); + color: #ff8a8a; +} + +/* .btn-close — square X-close button for filter clears, inline removals. + Renamed from .btn-icon to avoid clashing with the existing top-bar + icon+text button class that's used for Refresh. */ +.btn-close { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: 6px 10px; + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background 0.15s, color 0.15s, border-color 0.15s; + line-height: 1; +} + +.btn-close:hover:not(:disabled) { + background: rgba(255, 70, 70, 0.10); + border-color: rgba(255, 70, 70, 0.45); + color: #ff6b6b; +} + +.btn-close:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6); +} + +/* .queue-detail-btn — tiny chip-style action button used in queue item + detail rows AND in the archive search results list. Was previously + rendering with browser defaults (gray flat button). */ +.queue-detail-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(145, 70, 255, 0.10); + color: var(--text); + border: 1px solid rgba(145, 70, 255, 0.30); + border-radius: 5px; + padding: 4px 10px; + margin-right: 6px; + margin-bottom: 4px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.12s; +} + +.queue-detail-btn:hover { + background: rgba(145, 70, 255, 0.22); + border-color: rgba(145, 70, 255, 0.6); + color: #fff; + transform: translateY(-1px); +} + +.queue-detail-btn:active { + transform: translateY(0); +} + +.queue-detail-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.7); +} + +/* Clips */ +.clip-input { + max-width: 600px; + margin: 0 auto; + text-align: center; + padding: 40px 20px; +} + +.clip-input h2 { + margin-bottom: 20px; +} + +.clip-input input { + width: 100%; + background: var(--bg-card); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 12px 15px; + color: var(--text); + font-size: 14px; + margin-bottom: 15px; +} + +.clip-status { + margin-top: 15px; + font-size: 14px; +} + +.clip-status.success { color: var(--success); } +.clip-status.error { color: var(--error); } +.clip-status.loading { color: var(--warning); } + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + min-height: 60vh; + padding: 20px; + color: var(--text-secondary); +} + +.empty-state svg { + width: 80px; + height: 80px; + margin-bottom: 18px; + opacity: 0.45; + color: var(--accent); + animation: empty-state-float 4s ease-in-out infinite; +} + +@keyframes empty-state-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +.empty-state h3 { + margin-bottom: 10px; + color: var(--text); + font-size: 18px; + font-weight: 600; +} + +.empty-state p { + max-width: 380px; + line-height: 1.5; + font-size: 13px; +} + +/* Status Bar */ +.status-bar { + padding: 10px 30px; + background: var(--bg-sidebar); + border-top: 1px solid rgba(255,255,255,0.1); + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-secondary); +} + +.status-dot.connected { background: var(--success); } +.status-dot.error { background: var(--error); } + +.status-bar-queue-summary { + color: var(--text-secondary); + font-size: 12px; + margin-left: auto; + padding-right: 12px; + font-variant-numeric: tabular-nums; +} + +.status-bar-version { + color: var(--text-secondary); + font-size: 12px; + opacity: 0.7; +} + +/* ============================================ + STORAGE STATS TABLE — Settings page disk usage + ============================================ */ +.storage-stats-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.storage-stats-table th { + text-align: left; + padding: 6px 8px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-soft); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.4px; + font-size: 10px; +} + +.storage-stats-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--border-soft); + font-variant-numeric: tabular-nums; +} + +.storage-stats-table tbody tr { + transition: background 0.12s; +} + +.storage-stats-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.storage-stats-table tbody tr:last-child td { + border-bottom: none; +} + +.storage-stats-section { + color: var(--text-secondary); + font-size: 12px; + margin: 14px 0 4px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +/* ============================================ + FORM UTILITY CLASSES — small recurring patterns + ============================================ + These replace the 6+ inline-style copies of the same visual + pattern that were scattered across Settings cards. */ + +/* Small secondary-coloured label / note text. Used as field-label + above stacked inputs, as inline metadata next to controls, etc. */ +.form-sublabel { + font-size: 12px; + color: var(--text-secondary); +} + +/* Vertical stack: label on top, control below, equal flex share in + a flex-row. Used for the 3-up auto-cleanup row + poll-config rows. */ +.form-stack { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +/* Min-width sizing modifiers — let the row wrap to a new line before + the stack collapses below the named breakpoint. Replaces three inline + min-width declarations in the Auto-Cleanup 3-up row. */ +.form-stack.size-sm { + min-width: 120px; +} + +.form-stack.size-md { + min-width: 160px; +} + +/* Compact-width input — used for the Auto-VOD poll/age inputs where + the values are 2-3 digits and a full-width input would look odd + alongside their inline sublabels. Stylt sich selbst mit dark-theme + weil es direkt in einer .form-row sitzt (kein .form-group / .form-stack + Wrapper, der das styling sonst beistellt). */ +.input-narrow { + width: 90px; + background-color: var(--bg-main); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 8px 10px; + color: var(--text); + font-size: 14px; +} + +.input-narrow:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18); +} + +.input-narrow:hover:not(:focus):not(:disabled) { + border-color: rgba(145, 70, 255, 0.45); +} + +.input-narrow:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* Block-level note text — same colour as .form-sublabel but reserved + for full-row paragraphs like the cleanup report area. */ +.form-note { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} + +/* Card intro paragraph — the descriptive paragraph that sits below a + card heading and explains what the card does. Used identically on + the Archive, API-help, Storage, Cleanup, Discord, Auto-VOD and + Backup cards (was 7 duplicated inline style attributes). */ +.card-intro { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; + margin-bottom: 12px; +} + +/* Inline link inside a card intro — picks up the accent colour so it + reads as actionable text rather than the default browser blue. The + underline + pointer cursor come from the browser's defaults. */ +.card-intro a { + color: var(--accent); +} + +/* Multi-line info text — preserves authored line breaks (white-space: + pre-line) so the Clips card can list URL formats one-per-line in + the HTML without separate
/
  • markup. */ +.info-text { + color: var(--text-secondary); + line-height: 1.6; + white-space: pre-line; +} + +/* Responsive KPI grid for the Stats Summary card — fits as many 180px + tiles per row as the column allows, with equal-share growth. */ +.stats-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +/* Flush variant: the intro sits flush against the next sibling block + (e.g. the stats summary grid) and gets its top breathing room from + the preceding section-header row rather than its own bottom margin. */ +.card-intro.flush { + margin-top: 8px; + margin-bottom: 0; +} + +/* Filename-templates 3-pair grid (VOD / Part / Clip template inputs). + Each row is a label above an input; the label gets the 13px secondary + styling that used to be inline on every label. */ +.filename-template-grid { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.filename-template-grid label { + font-size: 13px; + color: var(--text-secondary); +} + +.filename-template-grid label:not(:first-child) { + margin-top: 4px; +} + +/* Settings toggle row — label wraps an input[type=checkbox] + span. + Used 17 times across the Settings cards. Adjacent-sibling + combinator adds the gap between consecutive toggle rows so the + inline `margin-top: 8px` repeats are no longer needed. */ +.toggle-row { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.toggle-row + .toggle-row { + margin-top: 8px; +} + +/* Indented sub-toggle — kept by the renderer for visual nesting + under a parent toggle (delete-parts-after-merge under + auto-merge-parts, for example). */ +.toggle-row.indented { + margin-left: 22px; +} + +/* Compact horizontal-row toggle — used in filter rows where the + toggle sits alongside other controls (Hide downloaded, etc). + Tighter gap + secondary colour + tiny font to fit a tool-row + without dominating it. */ +.inline-toggle { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + user-select: none; +} + +/* Filename-template lint badge — used both by the Settings card's + template inputs and by the clip-cutter modal's custom template + row. Two states: green for OK, red for unknown-placeholder + warning. Pull the colours from --success / --error vars so the + lint always tracks the rest of the apps semantic palette. + + margin-top is part of the class so both usage sites pick up the + same rhythm — the previous inline-style values diverged by 2px + between the two spots, an inconsistency that's not worth tracking. */ +.template-lint { + font-size: 12px; + line-height: 1.4; + margin-top: 6px; + transition: color 0.15s; +} + +.template-lint.ok { + color: var(--success); +} + +.template-lint.warn { + color: var(--error); +} + +/* Sidebar queue empty state — small dashed-border card matching the + sibling streamer-list empty state. */ +.queue-empty { + color: var(--text-secondary); + font-size: 12px; + text-align: center; + padding: 14px; + border: 1px dashed var(--border-soft); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + margin: 4px 0; + line-height: 1.4; +} + +/* Merge-tab empty state — uses the global .empty-state base and adds + its own padding override since the merge file-list container sits + inside a settings-card with its own padding. */ +.merge-empty-state { + padding: 40px 20px; +} + +.merge-empty-state svg { + opacity: 0.3; + width: 48px; + height: 48px; +} + +.merge-empty-state p { + margin-top: 10px; +} + +/* ============================================ + ARCHIVE SEARCH RESULTS — row layout + ============================================ + Replaces ~10 inline-styled divs in renderer-archive's row template + with reusable classes. Hover background scoped to the row so the + list scans as a real interactive list. */ +.archive-no-matches { + color: var(--text-secondary); + padding: 12px; +} + +.archive-result-row { + display: flex; + padding: 10px 8px; + border-bottom: 1px solid var(--border-soft); + gap: 10px; + align-items: center; + transition: background 0.12s; +} + +.archive-result-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.archive-result-row:last-child { + border-bottom: none; +} + +.archive-result-body { + flex: 1; + min-width: 0; +} + +.archive-result-meta { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.archive-result-streamer { + color: var(--text); +} + +.archive-result-date { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.archive-result-filename { + font-size: 13px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.archive-result-size { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; + font-variant-numeric: tabular-nums; +} + +.archive-result-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +/* Type pill — LIVE / VOD chip in the archive row's meta line. */ +.archive-type-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 3px; + letter-spacing: 0.3px; +} + +.archive-type-badge.live { + background: rgba(255, 68, 68, 0.18); + color: #ff4444; +} + +.archive-type-badge.vod { + background: rgba(145, 70, 255, 0.18); + color: #9146ff; +} + +/* ============================================ + STATS DASHBOARD KPI CARDS + ============================================ + Six-tile overview grid at the top of the Statistik tab. Each card + shows a label (uppercase track), a big value, and an optional + secondary line (e.g. byte-size total under the count). */ +.stats-kpi-card { + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: 12px; + transition: border-color 0.18s, transform 0.18s; +} + +.stats-kpi-card:hover { + border-color: rgba(145, 70, 255, 0.4); + transform: translateY(-1px); +} + +.stats-kpi-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stats-kpi-value { + font-size: 22px; + font-weight: 600; + margin-top: 4px; + font-variant-numeric: tabular-nums; +} + +.stats-kpi-sub { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; + font-variant-numeric: tabular-nums; +} + +.stats-no-root { + grid-column: 1 / -1; + color: var(--text-secondary); +} + +/* Top-streamers bar list — one row per streamer, label row above a + purple-to-green gradient bar. Live/VOD breakdown labels sit + overlaid on top of the bar for a compact two-column read. */ +.stats-top-row { + margin-bottom: 10px; +} + +.stats-top-row:last-child { + margin-bottom: 0; +} + +.stats-top-meta { + display: flex; + justify-content: space-between; + font-size: 13px; + margin-bottom: 4px; + gap: 8px; +} + +.stats-top-meta-sub { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.stats-top-share { + opacity: 0.7; +} + +.stats-top-bar-track { + background: var(--bg-elevated); + border-radius: 3px; + height: 18px; + overflow: hidden; + position: relative; +} + +.stats-top-bar-fill { + height: 100%; + background: linear-gradient(90deg, #9146ff 0%, #00c853 100%); + transition: width 0.4s ease-out; +} + +.stats-top-bar-labels { + position: absolute; + top: 0; + left: 8px; + right: 8px; + height: 100%; + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + color: rgba(255, 255, 255, 0.92); + font-weight: 600; + letter-spacing: 0.3px; + pointer-events: none; +} + +/* 30-day activity chart — vertical bar per day with optional date + label below every 7th column. */ +.stats-activity-row { + display: flex; + gap: 2px; + align-items: flex-end; + padding: 6px 0; +} + +.stats-day-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 0; +} + +.stats-day-bar-track { + width: 100%; + height: 90px; + display: flex; + align-items: flex-end; +} + +.stats-day-bar-fill { + width: 100%; + background: var(--accent, #9146ff); + border-radius: 2px 2px 0 0; + transition: height 0.3s ease-out, background 0.2s; +} + +.stats-day-bar-fill:hover { + background: var(--accent-hover, #b97aff); +} + +.stats-day-label { + font-size: 9px; + color: var(--text-secondary); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.stats-activity-summary { + font-size: 12px; + color: var(--text-secondary); + margin-top: 6px; + font-variant-numeric: tabular-nums; +} + +/* Recording-size distribution buckets — one row per size bucket, + count + total bytes on the right, horizontal bar below. */ +.stats-bucket-row { + margin-bottom: 8px; +} + +.stats-bucket-row:last-child { + margin-bottom: 0; +} + +.stats-bucket-meta { + display: flex; + justify-content: space-between; + font-size: 13px; + margin-bottom: 3px; + gap: 8px; +} + +.stats-bucket-meta-sub { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.stats-bucket-bar-track { + background: var(--bg-elevated); + border-radius: 3px; + height: 12px; + overflow: hidden; +} + +.stats-bucket-bar-fill { + height: 100%; + background: var(--accent, #9146ff); + transition: width 0.4s ease-out; +} + +/* Old generic scrollbar rules were dead — superseded by the + purple-themed *::-webkit-scrollbar block further down the file. + Removed to avoid confusion when someone greps for scrollbar styles. */ + +/* Update Banner */ +.update-banner { + background: linear-gradient(90deg, var(--accent), #5a2d82); + padding: 10px 20px; + display: none; + justify-content: center; + align-items: center; + gap: 15px; + font-size: 13px; +} + +.update-banner.show { + display: flex; +} + +.update-banner button { + background: white; + color: var(--accent); + border: none; + border-radius: 4px; + padding: 6px 15px; + cursor: pointer; + font-weight: 600; +} + +.update-banner button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* Update-banner download progress — sits between the message and + the button, fills as the update download runs. */ +.update-banner-progress-wrap { + flex: 1; + margin: 0 15px; +} + +.update-banner-progress-track { + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + height: 8px; + overflow: hidden; +} + +.update-banner-progress-bar { + background: #fff; + height: 100%; + width: 0%; + transition: width 0.3s ease-out; +} + +.update-modal { + max-width: 680px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(145, 70, 255, 0.18) 0%, rgba(145, 70, 255, 0.05) 24%, rgba(14, 14, 16, 0.98) 100%), + var(--bg-card); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.48); +} + +.update-modal-eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(145, 70, 255, 0.16); + color: #f1e7ff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 14px; +} + +.update-modal-message { + color: var(--text); + line-height: 1.6; + margin: -8px 0 12px; +} + +.update-modal-meta { + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 16px; +} + +.update-modal-actions { + justify-content: flex-end; +} + +.update-changelog-card { + margin-top: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(7, 7, 10, 0.42); + overflow: hidden; +} + +.update-changelog-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.update-changelog-label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.update-changelog-toggle { + background: transparent; + border: none; + color: #f3ecff; + cursor: pointer; + font-size: 13px; + font-weight: 600; +} + +.update-changelog-toggle:hover { + color: white; +} + +.update-changelog-panel { + display: grid; + grid-template-rows: 0fr; + max-height: 320px; + overflow: hidden; + padding: 0 14px; + opacity: 0; + transform: translateY(-4px); + transition: grid-template-rows 440ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 440ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 320ms ease, transform 440ms cubic-bezier(0.22, 0.76, 0.22, 1); +} + +.update-changelog-panel.is-expanded { + grid-template-rows: 1fr; + padding: 14px; + opacity: 1; + transform: translateY(0); +} + +.update-changelog-panel-inner { + min-height: 0; + overflow: hidden; +} + +.update-changelog-panel.is-expanded .update-changelog-panel-inner { + overflow: auto; +} + +.update-changelog-content { + display: grid; + gap: 12px; +} + +.update-changelog-heading { + font-size: 17px; + line-height: 1.25; + color: #ffffff; + margin: 0; +} + +.update-changelog-paragraph { + margin: 0; + color: var(--text); + line-height: 1.6; +} + +.update-changelog-list { + margin: 0; + padding-left: 18px; + color: var(--text); + display: grid; + gap: 8px; +} + +.update-changelog-list li { + line-height: 1.5; +} + +.update-changelog-content strong { + color: #ffffff; + font-weight: 700; +} + +.update-changelog-empty { + margin: 0; + color: var(--text-secondary); + font-size: 13px; +} + +#updateProgressBar.downloading { + width: 30% !important; + animation: indeterminate 1.5s ease-in-out infinite; +} + +@keyframes indeterminate { + 0% { margin-left: 0; width: 30%; } + 50% { margin-left: 35%; width: 30%; } + 100% { margin-left: 70%; width: 30%; } +} + +.cutter-container { + width: 100%; + max-width: 1600px; + margin: 0 auto; + display: grid; + gap: 12px; +} + +#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) { + display: none; +} + +@media (min-width: 1181px) and (min-height: 680px) { + #cutterTab { + overflow-y: hidden; + } + + #cutterTab .cutter-container { + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + } + + #cutterTab .cutter-container:has(.cutter-workspace.shown) { + grid-template-rows: minmax(0, 1fr) auto; + } + + #cutterTab .cutter-container:has(.cutter-workspace.shown):has(> .cutter-recovery-panel:not([hidden])) { + grid-template-rows: auto minmax(0, 1fr) auto; + } + + #cutterTab .cutter-workspace { + min-height: 0; + } + + #cutterTab .cutter-workspace:not(.shown) { + align-items: center; + } + + #cutterTab .cutter-workspace:not(.shown) .cutter-preview-panel { + width: min(1200px, 100%); + height: auto; + grid-template-rows: auto; + } + + #cutterTab .cutter-workspace:not(.shown) .video-preview { + height: auto; + aspect-ratio: 16 / 9; + } + + #cutterTab .cutter-sidebar, + #cutterTab .cutter-preview-panel { + min-height: 0; + overflow: hidden; + } + + #cutterTab .video-preview { + width: 100%; + height: 100%; + aspect-ratio: auto; + } + + #cutterTab .timeline { + height: 132px; + } + + #cutterTab .cutter-ruler { + height: 24px; + } + + #cutterTab .cutter-track, + #cutterTab .cutter-audio-track { + height: 54px; + } + + #cutterTab .timeline-selection, + #cutterTab .cutter-outside-shade { + top: 24px; + } + + #cutterTab .cutter-cut-overlays { + inset: 24px 0 0; + } +} + +.cutter-source-bar { + min-height: 60px; + padding: 10px 12px; + display: flex; + align-items: center; + gap: 12px; + background: var(--workspace-panel, var(--bg-card)); + border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); + border-radius: 8px; +} + +.cutter-source-copy { + min-width: 0; + flex: 1; + display: grid; + gap: 4px; +} + +.cutter-source-title, +.cutter-card-title { + color: var(--workspace-text, var(--text)); + font-size: 12px; + font-weight: 700; +} + +#cutterFilePath { + width: 100%; + padding: 0; + overflow: hidden; + color: var(--workspace-text-muted, var(--text-secondary)); + background: transparent; + border: 0; + outline: 0; + text-overflow: ellipsis; +} + +.cutter-recovery-panel { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + padding: 8px 10px; + color: var(--workspace-text, var(--text)); + background: var(--workspace-control, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); + border-radius: 8px; + font-size: 12px; +} + +.cutter-recovery-panel span { + flex: 1; +} + +.cutter-workspace { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 12px; + min-height: 420px; +} + +.cutter-workspace:not(.shown) { + grid-template-columns: minmax(0, 1fr); +} + +.cutter-workspace:not(.shown) .cutter-sidebar { + display: none; +} + +.cutter-workspace:not(.shown) .cutter-preview-panel { + width: min(1200px, 100%); + margin: 0 auto; +} + +.cutter-workspace:not(.shown) ~ .cutter-actions { + display: none; +} + +.cutter-sidebar, +.cutter-preview-panel { + min-width: 0; + background: var(--workspace-panel, var(--bg-card)); + border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); + border-radius: 8px; +} + +.cutter-sidebar { + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.cutter-sidebar-heading, +.cutter-card-title-row, +.cutter-timeline-toolbar, +.cutter-player-controls, +.cutter-cut-row-heading { + display: flex; + align-items: center; +} + +.cutter-sidebar-heading, +.cutter-card-title-row { + justify-content: space-between; +} + +.cutter-sidebar-heading h3 { + margin: 2px 0 0; + color: var(--workspace-text, var(--text)); + font-size: 16px; +} + +.cutter-eyebrow { + color: var(--workspace-primary, var(--accent)); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.14em; +} + +.cutter-icon-button, +.cutter-player-button, +.cutter-cut-remove, +.timeline-handle, +.cutter-cut-handle { + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--workspace-text, var(--text)); + background: transparent; + border: 0; +} + +.cutter-icon-button { + width: 32px; + height: 32px; + border-radius: 6px; +} + +.cutter-icon-button:hover:not(:disabled) { + background: var(--workspace-control-hover, rgba(255, 255, 255, 0.08)); +} + +.cutter-icon-button svg, +.cutter-player-button svg, +.cutter-cut-remove svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.cutter-player-button .cutter-filled-icon { + fill: currentColor; + stroke: none; +} + +.cutter-play-icon, +.cutter-pause-icon { + fill: currentColor !important; + stroke: none !important; +} + +.cutter-preview-toggle { + padding: 10px; + display: flex; + align-items: center; + gap: 10px; + background: var(--workspace-control, rgba(255, 255, 255, 0.04)); + border-radius: 7px; + cursor: pointer; +} + +.cutter-preview-toggle > span:first-child { + min-width: 0; + flex: 1; + display: grid; + gap: 2px; +} + +.cutter-preview-toggle strong { + font-size: 12px; +} + +.cutter-preview-toggle small { + color: var(--workspace-text-muted, var(--text-secondary)); + font-size: 10px; +} + +.cutter-preview-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.cutter-toggle-track { + width: 34px; + height: 18px; + position: relative; + flex: 0 0 auto; + background: var(--workspace-border-strong, rgba(255, 255, 255, 0.18)); + border-radius: 999px; + transition: background 160ms ease; +} + +.cutter-toggle-track::after { + content: ''; + width: 14px; + height: 14px; + position: absolute; + top: 2px; + left: 2px; + background: #fff; + border-radius: 50%; + transition: transform 180ms cubic-bezier(.2, .8, .2, 1); +} + +.cutter-preview-toggle input:checked + .cutter-toggle-track { + background: var(--workspace-primary, var(--accent)); +} + +.cutter-preview-toggle input:focus-visible + .cutter-toggle-track { + outline: 2px solid var(--workspace-primary, var(--accent)); + outline-offset: 3px; +} + +.cutter-preview-toggle input:checked + .cutter-toggle-track::after { + transform: translateX(16px); +} + +.cutter-export-options { + padding: 11px; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + background: var(--workspace-control, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); + border-radius: 7px; +} + +.cutter-export-options label { + color: var(--workspace-text-muted, var(--text-secondary)); + font-size: 11px; +} + +.cutter-export-options select { + min-width: 0; +} + +.cutter-trim-card, +.cutter-cut-section { + padding: 11px; + display: grid; + gap: 9px; + background: var(--workspace-control, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); + border-radius: 7px; +} + +.cutter-cut-section { + min-height: 0; + flex: 1; + grid-template-rows: auto minmax(0, 1fr); +} + +.cutter-time-field-row { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.cutter-time-field-row label { + color: var(--workspace-text-muted, var(--text-secondary)); + font-size: 11px; +} + +.cutter-time-field-row input, +.cutter-cut-fields input { + min-width: 0; + padding: 7px 8px; + color: var(--workspace-text, var(--text)); + background: var(--workspace-panel, var(--bg-card)); + border: 1px solid var(--workspace-border-strong, rgba(255, 255, 255, 0.14)); + border-radius: 5px; + font-family: Consolas, monospace; + font-size: 11px; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; + line-height: 14px; + text-align: center; +} + +.cutter-cut-count { + min-width: 21px; + height: 21px; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + background: #d84a52; + border-radius: 999px; + font-size: 10px; + font-weight: 800; +} + +.cutter-cut-list { + min-height: 0; + overflow: auto; + display: grid; + align-content: start; + gap: 7px; +} + +.cutter-cut-empty { + padding: 20px 8px; + color: var(--workspace-text-muted, var(--text-secondary)); + font-size: 11px; + text-align: center; +} + +.cutter-cut-row { + padding: 7px; + display: grid; + gap: 7px; + background: var(--workspace-panel, var(--bg-card)); + border: 1px solid transparent; + border-radius: 6px; +} + +.cutter-cut-row.active { + border-color: #e05a62; + box-shadow: 0 0 0 1px rgba(224, 90, 98, 0.15); +} + +.cutter-cut-row-heading { + width: 100%; + padding: 0; + gap: 7px; + color: var(--workspace-text, var(--text)); + background: transparent; + border: 0; + text-align: left; +} + +.cutter-cut-row-heading strong { + flex: 1; + font-size: 11px; +} + +.cutter-cut-row-heading > span:last-child { + color: var(--workspace-text-muted, var(--text-secondary)); + font-family: Consolas, monospace; + font-size: 9px; +} + +.cutter-cut-color { + width: 7px; + height: 7px; + background: #dc4d56; + border-radius: 50%; +} + +.cutter-cut-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 24px; + gap: 5px; +} + +.cutter-cut-remove { + width: 24px; + height: 28px; + color: var(--workspace-text-muted, var(--text-secondary)); + border-radius: 4px; +} + +.cutter-cut-remove:hover { + color: #ff747c; + background: rgba(220, 77, 86, 0.12); +} + +.cutter-cut-remove svg { + width: 14px; + height: 14px; +} + +.cutter-preview-panel { + padding: 12px; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 10px; +} + +.video-preview { + min-height: 0; + aspect-ratio: 16 / 9; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #050506; + border-radius: 7px; +} + +.video-preview video { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.video-preview .placeholder, +.cutter-player-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--workspace-text-muted, var(--text-secondary)); + text-align: center; +} + +.video-preview .placeholder[hidden], +.cutter-player-loading[hidden] { + display: none; +} + +.video-preview .placeholder svg { + opacity: 0.28; +} + +.video-preview .placeholder p { + margin: 0; +} + +.cutter-spinner { + width: 24px; + height: 24px; + border: 3px solid rgba(255, 255, 255, 0.15); + border-top-color: var(--workspace-primary, var(--accent)); + border-radius: 50%; + animation: cutter-spin 700ms linear infinite; +} + +@keyframes cutter-spin { + to { transform: rotate(360deg); } +} + +.cutter-player-controls { + min-height: 48px; + padding: 14px 10px 8px; + position: absolute; + right: 0; + bottom: 0; + left: 0; + gap: 6px; + color: #fff; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.86)); + opacity: 0; + transform: translateY(4px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.video-preview:hover .cutter-player-controls, +.video-preview:focus-within .cutter-player-controls, +.video-preview:not(.playing) .cutter-player-controls { + opacity: 1; + transform: translateY(0); +} + +.cutter-player-button { + min-width: 34.68px; + height: 34.68px; + padding: 0 6px; + color: #fff; + border-radius: 5px; + font-size: 10px; +} + +.cutter-player-button svg { + width: 20.4px; + height: 20.4px; +} + +.cutter-skip-button { + position: relative; +} + +.cutter-skip-button svg { + width: 25.5px; + height: 25.5px; +} + +.cutter-skip-button span { + position: absolute; + top: 50%; + left: 50%; + font-size: 8px; + font-weight: 700; + line-height: 8px; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.cutter-player-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.13); +} + +.cutter-pause-icon, +.video-preview.playing .cutter-play-icon { + display: none; +} + +.video-preview.playing .cutter-pause-icon { + display: block; +} + +.cutter-volume-control { + height: 34.68px; + display: inline-flex; + align-items: center; + flex: none; +} + +.cutter-volume { + --cutter-volume-progress: 100%; + width: 0; + height: 18px; + margin: 0; + padding: 0; + opacity: 0; + visibility: hidden; + pointer-events: none; + border: 0; + border-radius: 0; + outline: none; + background: linear-gradient(to right, var(--workspace-primary, var(--accent)) 0 var(--cutter-volume-progress), rgba(255, 255, 255, 0.42) var(--cutter-volume-progress) 100%) center / 100% 4px no-repeat; + box-shadow: none; + -webkit-appearance: none; + appearance: none; + transition: width 220ms cubic-bezier(0.2, 0.75, 0.25, 1), margin 220ms cubic-bezier(0.2, 0.75, 0.25, 1), opacity 150ms ease, visibility 0s linear 220ms; +} + +.cutter-volume::-webkit-slider-runnable-track { + height: 4px; + border: 0; + background: transparent; +} + +.cutter-volume::-webkit-slider-thumb { + width: 14px; + height: 14px; + margin-top: -5px; + border: 0; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + -webkit-appearance: none; + appearance: none; +} + +.cutter-volume-control:not(.disabled):hover .cutter-volume, +.cutter-volume-control:not(.disabled):focus-within .cutter-volume { + width: 80px; + margin: 0 4px; + opacity: 1; + visibility: visible; + pointer-events: auto; + transition-delay: 0s; +} + +.cutter-volume-control.disabled { + pointer-events: none; +} + +.cutter-player-time { + min-width: 142px; + flex: 1; + display: flex; + align-items: center; + gap: 4px; + font-family: Consolas, monospace; + font-size: 12px; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; + line-height: 16px; + white-space: nowrap; +} + +.cutter-player-time > span:not(:nth-child(2)) { + width: 62px; + display: inline-block; + text-align: center; + contain: layout; +} + +.cutter-player-settings { + position: relative; +} + +.cutter-settings-menu { + width: 190px; + padding: 10px; + position: absolute; + z-index: 30; + right: 0; + bottom: 38px; + color: #fff; + background: rgba(24, 24, 26, 0.96); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 7px; + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.38); +} + +.cutter-settings-menu[hidden] { + display: none; +} + +.cutter-settings-menu > span { + display: block; + margin-bottom: 7px; + color: rgba(255, 255, 255, 0.68); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.cutter-speed-options { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} + +.cutter-speed-options button { + min-height: 30px; + padding: 4px; + color: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.06); + border: 1px solid transparent; + border-radius: 5px; + font-size: 9px; +} + +.cutter-speed-options button:hover, +.cutter-speed-options button.active { + color: #fff; + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.12); +} + +.cutter-info { + display: none; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.cutter-info.shown { + display: grid; +} + +.cutter-info-item { + min-width: 0; + padding: 9px; + display: grid; + grid-template-rows: 12px 18px; + align-content: center; + gap: 3px; + background: var(--workspace-control, rgba(255, 255, 255, 0.04)); + border-radius: 6px; + text-align: center; +} + +.cutter-info-label { + color: var(--workspace-text-muted, var(--text-secondary)); + font-size: 9px; + line-height: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.cutter-info-value { + overflow: hidden; + color: var(--workspace-text, var(--text)); + font-family: Consolas, monospace; + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 18px; + text-overflow: ellipsis; +} + +.timeline-container { + display: none; + overflow: hidden; + background: var(--workspace-panel, var(--bg-card)); + border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); + border-radius: 8px; +} + +.timeline-container.shown { + display: block; +} + +.cutter-timeline-toolbar { + min-height: 44px; + padding: 6px 10px; + gap: 8px; + border-bottom: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); +} + +.cutter-timeline-timecode { + width: 96px; + min-width: 96px; + color: var(--workspace-primary, var(--accent)); + font-family: Consolas, monospace; + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; + line-height: 16px; + contain: layout; +} + +.cutter-history-controls, +.cutter-zoom-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.cutter-zoom-controls { + margin-left: auto; +} + +#cutterZoom { + width: 110px; + accent-color: var(--workspace-primary, var(--accent)); +} + +.cutter-timeline-scroll { + overflow-x: scroll; + overflow-y: hidden; + scrollbar-width: thin; +} + +.timeline { + width: 100%; + min-width: 100%; + height: 188px; + position: relative; + overflow: hidden; + background: var(--workspace-control, var(--bg-main)); + cursor: crosshair; + user-select: none; +} + +.cutter-ruler { + height: 28px; + position: relative; + border-bottom: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); +} + +.cutter-ruler-tick { + position: absolute; + bottom: 5px; + color: var(--workspace-text-muted, var(--text-secondary)); + font-family: Consolas, monospace; + font-size: 12px; + font-weight: 600; + line-height: 14px; + transform: translateX(-50%); +} + +.cutter-ruler-tick:first-child { + transform: none; +} + +.cutter-ruler-tick:last-child { + transform: translateX(-100%); +} + +.cutter-ruler-tick:first-child::after { + left: 0; +} + +.cutter-ruler-tick:last-child::after { + left: 100%; +} + +.cutter-ruler-tick::after { + content: ''; + width: 1px; + height: 4px; + position: absolute; + bottom: -5px; + left: 50%; + background: var(--workspace-border-strong, rgba(255, 255, 255, 0.16)); +} + +.cutter-track { + height: 80px; + position: relative; + overflow: hidden; + background: #111215; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.cutter-track-label { + padding: 3px 5px; + position: absolute; + z-index: 3; + top: 4px; + left: 20px; + color: rgba(255, 255, 255, 0.76); + background: rgba(0, 0, 0, 0.62); + border-radius: 3px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.08em; +} + +.cutter-thumbnail-strip { + width: 100%; + height: 100%; + display: flex; + overflow: hidden; +} + +.cutter-thumbnail-strip img { + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.cutter-thumbnail-strip img.cutter-thumbnail-sprite { + display: none; +} + +.cutter-thumbnail-tile { + min-width: 0; + height: 100%; + display: block; + pointer-events: none; +} + +.cutter-audio-track { + height: 80px; + background: #16171b; +} + +#cutterWaveform { + width: 100%; + height: 100%; + display: block; + object-fit: fill; + opacity: 1; + filter: none; + pointer-events: none; +} + +#cutterWaveform[hidden] { + display: none; +} + +.cutter-audio-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--workspace-text-muted, var(--text-secondary)); + font-size: 10px; +} + +.cutter-audio-empty[hidden] { + display: none; +} + +.timeline-selection { + position: absolute; + z-index: 6; + top: 28px; + bottom: 0; + border-top: 2px solid #5c9cff; + border-bottom: 2px solid #5c9cff; + pointer-events: none; +} + +.timeline-handle { + width: 12px; + position: absolute; + top: -2px; + bottom: -2px; + background: #5c9cff; + border: 2px solid #c7dcff; + border-radius: 3px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px rgba(92, 156, 255, 0.3); + cursor: ew-resize; + pointer-events: auto; +} + +.timeline-handle::after { + content: ''; + width: 2px; + height: 18px; + background: rgba(0, 0, 0, 0.35); + border-radius: 2px; +} + +.timeline-handle.start { + left: 0; + transform: none; +} + +.timeline-handle.end { + right: 0; + transform: none; +} + +.cutter-outside-shade { + position: absolute; + z-index: 4; + top: 28px; + bottom: 0; + background: rgba(0, 0, 0, 0.66); + pointer-events: none; +} + +.cutter-cut-overlays { + position: absolute; + z-index: 7; + inset: 28px 0 0; + pointer-events: none; +} + +.cutter-cut-overlay { + min-width: 5px; + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background: rgba(181, 57, 66, 0.5); + border: 1px solid rgba(255, 121, 129, 0.72); + cursor: grab; + pointer-events: auto; + transition: background 120ms ease, box-shadow 120ms ease; +} + +.cutter-cut-overlay:hover, +.cutter-cut-overlay.active { + background: rgba(218, 61, 72, 0.72); + box-shadow: inset 0 0 0 1px rgba(255, 206, 209, 0.28); +} + +.cutter-cut-overlay > span { + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(105, 18, 25, 0.8); + border-radius: 4px; + font-size: 9px; + font-weight: 800; + pointer-events: none; +} + +.cutter-cut-handle { + width: 12px; + position: absolute; + top: -1px; + bottom: -1px; + display: flex; + align-items: center; + justify-content: center; + background: #f06b73; + border: 2px solid #ffd0d3; + border-radius: 3px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px rgba(240, 107, 115, 0.3); + cursor: ew-resize; +} + +.cutter-cut-handle::after { + content: ''; + width: 2px; + height: 18px; + background: rgba(0, 0, 0, 0.35); + border-radius: 2px; +} + +.cutter-cut-handle.start { left: 0; } +.cutter-cut-handle.end { right: 0; } + +.timeline-current { + width: 2px; + position: absolute; + z-index: 8; + top: 20px; + bottom: 0; + background: #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.22); + pointer-events: none; +} + +.timeline-current span { + width: 0; + height: 0; + position: absolute; + top: -1px; + left: 50%; + border-top: 0; + border-right: 6px solid transparent; + border-bottom: 8px solid #fff; + border-left: 6px solid transparent; + transform: translate(-50%, -100%) rotate(180deg); +} + +.cutter-dragging, +.cutter-dragging * { + cursor: ew-resize !important; +} + +.cutter-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-left: 8px; +} + +.cutter-actions #btnCut { + min-width: 160px; +} + +@media (max-width: 1180px) { + .cutter-workspace { + grid-template-columns: 1fr; + } + + .cutter-sidebar { + max-height: 360px; + } + + .cutter-cut-list { + max-height: 170px; + } + + .cutter-timeline-toolbar { + flex-wrap: wrap; + } + + .cutter-actions { + width: 100%; + margin-left: 0; + } +} + +/* Merge Styles */ +.merge-container { + max-width: 800px; + margin: 0 auto; +} + +.file-list { + background: var(--bg-card); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + min-height: 200px; +} + +.file-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px 15px; + background: var(--bg-main); + border-radius: 6px; + margin-bottom: 10px; +} + +.file-item .file-order { + width: 30px; + height: 30px; + background: var(--accent); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; +} + +.file-item .file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-item .file-actions { + display: flex; + gap: 8px; +} + +.file-item .file-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 5px; + font-size: 16px; +} + +.file-item .file-btn:hover { + color: var(--text); +} + +.file-item .file-btn:focus-visible { + outline: none; + border-radius: 4px; + box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); + color: var(--text); +} + +.file-item .file-btn.remove:hover { + color: var(--error); +} + +.file-item .file-btn.remove:focus-visible { + box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.65); + color: var(--error); +} + +.merge-actions { + display: flex; + gap: 10px; + justify-content: center; +} + +/* Progress Bar */ +.progress-container { + background: var(--bg-card); + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + display: none; +} + +.progress-container.show { + display: block; +} + +.progress-bar { + height: 8px; + background: var(--bg-main); + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; +} + +.progress-bar-fill { + height: 100%; + background: var(--accent); + transition: width 0.3s; +} + +.progress-text { + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + +/* Theme variations */ +body.theme-discord { + --bg-main: #36393f; + --bg-sidebar: #202225; + --bg-card: #2f3136; + --accent: #5865F2; + --accent-hover: #4752C4; +} + +body.theme-youtube { + --bg-main: #0f0f0f; + --bg-sidebar: #0f0f0f; + --bg-card: #272727; + --accent: #FF0000; + --accent-hover: #cc0000; +} + +body.theme-apple { + --bg-main: #1c1c1e; + --bg-sidebar: #2c2c2e; + --bg-card: #3a3a3c; + --accent: #0A84FF; + --accent-hover: #0071e3; +} + +body.theme-light { + --bg-main: #f0f2f5; + --bg-sidebar: #ffffff; + --bg-card: #e4e6ea; + --text: #1a1a2e; + --text-secondary: #65676b; + --accent: #9146ff; + --accent-hover: #772ce8; + --success: #00c853; + --error: #e41e3f; + --warning: #e68a00; + --border-soft: rgba(0, 0, 0, 0.12); +} + +/* Light theme: swap white-alpha borders/backgrounds to black-alpha */ +body.theme-light .sidebar, +body.theme-light .queue-section, +body.theme-light .logo, +body.theme-light .stats-bar, +body.theme-light .header, +body.theme-light .status-bar { + border-color: rgba(0,0,0,0.1); +} + +body.theme-light .add-streamer input, +body.theme-light .form-group input:not([type="checkbox"]):not([type="radio"]), +body.theme-light .form-group select, +body.theme-light .form-stack input:not([type="checkbox"]):not([type="radio"]), +body.theme-light .form-stack select, +body.theme-light .input-narrow, +body.theme-light .clip-input input, +body.theme-light .time-input-group input, +body.theme-light .part-number-group input, +body.theme-light .btn-secondary, +body.theme-light .lang-option, +body.theme-light .log-panel, +body.theme-light .template-guide-table-wrap, +body.theme-light .template-guide-preview-box { + border-color: rgba(0,0,0,0.12); +} + +body.theme-light .lang-option:hover { + border-color: rgba(0,0,0,0.26); +} + +body.theme-light .streamer-item:hover { + background: rgba(0,0,0,0.05); +} + +body.theme-light .vod-btn.secondary { + background: rgba(0,0,0,0.08); +} + +body.theme-light .vod-btn.secondary:hover { + background: rgba(0,0,0,0.12); +} + +body.theme-light .nav-item:hover { + background: rgba(145, 71, 255, 0.1); +} + +body.theme-light ::-webkit-scrollbar-thumb { + background: rgba(0,0,0,0.15); +} + +body.theme-light ::-webkit-scrollbar-thumb:hover { + background: rgba(0,0,0,0.25); +} + +body.theme-light .update-modal { + border-color: rgba(0,0,0,0.1); + background: + linear-gradient(180deg, rgba(145, 70, 255, 0.12) 0%, rgba(145, 70, 255, 0.03) 24%, rgba(240, 242, 245, 0.98) 100%), + var(--bg-card); +} + +body.theme-light .update-modal-eyebrow { + color: #4a2a8a; +} + +body.theme-light .update-changelog-card { + border-color: rgba(0,0,0,0.08); + background: rgba(255, 255, 255, 0.6); +} + +body.theme-light .update-changelog-header { + border-bottom-color: rgba(0,0,0,0.06); +} + +body.theme-light .update-changelog-toggle { + color: #4a2a8a; +} + +body.theme-light .update-changelog-toggle:hover { + color: #1a1a2e; +} + +body.theme-light .update-changelog-heading, +body.theme-light .update-changelog-content strong { + color: #1a1a2e; +} + +body.theme-light .template-guide-preview-box { + background: rgba(0, 0, 0, 0.04); +} + +body.theme-light .template-guide-output { + background: rgba(0, 0, 0, 0.06); +} + +body.theme-light .template-guide-table th, +body.theme-light .template-guide-table td { + border-bottom-color: rgba(0,0,0,0.08); +} + +body.theme-light .log-panel { + background: #f8f9fb; + color: #2c3e50; +} + +body.theme-light .app-toast { + background: rgba(255, 255, 255, 0.96); + color: #1a1a2e; + border-color: rgba(0,0,0,0.12); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} + +body.theme-light .btn-retry { + background: #dce4f0; + color: #2a3344; +} + +body.theme-light .btn-retry:hover { + background: #c8d4e8; +} + +body.theme-light .btn-clear:hover { + background: #d0d2d6; +} + +body.theme-light .modal { + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); +} diff --git a/src/styles.css b/src/styles.css index a92d48d..4989d4a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -907,5030 +907,3 @@ select option { .select-compact.size-md { min-width: 160px; } - -/* Queue Section */ -.queue-section { - border-top: 1px solid rgba(255,255,255,0.1); - padding: 15px; - display: flex; - flex-direction: column; - min-height: 0; - flex: 1; -} - -.queue-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - gap: 8px; -} - -.queue-title { - font-size: 13px; - font-weight: 600; -} - -.queue-count { - background: var(--accent); - color: white; - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; -} - -.queue-list { - flex: 1; - overflow-y: auto; - min-height: 60px; -} - -.health-badge { - font-size: 10px; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid transparent; - white-space: nowrap; -} - -.health-badge.good { - background: rgba(0, 200, 83, 0.2); - border-color: rgba(0, 200, 83, 0.45); - color: #93efb9; -} - -.health-badge.warn { - background: rgba(255, 171, 0, 0.2); - border-color: rgba(255, 171, 0, 0.45); - color: #ffd98e; -} - -.health-badge.bad, -.health-badge.unknown { - background: rgba(255, 68, 68, 0.2); - border-color: rgba(255, 68, 68, 0.45); - color: #ffaaaa; -} - -.queue-item { - position: relative; - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 8px; - background: var(--bg-card); - border-radius: 6px; - margin-bottom: 6px; - font-size: 12px; - border-left: 3px solid transparent; - transition: border-color 0.2s, background 0.2s; -} - -.queue-item:has(.status.downloading) { - border-left-color: var(--accent); - background: rgba(145, 70, 255, 0.06); -} - -.queue-item:has(.status.error) { - border-left-color: var(--error); -} - -.queue-item:has(.status.completed) { - border-left-color: var(--success); - opacity: 0.85; -} - -.queue-item .status { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-secondary); - flex-shrink: 0; - margin-top: 4px; -} - -.queue-item .status.pending { background: var(--warning); box-shadow: 0 0 6px rgba(255, 167, 38, 0.5); } -.queue-item .status.downloading { background: var(--accent); animation: pulse 1s infinite; box-shadow: 0 0 8px rgba(145, 70, 255, 0.6); } -.queue-item .status.completed { background: var(--success); box-shadow: 0 0 6px rgba(0, 200, 83, 0.5); } -.queue-item .status.error { background: var(--error); box-shadow: 0 0 6px rgba(255, 70, 70, 0.5); } - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -.queue-item .title { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; -} - -.queue-detail-label { - color: var(--text-secondary); - font-weight: 500; - margin-right: 4px; -} - -.queue-retry-btn { - background: transparent; - border: 1px solid var(--border-soft); - border-radius: 6px; - color: var(--text-secondary); - cursor: pointer; - padding: 4px 8px; - font-size: 14px; - line-height: 1; - align-self: center; - transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.12s; -} - -.queue-retry-btn:hover { - background: rgba(145, 70, 255, 0.18); - border-color: rgba(145, 70, 255, 0.55); - color: #fff; -} - -.queue-retry-btn:active { - transform: scale(0.92); -} - -.queue-retry-btn:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); - border-color: rgba(145, 70, 255, 0.55); -} - -.queue-main { - flex: 1; - min-width: 0; -} - -.queue-title-row { - display: flex; - align-items: center; - gap: 8px; - min-height: 16px; -} - -.queue-status-label { - flex-shrink: 0; - font-size: 10px; - color: var(--text-secondary); - line-height: 16px; -} - -.queue-meta { - font-size: 10px; - color: var(--text-secondary); - margin-top: 2px; - margin-bottom: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.queue-progress-wrap { - height: 5px; - border-radius: 999px; - overflow: hidden; - background: rgba(255,255,255,0.10); - position: relative; -} - -.queue-progress-bar { - height: 100%; - width: 0; - background: linear-gradient(90deg, #168f4a 0%, var(--success) 100%); - transition: width 0.3s ease; - position: relative; - overflow: hidden; -} - -.queue-progress-bar::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.35) 50%, transparent 100%); - transform: translateX(-100%); - animation: queue-progress-shimmer 1.8s ease-in-out infinite; -} - -@keyframes queue-progress-shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} - -.queue-progress-info { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-top: 3px; - font-size: 10px; - color: var(--text-secondary); - line-height: 14px; -} - -.queue-progress-status, -.queue-progress-metrics { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.queue-progress-status.is-starting { - animation: queue-starting-pulse 1.1s ease-in-out infinite; -} - -@keyframes queue-starting-pulse { - 0%, 100% { opacity: 0.62; } - 50% { opacity: 1; } -} - -.queue-progress-metrics { - margin-left: auto; - text-align: right; - font-variant-numeric: tabular-nums; -} - -.queue-item .remove { - display: inline-flex; - width: 16px; - height: 16px; - align-items: center; - justify-content: center; - flex: 0 0 16px; - cursor: pointer; - color: var(--error); - opacity: 0.7; - font-size: 11px; - line-height: 16px; -} - -.queue-item .remove:hover { - opacity: 1; -} - -.queue-item[draggable="true"] { - cursor: grab; -} - -.queue-item[draggable="true"]:active { - cursor: grabbing; -} - -.queue-item.dragging { - opacity: 0.4; -} - -.queue-details { - display: none; - font-size: 10px; - color: var(--text-secondary); - padding: 4px 0; - word-break: break-all; -} - -.queue-details.expanded { - display: block; -} - -.queue-details div { - margin-bottom: 2px; -} - -.queue-selection-order { - position: absolute; - z-index: 2; - top: -5px; - left: -5px; - display: inline-flex; - width: 18px; - height: 18px; - align-items: center; - justify-content: center; - border: 2px solid var(--bg-panel); - border-radius: 50%; - background: var(--success); - color: #fff; - font-size: 10px; - font-weight: 700; - line-height: 1; - font-variant-numeric: tabular-nums; - user-select: none; -} - -.queue-item.merge-selected { - box-shadow: inset 0 0 0 1px rgba(0, 200, 83, 0.45); -} - -.queue-item .title:focus-visible { - outline: none; - border-radius: 3px; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.45); -} - -.queue-item.merge-group { - border-left: 3px solid var(--accent); -} - -.merge-group-icon { - vertical-align: middle; - margin-right: 2px; - opacity: 0.8; -} - -.btn-merge-group { - background: var(--accent); - color: var(--bg-primary); -} - -.btn-merge-group:hover { - opacity: 0.9; -} - -.queue-actions { - display: flex; - gap: 8px; - margin-top: 10px; - flex-shrink: 0; -} - -.stats-bar { - padding: 6px 15px; - font-size: 10px; - color: var(--text-secondary); - border-top: 1px solid rgba(255,255,255,0.1); - flex-shrink: 0; -} - -.btn { - flex: 1; - padding: 5px 8px; - border: none; - border-radius: 4px; - cursor: pointer; - font-weight: 600; - font-size: 12px; - transition: all 0.2s; -} - -.btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.btn:disabled:hover { - background: inherit; -} - -.btn:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); -} - -.btn-start:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(0, 200, 83, 0.65); -} - -.btn-start.downloading:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(229, 70, 70, 0.65); -} - -.btn-retry { - background: #2a3344; - color: #d9e4f7; -} - -.btn-retry:hover { - background: #33405a; -} - -.btn-start { - background: var(--success); - color: white; -} - -.btn-start:hover { - background: #00a844; -} - -.btn-start.downloading { - background: var(--error); -} - -.btn-clear { - background: var(--bg-card); - color: var(--text-secondary); -} - -.btn-clear:hover { - background: #2a2a2e; -} - -/* Main Content */ -.main { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.header { - padding: 20px 30px; - border-bottom: 1px solid rgba(255,255,255,0.1); - display: flex; - justify-content: space-between; - align-items: center; -} - -.header h1 { - font-size: 22px; - font-weight: 600; -} - -.header-actions { - display: flex; - align-items: center; - gap: 15px; -} - -.header-search { - display: flex; - gap: 8px; -} - -.header-search input { - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: 6px; - padding: 8px 12px; - color: var(--text); - font-size: 13px; - width: 200px; -} - -.header-search input::placeholder { - color: var(--text-secondary); -} - -.header-search button { - background: var(--accent); - border: none; - border-radius: 6px; - color: white; - padding: 8px 14px; - cursor: pointer; - font-size: 16px; - font-weight: 700; - transition: background 0.18s, transform 0.12s, box-shadow 0.18s; - line-height: 1; -} - -.header-search button:hover { - background: var(--accent-hover); - box-shadow: 0 4px 14px rgba(145, 70, 255, 0.35); -} - -.header-search button:active { - transform: scale(0.94); -} - -.header-search button:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); -} - -.header-search input:focus-visible { - outline: none; - border-color: rgba(145, 70, 255, 0.6); - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.35); -} - -.btn-icon { - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: 6px; - color: var(--text); - padding: 8px 14px; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 500; - transition: background 0.18s, border-color 0.18s, transform 0.12s, box-shadow 0.18s; -} - -.btn-icon:hover { - background: rgba(145, 70, 255, 0.12); - border-color: rgba(145, 70, 255, 0.45); - color: #fff; -} - -.btn-icon:hover svg { - animation: btn-icon-spin 0.6s ease-out; -} - -.btn-icon:active { - transform: scale(0.96); -} - -.btn-icon:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); - border-color: rgba(145, 70, 255, 0.55); -} - -@keyframes btn-icon-spin { - from { transform: rotate(0deg); } - to { transform: rotate(180deg); } -} - -.content { - flex: 1; - overflow-y: auto; - padding: 25px 30px; -} - -/* Tabs */ -.tab-content { - display: none; -} - -.tab-content.active { - display: flex; - flex-direction: column; - min-height: 100%; -} - -/* VOD Grid */ -.vod-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; - flex: 1; -} - -.vod-grid:has(.empty-state) { - display: flex; - align-items: center; - justify-content: center; -} - -.vod-card { - background: var(--bg-card); - border-radius: 8px; - overflow: hidden; - transition: transform 0.22s ease-out, box-shadow 0.22s ease-out, border-color 0.22s; - cursor: pointer; - position: relative; - border: 1px solid transparent; - /* Flex-Column + stretch (grid default) macht alle Cards einer Reihe - gleich hoch. Die Actions unten kriegen margin-top:auto und docken - damit am Boden an — egal ob der Titel 1 oder 2 Zeilen hat. Vorher - sass der Button bei 1-Zeilen-Titeln hoeher als bei 2-Zeilen-Nachbarn. */ - display: flex; - flex-direction: column; -} - -.vod-card:hover { - transform: translateY(-4px); - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.35); - border-color: rgba(145, 70, 255, 0.35); -} - -.vod-card:focus-visible { - outline: none; - border-color: rgba(145, 70, 255, 0.7); - box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.35); -} - -/* The bulk-select checkbox overlaid on each VOD thumbnail top-left. - Positioned absolutely so it sits over the artwork without affecting - the cards flex/info layout. - WICHTIG: Selektor MUSS hoehere Spezifitaet haben als die globale - `input[type="checkbox"]` Regel (0,0,1,1), sonst gewinnt deren - `position: relative` + `width/height:16px` und die Checkbox wird zum - in-flow Flex-Item -> belegt eine 16px-Reihe oben in der Card und - schiebt das Thumbnail runter (grauer Balken ueber jedem VOD-Bild, - gemeldet in 5.0.14). `input[type="checkbox"].vod-select-checkbox` - = (0,0,2,1) schlaegt die globale Regel sauber. */ -input[type="checkbox"].vod-select-checkbox { - position: absolute; - top: 8px; - left: 8px; - width: 18px; - height: 18px; - cursor: pointer; - z-index: 2; -} - -.vod-card.selected { - box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25); -} - -.vod-downloaded-badge { - position: absolute; - top: 8px; - right: 8px; - background: rgba(0, 200, 83, 0.92); - color: white; - border-radius: 50%; - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - z-index: 2; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); -} - -.vod-card.already-downloaded .vod-thumbnail { - opacity: 0.6; -} - -#cutterPreview.drag-over { - outline: 2px dashed var(--accent); - outline-offset: -8px; - background: rgba(145, 70, 255, 0.08); -} - -.streamer-item.dragging { - opacity: 0.4; -} - -.streamer-rec { - margin-right: 6px; - color: #ff4444; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - cursor: pointer; - padding: 2px 5px; - border: 1px solid rgba(255, 68, 68, 0.4); - border-radius: 3px; - background: transparent; - transition: background 0.15s; -} - -.streamer-rec:hover { - background: rgba(255, 68, 68, 0.15); -} - -.streamer-auto { - margin-left: auto; - margin-right: 4px; - color: var(--text-secondary); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - cursor: pointer; - padding: 2px 5px; - border: 1px solid var(--border-soft); - border-radius: 3px; - background: transparent; - transition: background 0.15s, color 0.15s, border-color 0.15s; -} - -.streamer-auto.active { - color: #00c853; - border-color: rgba(0, 200, 83, 0.45); - background: rgba(0, 200, 83, 0.10); -} - -.streamer-auto:hover { - background: rgba(0, 200, 83, 0.18); - color: #00c853; -} - -.streamer-vod { - margin-right: 4px; - color: var(--text-secondary); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - cursor: pointer; - padding: 2px 5px; - border: 1px solid var(--border-soft); - border-radius: 3px; - background: transparent; - transition: background 0.15s, color 0.15s, border-color 0.15s; -} - -.streamer-vod.active { - color: #2196f3; - border-color: rgba(33, 150, 243, 0.45); - background: rgba(33, 150, 243, 0.10); -} - -.streamer-vod:hover { - background: rgba(33, 150, 243, 0.18); - color: #2196f3; -} - -.queue-health-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - margin-right: 6px; - vertical-align: middle; - box-shadow: 0 0 4px currentColor; -} - -.queue-health-dot.health-ok { - background: #00c853; - color: #00c853; - animation: queue-health-pulse 2s ease-in-out infinite; -} - -.queue-health-dot.health-stale { - background: #ffab00; - color: #ffab00; - animation: queue-health-flash 1s ease-in-out infinite; -} - -.queue-health-dot.health-unknown { - background: var(--text-secondary); - color: var(--text-secondary); - box-shadow: none; -} - -@keyframes queue-health-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -@keyframes queue-health-flash { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -.queue-live-badge { - display: inline-block; - background: #ff4444; - color: white; - font-size: 9px; - font-weight: 700; - letter-spacing: 0.5px; - padding: 1px 5px; - border-radius: 3px; - vertical-align: middle; - animation: queue-live-pulse 1.5s ease-in-out infinite; -} - -@keyframes queue-live-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -.vod-thumbnail { - width: 100%; - aspect-ratio: 16/9; - background: #333; - object-fit: cover; -} - -.vod-info { - padding: 12px 15px; -} - -.vod-title { - font-weight: 600; - font-size: 14px; - margin-bottom: 6px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; -} - -.vod-meta { - display: flex; - gap: 12px; - font-size: 12px; - color: var(--text-secondary); -} - -.vod-actions { - padding: 10px 15px 15px; - display: flex; - gap: 8px; - /* Dockt am Card-Boden an, sodass Trim/Queue-Buttons ueber alle Cards - einer Reihe auf gleicher Hoehe liegen — unabhaengig von Titel-Zeilen. */ - margin-top: auto; -} - -.vod-btn { - flex: 1; - padding: 8px; - border: none; - border-radius: 4px; - cursor: pointer; - font-weight: 500; - font-size: 12px; - transition: all 0.2s; -} - -.vod-btn.primary { - background: #1f7a43; - color: white; -} - -.vod-btn.primary:hover { - background: #186638; -} - -.vod-btn.secondary { - background: rgba(255,255,255,0.1); - color: var(--text); -} - -.vod-btn.secondary:hover { - background: rgba(255,255,255,0.15); -} - -/* Focus-visible for the per-card action buttons (Trim, Queue, etc.). The - primary variant already has a purple background — use the inner-white - + outer-purple double ring so the focus indicator stays visible - against the button's own colour. */ -.vod-btn:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); -} - -.vod-btn.primary:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(31, 122, 67, 0.65); -} - -/* Settings */ -.settings-card { - background: var(--bg-card); - border-radius: 8px; - padding: 20px; - margin-bottom: 20px; -} - -/* Centred-narrow settings card — used for the standalone Clips Info - card where the content (a short list of supported URL formats) reads - better at a constrained width than across the full main column. */ -.settings-card.centered { - max-width: 600px; - margin: 20px auto; -} - -.settings-card h3 { - font-size: 16px; - margin-bottom: 15px; - display: flex; - align-items: center; - gap: 8px; -} - -/* Subsection heading inside a settings card — used when a single card - bundles two logical groups (Storage → Auto-Cleanup) and the second - needs its own miniature heading after a divider. */ -.settings-card h4 { - margin: 0 0 8px 0; - font-size: 14px; -} - -/* Horizontal divider inside settings cards — soft single line, balanced - vertical breathing room, no default browser shading. */ -.settings-card hr { - border: none; - border-top: 1px solid var(--border-soft); - margin: 16px 0; -} - -.form-group { - margin-bottom: 15px; -} - -.form-group label { - display: block; - font-size: 13px; - color: var(--text-secondary); - margin-bottom: 6px; -} - -.form-group input:not([type="checkbox"]):not([type="radio"]), -.form-group select, -.form-stack input:not([type="checkbox"]):not([type="radio"]), -.form-stack select { - width: 100%; - /* background-color (nicht background shorthand) — sonst wuerden - background-image (Chevron-SVG), background-repeat, background-size - und background-position aus der globalen `select`-Regel resettet, - was zu tiled Chevrons im Dropdown gefuehrt hat. */ - background-color: var(--bg-main); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - padding: 10px 12px; - color: var(--text); - font-size: 14px; -} - -.form-group input:not([type="checkbox"]):not([type="radio"]):focus, -.form-group select:focus, -.form-stack input:not([type="checkbox"]):not([type="radio"]):focus, -.form-stack select:focus { - outline: none; - border-color: var(--accent); -} - -.form-group input:not([type="checkbox"]):not([type="radio"]):disabled, -.form-group select:disabled, -.form-stack input:not([type="checkbox"]):not([type="radio"]):disabled, -.form-stack select:disabled { - opacity: 0.55; - cursor: not-allowed; - color: rgba(239, 239, 241, 0.7); -} - -.input-disabled { - opacity: 0.65; -} - -.form-group input[type="checkbox"], -.form-group input[type="radio"] { - /* width:auto wuerde Checkbox auf 0/1px kollabieren, weil - appearance:none + kein Content. Wir wollen die 16x16 aus der - globalen Regel — daher explicit width:16px hier nochmal, damit - die Klassen-Specificity nicht den globalen Wert ueberschreibt. */ - width: 16px; -} - -.language-picker { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.lang-option { - display: flex; - align-items: center; - gap: 8px; - border: 1px solid rgba(255,255,255,0.14); - border-radius: 6px; - background: var(--bg-main); - color: var(--text); - padding: 9px 10px; - cursor: pointer; - font-size: 13px; -} - -.lang-option:hover { - border-color: rgba(255,255,255,0.26); -} - -.lang-option:focus-visible { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); -} - -.lang-option.active { - border-color: var(--accent); - box-shadow: 0 0 0 1px rgba(145, 70, 255, 0.2); -} - -/* Active + focused — combine the pressed-state border with the - thicker focus halo so keyboard users still see which one was - focused even when it's also the currently-selected language. */ -.lang-option.active:focus-visible { - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); -} - -.flag-icon { - width: 16px; - height: 12px; - border-radius: 2px; - border: 1px solid rgba(0,0,0,0.35); - flex-shrink: 0; - position: relative; - overflow: hidden; -} - -.flag-de { - background: linear-gradient(to bottom, #111 0 33.33%, #dd0000 33.33% 66.66%, #ffce00 66.66% 100%); -} - -.flag-en { - width: 18px; - background: none; - opacity: 1; -} - -.form-row { - display: flex; - gap: 10px; -} - -/* Settings-card header row: card title + right-aligned refresh button. - Used by System-Check, Storage and similar cards where an h3 lives in - a form-row with a button pinned to the far right. The descendant h3 - margin reset kills the inline style="margin:0" that those headings - used to carry. */ -.form-row.section-header { - align-items: center; - justify-content: space-between; - margin-bottom: 10px; - flex-wrap: wrap; -} - -.form-row.section-header h3 { - margin: 0; -} - -/* Right-side action cluster inside a section-header — keeps a label - and a button (or two) together as a single unit so the section-header - parent's justify-content:space-between can pin the cluster to the - right while the h3 stays at the left. */ -.section-header-actions { - display: flex; - gap: 8px; - align-items: center; -} - -/* Plain centred form-row with bottom margin — the most common - form-row shape in Settings (button + button + inline-toggle, or - number-input + sublabel). Replaces three duplicated inline copies - of the same align-items:center; margin-bottom:10px declaration. */ -.form-row.aligned { - align-items: center; - margin-bottom: 10px; -} - -/* Search/filter tool-row variant — wraps on narrow widths so the - select / input cluster collapses gracefully. Used by the Archive - search row (input + 3 selects + button). */ -.form-row.search-bar { - gap: 8px; - margin-bottom: 8px; - flex-wrap: wrap; - align-items: center; -} - -.log-panel { - background: #11151c; - border: 1px solid rgba(255,255,255,0.12); - border-radius: 6px; - padding: 10px; - max-height: 220px; - overflow: auto; - white-space: pre-wrap; - color: #b8c7df; - font-size: 12px; - line-height: 1.35; -} - -.form-row input { - flex: 1; -} - -.btn-primary { - background: var(--accent); - color: white; - border: none; - border-radius: 4px; - padding: 10px 20px; - cursor: pointer; - font-weight: 600; -} - -.btn-primary:hover { - background: var(--accent-hover); -} - -.btn-primary:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); -} - -.btn-primary:disabled { - background: var(--text-secondary); - cursor: not-allowed; -} - -.btn-secondary { - background: var(--bg-card); - color: var(--text); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - padding: 10px 20px; - cursor: pointer; - transition: background 0.15s, border-color 0.15s, color 0.15s; -} - -.btn-secondary:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.06); - border-color: rgba(255, 255, 255, 0.22); -} - -.btn-secondary:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); -} - -.btn-secondary:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -/* ============================================ - COMPACT / UTILITY BUTTONS - ============================================ - .btn-pill — small action buttons used in toolbars + bulk-bars. - Comes in default (transparent), primary (purple), success (green). - Replaces the inline-style blocks the renderer was rolling for each - bulk action button. */ -.btn-pill { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-soft); - border-radius: 6px; - padding: 6px 12px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s; - line-height: 1.2; -} - -.btn-pill:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.06); - color: var(--text); - border-color: rgba(255, 255, 255, 0.18); -} - -.btn-pill:active:not(:disabled) { - transform: translateY(1px); -} - -.btn-pill:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.55); -} - -.btn-pill.primary:focus-visible, -.btn-pill.success:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); -} - -.btn-pill.danger:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6); -} - -.btn-pill:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.btn-pill.primary { - background: var(--accent); - color: #fff; - border-color: var(--accent); - font-weight: 600; -} - -.btn-pill.primary:hover:not(:disabled) { - background: var(--accent-hover); - border-color: var(--accent-hover); - color: #fff; - box-shadow: 0 4px 14px rgba(145, 70, 255, 0.35); -} - -.btn-pill.success { - background: #00c853; - color: #fff; - border-color: #00c853; - font-weight: 600; -} - -.btn-pill.success:hover:not(:disabled) { - background: #00e676; - border-color: #00e676; - box-shadow: 0 4px 14px rgba(0, 200, 83, 0.35); -} - -.btn-pill.danger { - background: transparent; - color: #ff6b6b; - border-color: rgba(255, 107, 107, 0.4); -} - -.btn-pill.danger:hover:not(:disabled) { - background: rgba(255, 107, 107, 0.12); - border-color: rgba(255, 107, 107, 0.7); - color: #ff8a8a; -} - -/* .btn-close — square X-close button for filter clears, inline removals. - Renamed from .btn-icon to avoid clashing with the existing top-bar - icon+text button class that's used for Refresh. */ -.btn-close { - display: inline-flex; - align-items: center; - justify-content: center; - background: transparent; - border: 1px solid var(--border-soft); - border-radius: 6px; - padding: 6px 10px; - color: var(--text-secondary); - cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: background 0.15s, color 0.15s, border-color 0.15s; - line-height: 1; -} - -.btn-close:hover:not(:disabled) { - background: rgba(255, 70, 70, 0.10); - border-color: rgba(255, 70, 70, 0.45); - color: #ff6b6b; -} - -.btn-close:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6); -} - -/* .queue-detail-btn — tiny chip-style action button used in queue item - detail rows AND in the archive search results list. Was previously - rendering with browser defaults (gray flat button). */ -.queue-detail-btn { - display: inline-flex; - align-items: center; - justify-content: center; - background: rgba(145, 70, 255, 0.10); - color: var(--text); - border: 1px solid rgba(145, 70, 255, 0.30); - border-radius: 5px; - padding: 4px 10px; - margin-right: 6px; - margin-bottom: 4px; - font-size: 11px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.12s; -} - -.queue-detail-btn:hover { - background: rgba(145, 70, 255, 0.22); - border-color: rgba(145, 70, 255, 0.6); - color: #fff; - transform: translateY(-1px); -} - -.queue-detail-btn:active { - transform: translateY(0); -} - -.queue-detail-btn:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.7); -} - -/* Clips */ -.clip-input { - max-width: 600px; - margin: 0 auto; - text-align: center; - padding: 40px 20px; -} - -.clip-input h2 { - margin-bottom: 20px; -} - -.clip-input input { - width: 100%; - background: var(--bg-card); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - padding: 12px 15px; - color: var(--text); - font-size: 14px; - margin-bottom: 15px; -} - -.clip-status { - margin-top: 15px; - font-size: 14px; -} - -.clip-status.success { color: var(--success); } -.clip-status.error { color: var(--error); } -.clip-status.loading { color: var(--warning); } - -/* Empty State */ -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - min-height: 60vh; - padding: 20px; - color: var(--text-secondary); -} - -.empty-state svg { - width: 80px; - height: 80px; - margin-bottom: 18px; - opacity: 0.45; - color: var(--accent); - animation: empty-state-float 4s ease-in-out infinite; -} - -@keyframes empty-state-float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-6px); } -} - -.empty-state h3 { - margin-bottom: 10px; - color: var(--text); - font-size: 18px; - font-weight: 600; -} - -.empty-state p { - max-width: 380px; - line-height: 1.5; - font-size: 13px; -} - -/* Status Bar */ -.status-bar { - padding: 10px 30px; - background: var(--bg-sidebar); - border-top: 1px solid rgba(255,255,255,0.1); - display: flex; - justify-content: space-between; - font-size: 12px; - color: var(--text-secondary); -} - -.status-indicator { - display: flex; - align-items: center; - gap: 8px; -} - -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-secondary); -} - -.status-dot.connected { background: var(--success); } -.status-dot.error { background: var(--error); } - -.status-bar-queue-summary { - color: var(--text-secondary); - font-size: 12px; - margin-left: auto; - padding-right: 12px; - font-variant-numeric: tabular-nums; -} - -.status-bar-version { - color: var(--text-secondary); - font-size: 12px; - opacity: 0.7; -} - -/* ============================================ - STORAGE STATS TABLE — Settings page disk usage - ============================================ */ -.storage-stats-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -.storage-stats-table th { - text-align: left; - padding: 6px 8px; - color: var(--text-secondary); - border-bottom: 1px solid var(--border-soft); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.4px; - font-size: 10px; -} - -.storage-stats-table td { - padding: 6px 8px; - border-bottom: 1px solid var(--border-soft); - font-variant-numeric: tabular-nums; -} - -.storage-stats-table tbody tr { - transition: background 0.12s; -} - -.storage-stats-table tbody tr:hover { - background: rgba(255, 255, 255, 0.03); -} - -.storage-stats-table tbody tr:last-child td { - border-bottom: none; -} - -.storage-stats-section { - color: var(--text-secondary); - font-size: 12px; - margin: 14px 0 4px; - text-transform: uppercase; - letter-spacing: 0.4px; -} - -/* ============================================ - FORM UTILITY CLASSES — small recurring patterns - ============================================ - These replace the 6+ inline-style copies of the same visual - pattern that were scattered across Settings cards. */ - -/* Small secondary-coloured label / note text. Used as field-label - above stacked inputs, as inline metadata next to controls, etc. */ -.form-sublabel { - font-size: 12px; - color: var(--text-secondary); -} - -/* Vertical stack: label on top, control below, equal flex share in - a flex-row. Used for the 3-up auto-cleanup row + poll-config rows. */ -.form-stack { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; -} - -/* Min-width sizing modifiers — let the row wrap to a new line before - the stack collapses below the named breakpoint. Replaces three inline - min-width declarations in the Auto-Cleanup 3-up row. */ -.form-stack.size-sm { - min-width: 120px; -} - -.form-stack.size-md { - min-width: 160px; -} - -/* Compact-width input — used for the Auto-VOD poll/age inputs where - the values are 2-3 digits and a full-width input would look odd - alongside their inline sublabels. Stylt sich selbst mit dark-theme - weil es direkt in einer .form-row sitzt (kein .form-group / .form-stack - Wrapper, der das styling sonst beistellt). */ -.input-narrow { - width: 90px; - background-color: var(--bg-main); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - padding: 8px 10px; - color: var(--text); - font-size: 14px; -} - -.input-narrow:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.18); -} - -.input-narrow:hover:not(:focus):not(:disabled) { - border-color: rgba(145, 70, 255, 0.45); -} - -.input-narrow:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -/* Block-level note text — same colour as .form-sublabel but reserved - for full-row paragraphs like the cleanup report area. */ -.form-note { - color: var(--text-secondary); - font-size: 12px; - line-height: 1.45; -} - -/* Card intro paragraph — the descriptive paragraph that sits below a - card heading and explains what the card does. Used identically on - the Archive, API-help, Storage, Cleanup, Discord, Auto-VOD and - Backup cards (was 7 duplicated inline style attributes). */ -.card-intro { - color: var(--text-secondary); - font-size: 13px; - line-height: 1.5; - margin-bottom: 12px; -} - -/* Inline link inside a card intro — picks up the accent colour so it - reads as actionable text rather than the default browser blue. The - underline + pointer cursor come from the browser's defaults. */ -.card-intro a { - color: var(--accent); -} - -/* Multi-line info text — preserves authored line breaks (white-space: - pre-line) so the Clips card can list URL formats one-per-line in - the HTML without separate
    /
  • markup. */ -.info-text { - color: var(--text-secondary); - line-height: 1.6; - white-space: pre-line; -} - -/* Responsive KPI grid for the Stats Summary card — fits as many 180px - tiles per row as the column allows, with equal-share growth. */ -.stats-summary-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; -} - -/* Flush variant: the intro sits flush against the next sibling block - (e.g. the stats summary grid) and gets its top breathing room from - the preceding section-header row rather than its own bottom margin. */ -.card-intro.flush { - margin-top: 8px; - margin-bottom: 0; -} - -/* Filename-templates 3-pair grid (VOD / Part / Clip template inputs). - Each row is a label above an input; the label gets the 13px secondary - styling that used to be inline on every label. */ -.filename-template-grid { - display: grid; - gap: 8px; - margin-top: 8px; -} - -.filename-template-grid label { - font-size: 13px; - color: var(--text-secondary); -} - -.filename-template-grid label:not(:first-child) { - margin-top: 4px; -} - -/* Settings toggle row — label wraps an input[type=checkbox] + span. - Used 17 times across the Settings cards. Adjacent-sibling - combinator adds the gap between consecutive toggle rows so the - inline `margin-top: 8px` repeats are no longer needed. */ -.toggle-row { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; -} - -.toggle-row + .toggle-row { - margin-top: 8px; -} - -/* Indented sub-toggle — kept by the renderer for visual nesting - under a parent toggle (delete-parts-after-merge under - auto-merge-parts, for example). */ -.toggle-row.indented { - margin-left: 22px; -} - -/* Compact horizontal-row toggle — used in filter rows where the - toggle sits alongside other controls (Hide downloaded, etc). - Tighter gap + secondary colour + tiny font to fit a tool-row - without dominating it. */ -.inline-toggle { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-secondary); - font-size: 12px; - cursor: pointer; - user-select: none; -} - -/* Filename-template lint badge — used both by the Settings card's - template inputs and by the clip-cutter modal's custom template - row. Two states: green for OK, red for unknown-placeholder - warning. Pull the colours from --success / --error vars so the - lint always tracks the rest of the apps semantic palette. - - margin-top is part of the class so both usage sites pick up the - same rhythm — the previous inline-style values diverged by 2px - between the two spots, an inconsistency that's not worth tracking. */ -.template-lint { - font-size: 12px; - line-height: 1.4; - margin-top: 6px; - transition: color 0.15s; -} - -.template-lint.ok { - color: var(--success); -} - -.template-lint.warn { - color: var(--error); -} - -/* Sidebar queue empty state — small dashed-border card matching the - sibling streamer-list empty state. */ -.queue-empty { - color: var(--text-secondary); - font-size: 12px; - text-align: center; - padding: 14px; - border: 1px dashed var(--border-soft); - border-radius: 6px; - background: rgba(255, 255, 255, 0.02); - margin: 4px 0; - line-height: 1.4; -} - -/* Merge-tab empty state — uses the global .empty-state base and adds - its own padding override since the merge file-list container sits - inside a settings-card with its own padding. */ -.merge-empty-state { - padding: 40px 20px; -} - -.merge-empty-state svg { - opacity: 0.3; - width: 48px; - height: 48px; -} - -.merge-empty-state p { - margin-top: 10px; -} - -/* ============================================ - ARCHIVE SEARCH RESULTS — row layout - ============================================ - Replaces ~10 inline-styled divs in renderer-archive's row template - with reusable classes. Hover background scoped to the row so the - list scans as a real interactive list. */ -.archive-no-matches { - color: var(--text-secondary); - padding: 12px; -} - -.archive-result-row { - display: flex; - padding: 10px 8px; - border-bottom: 1px solid var(--border-soft); - gap: 10px; - align-items: center; - transition: background 0.12s; -} - -.archive-result-row:hover { - background: rgba(255, 255, 255, 0.03); -} - -.archive-result-row:last-child { - border-bottom: none; -} - -.archive-result-body { - flex: 1; - min-width: 0; -} - -.archive-result-meta { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 4px; - flex-wrap: wrap; -} - -.archive-result-streamer { - color: var(--text); -} - -.archive-result-date { - font-size: 12px; - color: var(--text-secondary); - font-variant-numeric: tabular-nums; -} - -.archive-result-filename { - font-size: 13px; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.archive-result-size { - font-size: 11px; - color: var(--text-secondary); - margin-top: 2px; - font-variant-numeric: tabular-nums; -} - -.archive-result-actions { - display: flex; - flex-direction: column; - gap: 4px; - flex-shrink: 0; -} - -/* Type pill — LIVE / VOD chip in the archive row's meta line. */ -.archive-type-badge { - font-size: 10px; - font-weight: 700; - padding: 2px 6px; - border-radius: 3px; - letter-spacing: 0.3px; -} - -.archive-type-badge.live { - background: rgba(255, 68, 68, 0.18); - color: #ff4444; -} - -.archive-type-badge.vod { - background: rgba(145, 70, 255, 0.18); - color: #9146ff; -} - -/* ============================================ - STATS DASHBOARD KPI CARDS - ============================================ - Six-tile overview grid at the top of the Statistik tab. Each card - shows a label (uppercase track), a big value, and an optional - secondary line (e.g. byte-size total under the count). */ -.stats-kpi-card { - background: var(--bg-elevated); - border: 1px solid var(--border-soft); - border-radius: 6px; - padding: 12px; - transition: border-color 0.18s, transform 0.18s; -} - -.stats-kpi-card:hover { - border-color: rgba(145, 70, 255, 0.4); - transform: translateY(-1px); -} - -.stats-kpi-label { - font-size: 11px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.stats-kpi-value { - font-size: 22px; - font-weight: 600; - margin-top: 4px; - font-variant-numeric: tabular-nums; -} - -.stats-kpi-sub { - font-size: 12px; - color: var(--text-secondary); - margin-top: 4px; - font-variant-numeric: tabular-nums; -} - -.stats-no-root { - grid-column: 1 / -1; - color: var(--text-secondary); -} - -/* Top-streamers bar list — one row per streamer, label row above a - purple-to-green gradient bar. Live/VOD breakdown labels sit - overlaid on top of the bar for a compact two-column read. */ -.stats-top-row { - margin-bottom: 10px; -} - -.stats-top-row:last-child { - margin-bottom: 0; -} - -.stats-top-meta { - display: flex; - justify-content: space-between; - font-size: 13px; - margin-bottom: 4px; - gap: 8px; -} - -.stats-top-meta-sub { - color: var(--text-secondary); - font-variant-numeric: tabular-nums; -} - -.stats-top-share { - opacity: 0.7; -} - -.stats-top-bar-track { - background: var(--bg-elevated); - border-radius: 3px; - height: 18px; - overflow: hidden; - position: relative; -} - -.stats-top-bar-fill { - height: 100%; - background: linear-gradient(90deg, #9146ff 0%, #00c853 100%); - transition: width 0.4s ease-out; -} - -.stats-top-bar-labels { - position: absolute; - top: 0; - left: 8px; - right: 8px; - height: 100%; - display: flex; - align-items: center; - gap: 8px; - font-size: 10px; - color: rgba(255, 255, 255, 0.92); - font-weight: 600; - letter-spacing: 0.3px; - pointer-events: none; -} - -/* 30-day activity chart — vertical bar per day with optional date - label below every 7th column. */ -.stats-activity-row { - display: flex; - gap: 2px; - align-items: flex-end; - padding: 6px 0; -} - -.stats-day-col { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - min-width: 0; -} - -.stats-day-bar-track { - width: 100%; - height: 90px; - display: flex; - align-items: flex-end; -} - -.stats-day-bar-fill { - width: 100%; - background: var(--accent, #9146ff); - border-radius: 2px 2px 0 0; - transition: height 0.3s ease-out, background 0.2s; -} - -.stats-day-bar-fill:hover { - background: var(--accent-hover, #b97aff); -} - -.stats-day-label { - font-size: 9px; - color: var(--text-secondary); - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -.stats-activity-summary { - font-size: 12px; - color: var(--text-secondary); - margin-top: 6px; - font-variant-numeric: tabular-nums; -} - -/* Recording-size distribution buckets — one row per size bucket, - count + total bytes on the right, horizontal bar below. */ -.stats-bucket-row { - margin-bottom: 8px; -} - -.stats-bucket-row:last-child { - margin-bottom: 0; -} - -.stats-bucket-meta { - display: flex; - justify-content: space-between; - font-size: 13px; - margin-bottom: 3px; - gap: 8px; -} - -.stats-bucket-meta-sub { - color: var(--text-secondary); - font-variant-numeric: tabular-nums; -} - -.stats-bucket-bar-track { - background: var(--bg-elevated); - border-radius: 3px; - height: 12px; - overflow: hidden; -} - -.stats-bucket-bar-fill { - height: 100%; - background: var(--accent, #9146ff); - transition: width 0.4s ease-out; -} - -/* Old generic scrollbar rules were dead — superseded by the - purple-themed *::-webkit-scrollbar block further down the file. - Removed to avoid confusion when someone greps for scrollbar styles. */ - -/* Update Banner */ -.update-banner { - background: linear-gradient(90deg, var(--accent), #5a2d82); - padding: 10px 20px; - display: none; - justify-content: center; - align-items: center; - gap: 15px; - font-size: 13px; -} - -.update-banner.show { - display: flex; -} - -.update-banner button { - background: white; - color: var(--accent); - border: none; - border-radius: 4px; - padding: 6px 15px; - cursor: pointer; - font-weight: 600; -} - -.update-banner button:disabled { - opacity: 0.7; - cursor: not-allowed; -} - -/* Update-banner download progress — sits between the message and - the button, fills as the update download runs. */ -.update-banner-progress-wrap { - flex: 1; - margin: 0 15px; -} - -.update-banner-progress-track { - background: rgba(0, 0, 0, 0.3); - border-radius: 4px; - height: 8px; - overflow: hidden; -} - -.update-banner-progress-bar { - background: #fff; - height: 100%; - width: 0%; - transition: width 0.3s ease-out; -} - -.update-modal { - max-width: 680px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: - linear-gradient(180deg, rgba(145, 70, 255, 0.18) 0%, rgba(145, 70, 255, 0.05) 24%, rgba(14, 14, 16, 0.98) 100%), - var(--bg-card); - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.48); -} - -.update-modal-eyebrow { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 999px; - background: rgba(145, 70, 255, 0.16); - color: #f1e7ff; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - margin-bottom: 14px; -} - -.update-modal-message { - color: var(--text); - line-height: 1.6; - margin: -8px 0 12px; -} - -.update-modal-meta { - color: var(--text-secondary); - font-size: 12px; - margin-bottom: 16px; -} - -.update-modal-actions { - justify-content: flex-end; -} - -.update-changelog-card { - margin-top: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - background: rgba(7, 7, 10, 0.42); - overflow: hidden; -} - -.update-changelog-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 12px 14px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); -} - -.update-changelog-label { - font-size: 12px; - color: var(--text-secondary); - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.update-changelog-toggle { - background: transparent; - border: none; - color: #f3ecff; - cursor: pointer; - font-size: 13px; - font-weight: 600; -} - -.update-changelog-toggle:hover { - color: white; -} - -.update-changelog-panel { - display: grid; - grid-template-rows: 0fr; - max-height: 320px; - overflow: hidden; - padding: 0 14px; - opacity: 0; - transform: translateY(-4px); - transition: grid-template-rows 440ms cubic-bezier(0.22, 0.76, 0.22, 1), padding 440ms cubic-bezier(0.22, 0.76, 0.22, 1), opacity 320ms ease, transform 440ms cubic-bezier(0.22, 0.76, 0.22, 1); -} - -.update-changelog-panel.is-expanded { - grid-template-rows: 1fr; - padding: 14px; - opacity: 1; - transform: translateY(0); -} - -.update-changelog-panel-inner { - min-height: 0; - overflow: hidden; -} - -.update-changelog-panel.is-expanded .update-changelog-panel-inner { - overflow: auto; -} - -.update-changelog-content { - display: grid; - gap: 12px; -} - -.update-changelog-heading { - font-size: 17px; - line-height: 1.25; - color: #ffffff; - margin: 0; -} - -.update-changelog-paragraph { - margin: 0; - color: var(--text); - line-height: 1.6; -} - -.update-changelog-list { - margin: 0; - padding-left: 18px; - color: var(--text); - display: grid; - gap: 8px; -} - -.update-changelog-list li { - line-height: 1.5; -} - -.update-changelog-content strong { - color: #ffffff; - font-weight: 700; -} - -.update-changelog-empty { - margin: 0; - color: var(--text-secondary); - font-size: 13px; -} - -#updateProgressBar.downloading { - width: 30% !important; - animation: indeterminate 1.5s ease-in-out infinite; -} - -@keyframes indeterminate { - 0% { margin-left: 0; width: 30%; } - 50% { margin-left: 35%; width: 30%; } - 100% { margin-left: 70%; width: 30%; } -} - -.cutter-container { - width: 100%; - max-width: 1600px; - margin: 0 auto; - display: grid; - gap: 12px; -} - -#cutterTab .cutter-source-bar:has(~ .cutter-workspace.shown) { - display: none; -} - -@media (min-width: 1181px) and (min-height: 680px) { - #cutterTab { - overflow-y: hidden; - } - - #cutterTab .cutter-container { - height: 100%; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - } - - #cutterTab .cutter-container:has(.cutter-workspace.shown) { - grid-template-rows: minmax(0, 1fr) auto; - } - - #cutterTab .cutter-container:has(.cutter-workspace.shown):has(> .cutter-recovery-panel:not([hidden])) { - grid-template-rows: auto minmax(0, 1fr) auto; - } - - #cutterTab .cutter-workspace { - min-height: 0; - } - - #cutterTab .cutter-workspace:not(.shown) { - align-items: center; - } - - #cutterTab .cutter-workspace:not(.shown) .cutter-preview-panel { - width: min(1200px, 100%); - height: auto; - grid-template-rows: auto; - } - - #cutterTab .cutter-workspace:not(.shown) .video-preview { - height: auto; - aspect-ratio: 16 / 9; - } - - #cutterTab .cutter-sidebar, - #cutterTab .cutter-preview-panel { - min-height: 0; - overflow: hidden; - } - - #cutterTab .video-preview { - width: 100%; - height: 100%; - aspect-ratio: auto; - } - - #cutterTab .timeline { - height: 132px; - } - - #cutterTab .cutter-ruler { - height: 24px; - } - - #cutterTab .cutter-track, - #cutterTab .cutter-audio-track { - height: 54px; - } - - #cutterTab .timeline-selection, - #cutterTab .cutter-outside-shade { - top: 24px; - } - - #cutterTab .cutter-cut-overlays { - inset: 24px 0 0; - } -} - -.cutter-source-bar { - min-height: 60px; - padding: 10px 12px; - display: flex; - align-items: center; - gap: 12px; - background: var(--workspace-panel, var(--bg-card)); - border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); - border-radius: 8px; -} - -.cutter-source-copy { - min-width: 0; - flex: 1; - display: grid; - gap: 4px; -} - -.cutter-source-title, -.cutter-card-title { - color: var(--workspace-text, var(--text)); - font-size: 12px; - font-weight: 700; -} - -#cutterFilePath { - width: 100%; - padding: 0; - overflow: hidden; - color: var(--workspace-text-muted, var(--text-secondary)); - background: transparent; - border: 0; - outline: 0; - text-overflow: ellipsis; -} - -.cutter-recovery-panel { - display: flex; - align-items: center; - gap: 8px; - margin-top: 8px; - padding: 8px 10px; - color: var(--workspace-text, var(--text)); - background: var(--workspace-control, rgba(255, 255, 255, 0.04)); - border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); - border-radius: 8px; - font-size: 12px; -} - -.cutter-recovery-panel span { - flex: 1; -} - -.cutter-workspace { - display: grid; - grid-template-columns: 300px minmax(0, 1fr); - gap: 12px; - min-height: 420px; -} - -.cutter-workspace:not(.shown) { - grid-template-columns: minmax(0, 1fr); -} - -.cutter-workspace:not(.shown) .cutter-sidebar { - display: none; -} - -.cutter-workspace:not(.shown) .cutter-preview-panel { - width: min(1200px, 100%); - margin: 0 auto; -} - -.cutter-workspace:not(.shown) ~ .cutter-actions { - display: none; -} - -.cutter-sidebar, -.cutter-preview-panel { - min-width: 0; - background: var(--workspace-panel, var(--bg-card)); - border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); - border-radius: 8px; -} - -.cutter-sidebar { - padding: 14px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.cutter-sidebar-heading, -.cutter-card-title-row, -.cutter-timeline-toolbar, -.cutter-player-controls, -.cutter-cut-row-heading { - display: flex; - align-items: center; -} - -.cutter-sidebar-heading, -.cutter-card-title-row { - justify-content: space-between; -} - -.cutter-sidebar-heading h3 { - margin: 2px 0 0; - color: var(--workspace-text, var(--text)); - font-size: 16px; -} - -.cutter-eyebrow { - color: var(--workspace-primary, var(--accent)); - font-size: 9px; - font-weight: 800; - letter-spacing: 0.14em; -} - -.cutter-icon-button, -.cutter-player-button, -.cutter-cut-remove, -.timeline-handle, -.cutter-cut-handle { - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--workspace-text, var(--text)); - background: transparent; - border: 0; -} - -.cutter-icon-button { - width: 32px; - height: 32px; - border-radius: 6px; -} - -.cutter-icon-button:hover:not(:disabled) { - background: var(--workspace-control-hover, rgba(255, 255, 255, 0.08)); -} - -.cutter-icon-button svg, -.cutter-player-button svg, -.cutter-cut-remove svg { - width: 18px; - height: 18px; - fill: none; - stroke: currentColor; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.cutter-player-button .cutter-filled-icon { - fill: currentColor; - stroke: none; -} - -.cutter-play-icon, -.cutter-pause-icon { - fill: currentColor !important; - stroke: none !important; -} - -.cutter-preview-toggle { - padding: 10px; - display: flex; - align-items: center; - gap: 10px; - background: var(--workspace-control, rgba(255, 255, 255, 0.04)); - border-radius: 7px; - cursor: pointer; -} - -.cutter-preview-toggle > span:first-child { - min-width: 0; - flex: 1; - display: grid; - gap: 2px; -} - -.cutter-preview-toggle strong { - font-size: 12px; -} - -.cutter-preview-toggle small { - color: var(--workspace-text-muted, var(--text-secondary)); - font-size: 10px; -} - -.cutter-preview-toggle input { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.cutter-toggle-track { - width: 34px; - height: 18px; - position: relative; - flex: 0 0 auto; - background: var(--workspace-border-strong, rgba(255, 255, 255, 0.18)); - border-radius: 999px; - transition: background 160ms ease; -} - -.cutter-toggle-track::after { - content: ''; - width: 14px; - height: 14px; - position: absolute; - top: 2px; - left: 2px; - background: #fff; - border-radius: 50%; - transition: transform 180ms cubic-bezier(.2, .8, .2, 1); -} - -.cutter-preview-toggle input:checked + .cutter-toggle-track { - background: var(--workspace-primary, var(--accent)); -} - -.cutter-preview-toggle input:focus-visible + .cutter-toggle-track { - outline: 2px solid var(--workspace-primary, var(--accent)); - outline-offset: 3px; -} - -.cutter-preview-toggle input:checked + .cutter-toggle-track::after { - transform: translateX(16px); -} - -.cutter-export-options { - padding: 11px; - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 6px; - background: var(--workspace-control, rgba(255, 255, 255, 0.04)); - border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); - border-radius: 7px; -} - -.cutter-export-options label { - color: var(--workspace-text-muted, var(--text-secondary)); - font-size: 11px; -} - -.cutter-export-options select { - min-width: 0; -} - -.cutter-trim-card, -.cutter-cut-section { - padding: 11px; - display: grid; - gap: 9px; - background: var(--workspace-control, rgba(255, 255, 255, 0.04)); - border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); - border-radius: 7px; -} - -.cutter-cut-section { - min-height: 0; - flex: 1; - grid-template-rows: auto minmax(0, 1fr); -} - -.cutter-time-field-row { - display: grid; - grid-template-columns: 44px minmax(0, 1fr); - align-items: center; - gap: 8px; -} - -.cutter-time-field-row label { - color: var(--workspace-text-muted, var(--text-secondary)); - font-size: 11px; -} - -.cutter-time-field-row input, -.cutter-cut-fields input { - min-width: 0; - padding: 7px 8px; - color: var(--workspace-text, var(--text)); - background: var(--workspace-panel, var(--bg-card)); - border: 1px solid var(--workspace-border-strong, rgba(255, 255, 255, 0.14)); - border-radius: 5px; - font-family: Consolas, monospace; - font-size: 11px; - font-variant-numeric: tabular-nums; - font-feature-settings: 'tnum' 1; - line-height: 14px; - text-align: center; -} - -.cutter-cut-count { - min-width: 21px; - height: 21px; - padding: 0 6px; - display: inline-flex; - align-items: center; - justify-content: center; - color: #fff; - background: #d84a52; - border-radius: 999px; - font-size: 10px; - font-weight: 800; -} - -.cutter-cut-list { - min-height: 0; - overflow: auto; - display: grid; - align-content: start; - gap: 7px; -} - -.cutter-cut-empty { - padding: 20px 8px; - color: var(--workspace-text-muted, var(--text-secondary)); - font-size: 11px; - text-align: center; -} - -.cutter-cut-row { - padding: 7px; - display: grid; - gap: 7px; - background: var(--workspace-panel, var(--bg-card)); - border: 1px solid transparent; - border-radius: 6px; -} - -.cutter-cut-row.active { - border-color: #e05a62; - box-shadow: 0 0 0 1px rgba(224, 90, 98, 0.15); -} - -.cutter-cut-row-heading { - width: 100%; - padding: 0; - gap: 7px; - color: var(--workspace-text, var(--text)); - background: transparent; - border: 0; - text-align: left; -} - -.cutter-cut-row-heading strong { - flex: 1; - font-size: 11px; -} - -.cutter-cut-row-heading > span:last-child { - color: var(--workspace-text-muted, var(--text-secondary)); - font-family: Consolas, monospace; - font-size: 9px; -} - -.cutter-cut-color { - width: 7px; - height: 7px; - background: #dc4d56; - border-radius: 50%; -} - -.cutter-cut-fields { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 24px; - gap: 5px; -} - -.cutter-cut-remove { - width: 24px; - height: 28px; - color: var(--workspace-text-muted, var(--text-secondary)); - border-radius: 4px; -} - -.cutter-cut-remove:hover { - color: #ff747c; - background: rgba(220, 77, 86, 0.12); -} - -.cutter-cut-remove svg { - width: 14px; - height: 14px; -} - -.cutter-preview-panel { - padding: 12px; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: 10px; -} - -.video-preview { - min-height: 0; - aspect-ratio: 16 / 9; - position: relative; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - background: #050506; - border-radius: 7px; -} - -.video-preview video { - width: 100%; - height: 100%; - display: block; - object-fit: contain; -} - -.video-preview .placeholder, -.cutter-player-loading { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - color: var(--workspace-text-muted, var(--text-secondary)); - text-align: center; -} - -.video-preview .placeholder[hidden], -.cutter-player-loading[hidden] { - display: none; -} - -.video-preview .placeholder svg { - opacity: 0.28; -} - -.video-preview .placeholder p { - margin: 0; -} - -.cutter-spinner { - width: 24px; - height: 24px; - border: 3px solid rgba(255, 255, 255, 0.15); - border-top-color: var(--workspace-primary, var(--accent)); - border-radius: 50%; - animation: cutter-spin 700ms linear infinite; -} - -@keyframes cutter-spin { - to { transform: rotate(360deg); } -} - -.cutter-player-controls { - min-height: 48px; - padding: 14px 10px 8px; - position: absolute; - right: 0; - bottom: 0; - left: 0; - gap: 6px; - color: #fff; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.86)); - opacity: 0; - transform: translateY(4px); - transition: opacity 160ms ease, transform 160ms ease; -} - -.video-preview:hover .cutter-player-controls, -.video-preview:focus-within .cutter-player-controls, -.video-preview:not(.playing) .cutter-player-controls { - opacity: 1; - transform: translateY(0); -} - -.cutter-player-button { - min-width: 34.68px; - height: 34.68px; - padding: 0 6px; - color: #fff; - border-radius: 5px; - font-size: 10px; -} - -.cutter-player-button svg { - width: 20.4px; - height: 20.4px; -} - -.cutter-skip-button { - position: relative; -} - -.cutter-skip-button svg { - width: 25.5px; - height: 25.5px; -} - -.cutter-skip-button span { - position: absolute; - top: 50%; - left: 50%; - font-size: 8px; - font-weight: 700; - line-height: 8px; - transform: translate(-50%, -50%); - pointer-events: none; -} - -.cutter-player-button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.13); -} - -.cutter-pause-icon, -.video-preview.playing .cutter-play-icon { - display: none; -} - -.video-preview.playing .cutter-pause-icon { - display: block; -} - -.cutter-volume-control { - height: 34.68px; - display: inline-flex; - align-items: center; - flex: none; -} - -.cutter-volume { - --cutter-volume-progress: 100%; - width: 0; - height: 18px; - margin: 0; - padding: 0; - opacity: 0; - visibility: hidden; - pointer-events: none; - border: 0; - border-radius: 0; - outline: none; - background: linear-gradient(to right, var(--workspace-primary, var(--accent)) 0 var(--cutter-volume-progress), rgba(255, 255, 255, 0.42) var(--cutter-volume-progress) 100%) center / 100% 4px no-repeat; - box-shadow: none; - -webkit-appearance: none; - appearance: none; - transition: width 220ms cubic-bezier(0.2, 0.75, 0.25, 1), margin 220ms cubic-bezier(0.2, 0.75, 0.25, 1), opacity 150ms ease, visibility 0s linear 220ms; -} - -.cutter-volume::-webkit-slider-runnable-track { - height: 4px; - border: 0; - background: transparent; -} - -.cutter-volume::-webkit-slider-thumb { - width: 14px; - height: 14px; - margin-top: -5px; - border: 0; - border-radius: 50%; - background: #fff; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); - -webkit-appearance: none; - appearance: none; -} - -.cutter-volume-control:not(.disabled):hover .cutter-volume, -.cutter-volume-control:not(.disabled):focus-within .cutter-volume { - width: 80px; - margin: 0 4px; - opacity: 1; - visibility: visible; - pointer-events: auto; - transition-delay: 0s; -} - -.cutter-volume-control.disabled { - pointer-events: none; -} - -.cutter-player-time { - min-width: 142px; - flex: 1; - display: flex; - align-items: center; - gap: 4px; - font-family: Consolas, monospace; - font-size: 12px; - font-variant-numeric: tabular-nums; - font-feature-settings: 'tnum' 1; - line-height: 16px; - white-space: nowrap; -} - -.cutter-player-time > span:not(:nth-child(2)) { - width: 62px; - display: inline-block; - text-align: center; - contain: layout; -} - -.cutter-player-settings { - position: relative; -} - -.cutter-settings-menu { - width: 190px; - padding: 10px; - position: absolute; - z-index: 30; - right: 0; - bottom: 38px; - color: #fff; - background: rgba(24, 24, 26, 0.96); - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 7px; - box-shadow: 0 12px 35px rgba(0, 0, 0, 0.38); -} - -.cutter-settings-menu[hidden] { - display: none; -} - -.cutter-settings-menu > span { - display: block; - margin-bottom: 7px; - color: rgba(255, 255, 255, 0.68); - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.cutter-speed-options { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 5px; -} - -.cutter-speed-options button { - min-height: 30px; - padding: 4px; - color: rgba(255, 255, 255, 0.78); - background: rgba(255, 255, 255, 0.06); - border: 1px solid transparent; - border-radius: 5px; - font-size: 9px; -} - -.cutter-speed-options button:hover, -.cutter-speed-options button.active { - color: #fff; - background: rgba(255, 255, 255, 0.14); - border-color: rgba(255, 255, 255, 0.12); -} - -.cutter-info { - display: none; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.cutter-info.shown { - display: grid; -} - -.cutter-info-item { - min-width: 0; - padding: 9px; - display: grid; - grid-template-rows: 12px 18px; - align-content: center; - gap: 3px; - background: var(--workspace-control, rgba(255, 255, 255, 0.04)); - border-radius: 6px; - text-align: center; -} - -.cutter-info-label { - color: var(--workspace-text-muted, var(--text-secondary)); - font-size: 9px; - line-height: 12px; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.cutter-info-value { - overflow: hidden; - color: var(--workspace-text, var(--text)); - font-family: Consolas, monospace; - font-size: 13px; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 18px; - text-overflow: ellipsis; -} - -.timeline-container { - display: none; - overflow: hidden; - background: var(--workspace-panel, var(--bg-card)); - border: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.1)); - border-radius: 8px; -} - -.timeline-container.shown { - display: block; -} - -.cutter-timeline-toolbar { - min-height: 44px; - padding: 6px 10px; - gap: 8px; - border-bottom: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); -} - -.cutter-timeline-timecode { - width: 96px; - min-width: 96px; - color: var(--workspace-primary, var(--accent)); - font-family: Consolas, monospace; - font-size: 13px; - font-weight: 700; - font-variant-numeric: tabular-nums; - font-feature-settings: 'tnum' 1; - line-height: 16px; - contain: layout; -} - -.cutter-history-controls, -.cutter-zoom-controls { - display: flex; - align-items: center; - gap: 2px; -} - -.cutter-zoom-controls { - margin-left: auto; -} - -#cutterZoom { - width: 110px; - accent-color: var(--workspace-primary, var(--accent)); -} - -.cutter-timeline-scroll { - overflow-x: scroll; - overflow-y: hidden; - scrollbar-width: thin; -} - -.timeline { - width: 100%; - min-width: 100%; - height: 188px; - position: relative; - overflow: hidden; - background: var(--workspace-control, var(--bg-main)); - cursor: crosshair; - user-select: none; -} - -.cutter-ruler { - height: 28px; - position: relative; - border-bottom: 1px solid var(--workspace-border, rgba(255, 255, 255, 0.08)); -} - -.cutter-ruler-tick { - position: absolute; - bottom: 5px; - color: var(--workspace-text-muted, var(--text-secondary)); - font-family: Consolas, monospace; - font-size: 12px; - font-weight: 600; - line-height: 14px; - transform: translateX(-50%); -} - -.cutter-ruler-tick:first-child { - transform: none; -} - -.cutter-ruler-tick:last-child { - transform: translateX(-100%); -} - -.cutter-ruler-tick:first-child::after { - left: 0; -} - -.cutter-ruler-tick:last-child::after { - left: 100%; -} - -.cutter-ruler-tick::after { - content: ''; - width: 1px; - height: 4px; - position: absolute; - bottom: -5px; - left: 50%; - background: var(--workspace-border-strong, rgba(255, 255, 255, 0.16)); -} - -.cutter-track { - height: 80px; - position: relative; - overflow: hidden; - background: #111215; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); -} - -.cutter-track-label { - padding: 3px 5px; - position: absolute; - z-index: 3; - top: 4px; - left: 20px; - color: rgba(255, 255, 255, 0.76); - background: rgba(0, 0, 0, 0.62); - border-radius: 3px; - font-size: 8px; - font-weight: 800; - letter-spacing: 0.08em; -} - -.cutter-thumbnail-strip { - width: 100%; - height: 100%; - display: flex; - overflow: hidden; -} - -.cutter-thumbnail-strip img { - height: 100%; - object-fit: cover; - pointer-events: none; -} - -.cutter-thumbnail-strip img.cutter-thumbnail-sprite { - display: none; -} - -.cutter-thumbnail-tile { - min-width: 0; - height: 100%; - display: block; - pointer-events: none; -} - -.cutter-audio-track { - height: 80px; - background: #16171b; -} - -#cutterWaveform { - width: 100%; - height: 100%; - display: block; - object-fit: fill; - opacity: 1; - filter: none; - pointer-events: none; -} - -#cutterWaveform[hidden] { - display: none; -} - -.cutter-audio-empty { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--workspace-text-muted, var(--text-secondary)); - font-size: 10px; -} - -.cutter-audio-empty[hidden] { - display: none; -} - -.timeline-selection { - position: absolute; - z-index: 6; - top: 28px; - bottom: 0; - border-top: 2px solid #5c9cff; - border-bottom: 2px solid #5c9cff; - pointer-events: none; -} - -.timeline-handle { - width: 12px; - position: absolute; - top: -2px; - bottom: -2px; - background: #5c9cff; - border: 2px solid #c7dcff; - border-radius: 3px; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px rgba(92, 156, 255, 0.3); - cursor: ew-resize; - pointer-events: auto; -} - -.timeline-handle::after { - content: ''; - width: 2px; - height: 18px; - background: rgba(0, 0, 0, 0.35); - border-radius: 2px; -} - -.timeline-handle.start { - left: 0; - transform: none; -} - -.timeline-handle.end { - right: 0; - transform: none; -} - -.cutter-outside-shade { - position: absolute; - z-index: 4; - top: 28px; - bottom: 0; - background: rgba(0, 0, 0, 0.66); - pointer-events: none; -} - -.cutter-cut-overlays { - position: absolute; - z-index: 7; - inset: 28px 0 0; - pointer-events: none; -} - -.cutter-cut-overlay { - min-width: 5px; - position: absolute; - top: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - background: rgba(181, 57, 66, 0.5); - border: 1px solid rgba(255, 121, 129, 0.72); - cursor: grab; - pointer-events: auto; - transition: background 120ms ease, box-shadow 120ms ease; -} - -.cutter-cut-overlay:hover, -.cutter-cut-overlay.active { - background: rgba(218, 61, 72, 0.72); - box-shadow: inset 0 0 0 1px rgba(255, 206, 209, 0.28); -} - -.cutter-cut-overlay > span { - min-width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(105, 18, 25, 0.8); - border-radius: 4px; - font-size: 9px; - font-weight: 800; - pointer-events: none; -} - -.cutter-cut-handle { - width: 12px; - position: absolute; - top: -1px; - bottom: -1px; - display: flex; - align-items: center; - justify-content: center; - background: #f06b73; - border: 2px solid #ffd0d3; - border-radius: 3px; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 0 12px rgba(240, 107, 115, 0.3); - cursor: ew-resize; -} - -.cutter-cut-handle::after { - content: ''; - width: 2px; - height: 18px; - background: rgba(0, 0, 0, 0.35); - border-radius: 2px; -} - -.cutter-cut-handle.start { left: 0; } -.cutter-cut-handle.end { right: 0; } - -.timeline-current { - width: 2px; - position: absolute; - z-index: 8; - top: 20px; - bottom: 0; - background: #fff; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.22); - pointer-events: none; -} - -.timeline-current span { - width: 0; - height: 0; - position: absolute; - top: -1px; - left: 50%; - border-top: 0; - border-right: 6px solid transparent; - border-bottom: 8px solid #fff; - border-left: 6px solid transparent; - transform: translate(-50%, -100%) rotate(180deg); -} - -.cutter-dragging, -.cutter-dragging * { - cursor: ew-resize !important; -} - -.cutter-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-left: 8px; -} - -.cutter-actions #btnCut { - min-width: 160px; -} - -@media (max-width: 1180px) { - .cutter-workspace { - grid-template-columns: 1fr; - } - - .cutter-sidebar { - max-height: 360px; - } - - .cutter-cut-list { - max-height: 170px; - } - - .cutter-timeline-toolbar { - flex-wrap: wrap; - } - - .cutter-actions { - width: 100%; - margin-left: 0; - } -} - -/* Merge Styles */ -.merge-container { - max-width: 800px; - margin: 0 auto; -} - -.file-list { - background: var(--bg-card); - border-radius: 8px; - padding: 20px; - margin-bottom: 20px; - min-height: 200px; -} - -.file-item { - display: flex; - align-items: center; - gap: 15px; - padding: 12px 15px; - background: var(--bg-main); - border-radius: 6px; - margin-bottom: 10px; -} - -.file-item .file-order { - width: 30px; - height: 30px; - background: var(--accent); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 600; - font-size: 14px; -} - -.file-item .file-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-item .file-actions { - display: flex; - gap: 8px; -} - -.file-item .file-btn { - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 5px; - font-size: 16px; -} - -.file-item .file-btn:hover { - color: var(--text); -} - -.file-item .file-btn:focus-visible { - outline: none; - border-radius: 4px; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); - color: var(--text); -} - -.file-item .file-btn.remove:hover { - color: var(--error); -} - -.file-item .file-btn.remove:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.65); - color: var(--error); -} - -.merge-actions { - display: flex; - gap: 10px; - justify-content: center; -} - -/* Progress Bar */ -.progress-container { - background: var(--bg-card); - border-radius: 8px; - padding: 20px; - margin-bottom: 20px; - display: none; -} - -.progress-container.show { - display: block; -} - -.progress-bar { - height: 8px; - background: var(--bg-main); - border-radius: 4px; - overflow: hidden; - margin-bottom: 10px; -} - -.progress-bar-fill { - height: 100%; - background: var(--accent); - transition: width 0.3s; -} - -.progress-text { - text-align: center; - color: var(--text-secondary); - font-size: 14px; -} - -/* Theme variations */ -body.theme-discord { - --bg-main: #36393f; - --bg-sidebar: #202225; - --bg-card: #2f3136; - --accent: #5865F2; - --accent-hover: #4752C4; -} - -body.theme-youtube { - --bg-main: #0f0f0f; - --bg-sidebar: #0f0f0f; - --bg-card: #272727; - --accent: #FF0000; - --accent-hover: #cc0000; -} - -body.theme-apple { - --bg-main: #1c1c1e; - --bg-sidebar: #2c2c2e; - --bg-card: #3a3a3c; - --accent: #0A84FF; - --accent-hover: #0071e3; -} - -body.theme-light { - --bg-main: #f0f2f5; - --bg-sidebar: #ffffff; - --bg-card: #e4e6ea; - --text: #1a1a2e; - --text-secondary: #65676b; - --accent: #9146ff; - --accent-hover: #772ce8; - --success: #00c853; - --error: #e41e3f; - --warning: #e68a00; - --border-soft: rgba(0, 0, 0, 0.12); -} - -/* Light theme: swap white-alpha borders/backgrounds to black-alpha */ -body.theme-light .sidebar, -body.theme-light .queue-section, -body.theme-light .logo, -body.theme-light .stats-bar, -body.theme-light .header, -body.theme-light .status-bar { - border-color: rgba(0,0,0,0.1); -} - -body.theme-light .add-streamer input, -body.theme-light .form-group input:not([type="checkbox"]):not([type="radio"]), -body.theme-light .form-group select, -body.theme-light .form-stack input:not([type="checkbox"]):not([type="radio"]), -body.theme-light .form-stack select, -body.theme-light .input-narrow, -body.theme-light .clip-input input, -body.theme-light .time-input-group input, -body.theme-light .part-number-group input, -body.theme-light .btn-secondary, -body.theme-light .lang-option, -body.theme-light .log-panel, -body.theme-light .template-guide-table-wrap, -body.theme-light .template-guide-preview-box { - border-color: rgba(0,0,0,0.12); -} - -body.theme-light .lang-option:hover { - border-color: rgba(0,0,0,0.26); -} - -body.theme-light .streamer-item:hover { - background: rgba(0,0,0,0.05); -} - -body.theme-light .vod-btn.secondary { - background: rgba(0,0,0,0.08); -} - -body.theme-light .vod-btn.secondary:hover { - background: rgba(0,0,0,0.12); -} - -body.theme-light .nav-item:hover { - background: rgba(145, 71, 255, 0.1); -} - -body.theme-light ::-webkit-scrollbar-thumb { - background: rgba(0,0,0,0.15); -} - -body.theme-light ::-webkit-scrollbar-thumb:hover { - background: rgba(0,0,0,0.25); -} - -body.theme-light .update-modal { - border-color: rgba(0,0,0,0.1); - background: - linear-gradient(180deg, rgba(145, 70, 255, 0.12) 0%, rgba(145, 70, 255, 0.03) 24%, rgba(240, 242, 245, 0.98) 100%), - var(--bg-card); -} - -body.theme-light .update-modal-eyebrow { - color: #4a2a8a; -} - -body.theme-light .update-changelog-card { - border-color: rgba(0,0,0,0.08); - background: rgba(255, 255, 255, 0.6); -} - -body.theme-light .update-changelog-header { - border-bottom-color: rgba(0,0,0,0.06); -} - -body.theme-light .update-changelog-toggle { - color: #4a2a8a; -} - -body.theme-light .update-changelog-toggle:hover { - color: #1a1a2e; -} - -body.theme-light .update-changelog-heading, -body.theme-light .update-changelog-content strong { - color: #1a1a2e; -} - -body.theme-light .template-guide-preview-box { - background: rgba(0, 0, 0, 0.04); -} - -body.theme-light .template-guide-output { - background: rgba(0, 0, 0, 0.06); -} - -body.theme-light .template-guide-table th, -body.theme-light .template-guide-table td { - border-bottom-color: rgba(0,0,0,0.08); -} - -body.theme-light .log-panel { - background: #f8f9fb; - color: #2c3e50; -} - -body.theme-light .app-toast { - background: rgba(255, 255, 255, 0.96); - color: #1a1a2e; - border-color: rgba(0,0,0,0.12); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); -} - -body.theme-light .btn-retry { - background: #dce4f0; - color: #2a3344; -} - -body.theme-light .btn-retry:hover { - background: #c8d4e8; -} - -body.theme-light .btn-clear:hover { - background: #d0d2d6; -} - -body.theme-light .modal { - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); -} - -/* Modal Styles */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.65); - display: none; - justify-content: center; - align-items: center; - z-index: 1000; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - animation: modal-overlay-fade 0.2s ease-out; -} - -.modal-overlay.show { - display: flex; -} - -@keyframes modal-overlay-fade { - from { opacity: 0; } - to { opacity: 1; } -} - -.modal { - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: 14px; - padding: 25px 28px; - width: 90%; - max-width: 500px; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(145, 70, 255, 0.10); - animation: modal-pop 0.22s cubic-bezier(0.16, 1, 0.3, 1); - position: relative; -} - -@keyframes modal-pop { - from { opacity: 0; transform: scale(0.96) translateY(8px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.modal h2 { - margin-bottom: 18px; - font-size: 18px; - font-weight: 600; - letter-spacing: -0.2px; - color: var(--text); -} - -.modal-close { - position: absolute; - top: 14px; - right: 14px; - width: 30px; - height: 30px; - background: transparent; - border: 1px solid var(--border-soft); - border-radius: 8px; - color: var(--text-secondary); - font-size: 16px; - cursor: pointer; - padding: 0; - line-height: 1; - transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.12s; -} - -.modal-close:hover { - color: #fff; - background: rgba(255, 70, 70, 0.18); - border-color: rgba(255, 70, 70, 0.55); -} - -.modal-close:focus-visible { - outline: none; - border-color: rgba(255, 70, 70, 0.6); - box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.6); -} - -.modal-close:active { - transform: scale(0.92); -} - -.slider-group { - margin-bottom: 20px; -} - -.slider-group label { - display: block; - margin-bottom: 8px; - color: var(--text-secondary); - font-size: 13px; -} - -/* ============================================ - RANGE SLIDER — Twitch-purple track + thumb - ============================================ - Track gets a subtle purple-tint behind a darker base so the slider - reads as part of the same family as the queue progress bar. Thumb - is a 16px purple circle with a hover halo. */ -.slider-group input[type="range"], -.modal input[type="range"] { - width: 100%; - height: 6px; - -webkit-appearance: none; - appearance: none; - background: linear-gradient(90deg, rgba(145, 70, 255, 0.18) 0%, rgba(20, 20, 24, 0.95) 100%); - border-radius: 999px; - outline: none; - cursor: pointer; - transition: box-shadow 0.18s; -} - -.slider-group input[type="range"]:focus-visible, -.modal input[type="range"]:focus-visible { - box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.22); -} - -.slider-group input[type="range"]::-webkit-slider-thumb, -.modal input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - background: var(--accent); - border: 2px solid #fff; - border-radius: 50%; - cursor: pointer; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); - transition: transform 0.15s, box-shadow 0.15s; -} - -.slider-group input[type="range"]::-moz-range-thumb, -.modal input[type="range"]::-moz-range-thumb { - width: 16px; - height: 16px; - background: var(--accent); - border: 2px solid #fff; - border-radius: 50%; - cursor: pointer; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); - transition: transform 0.15s, box-shadow 0.15s; -} - -.slider-group input[type="range"]:hover::-webkit-slider-thumb, -.modal input[type="range"]:hover::-webkit-slider-thumb { - background: var(--accent-hover); - transform: scale(1.15); - box-shadow: 0 3px 12px rgba(145, 70, 255, 0.55); -} - -.slider-group input[type="range"]:hover::-moz-range-thumb, -.modal input[type="range"]:hover::-moz-range-thumb { - background: var(--accent-hover); - transform: scale(1.15); - box-shadow: 0 3px 12px rgba(145, 70, 255, 0.55); -} - -/* ============================================ - NUMBER INPUT — hide OS spinners, rely on keyboard / scroll - ============================================ - The default Webkit spinners are a tiny gray arrow stack that always - reads as "unfinished input field" no matter the theme. Hidden across - all number inputs; users type or use arrow keys. Spinner-on-hover - pattern could come back as a custom thing later if needed. */ -input[type="number"] { - -moz-appearance: textfield; -} - -input[type="number"]::-webkit-inner-spin-button, -input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - appearance: none; - margin: 0; -} - -.clip-time-display { - display: flex; - justify-content: space-between; - margin-top: 8px; - font-family: monospace; - font-size: 14px; -} - -.clip-info-row { - background: var(--bg-main); - padding: 12px 15px; - border-radius: 6px; - margin-bottom: 15px; - text-align: center; -} - -.clip-info-row .label { - color: var(--text-secondary); - font-size: 12px; - margin-bottom: 4px; -} - -.clip-info-row .value { - font-size: 18px; - font-weight: 600; - color: var(--success); -} - -.clip-info-row .value.error { - color: var(--error); -} - -.part-number-group { - margin-bottom: 20px; -} - -.part-number-group input { - width: 100px; - background: var(--bg-main); - border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; - padding: 8px 12px; - color: var(--text); - font-size: 14px; -} - -.part-number-group small { - display: block; - margin-top: 5px; - color: var(--text-secondary); - font-size: 11px; -} - -.modal-actions { - display: flex; - gap: 10px; - margin-top: 20px; -} - -.modal-actions button { - flex: 1; -} - -.template-guide-modal { - max-width: 860px; -} - -.template-guide-intro { - color: var(--text-secondary); - margin-bottom: 14px; - line-height: 1.5; -} - -.template-guide-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-bottom: 12px; -} - -.template-guide-actions .btn-secondary { - padding: 8px 12px; - min-width: 140px; -} - -.template-guide-actions .btn-secondary.active { - background: var(--accent); - color: #fff; - border-color: transparent; -} - -.template-guide-label { - display: block; - margin-bottom: 6px; - font-size: 13px; - color: var(--text-secondary); -} - -.template-guide-input { - width: 100%; - font-family: Consolas, "Courier New", monospace; - margin-bottom: 10px; -} - -.template-guide-preview-box { - background: rgba(0, 0, 0, 0.22); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - padding: 10px; - margin-bottom: 14px; -} - -.template-guide-preview-label { - font-size: 12px; - color: var(--text-secondary); - margin-bottom: 6px; -} - -.template-guide-output { - font-family: Consolas, "Courier New", monospace; - color: var(--text); - word-break: break-word; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - padding: 8px; -} - -.template-guide-context { - margin-top: 6px; - font-size: 12px; - color: var(--text-secondary); -} - -.template-guide-vars-title { - margin: 0 0 8px; - font-size: 14px; -} - -.template-guide-table-wrap { - max-height: 280px; - overflow: auto; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - margin-bottom: 12px; -} - -.template-guide-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -.template-guide-table th, -.template-guide-table td { - text-align: left; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - padding: 8px; - vertical-align: top; -} - -.template-guide-table tbody tr:last-child td { - border-bottom: 0; -} - -.template-guide-table td:first-child, -.template-guide-table td:last-child { - font-family: Consolas, "Courier New", monospace; -} - -.template-guide-footer { - display: flex; - justify-content: flex-end; -} - -.app-toast { - position: fixed; - right: 18px; - bottom: 16px; - z-index: 2200; - max-width: min(90vw, 520px); - background: linear-gradient(135deg, rgba(28, 28, 34, 0.98), rgba(20, 20, 24, 0.98)); - color: #e6e6ea; - border: 1px solid rgba(255, 255, 255, 0.10); - border-left: 3px solid var(--accent); - border-radius: 10px; - padding: 12px 16px 12px 14px; - font-size: 13px; - line-height: 1.45; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(145, 70, 255, 0.12); - opacity: 0; - transform: translateX(20px); - pointer-events: none; - transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); - backdrop-filter: blur(8px); -} - -.app-toast.show { - opacity: 1; - transform: translateX(0); -} - -.app-toast.warn { - border-left-color: var(--warning); - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 167, 38, 0.25); -} - -/* ============================================ - STREAMER SECTION COUNTER - ============================================ - Tiny "X · Y live" line next to the "Streamer" section heading. - Updated by renderStreamers on every redraw. */ -.streamer-section-counter { - font-size: 11px; - color: var(--text-secondary); - font-weight: 400; - letter-spacing: 0.2px; -} - -/* Empty-state hint inside the sidebar streamer list (no streamers - added yet). Subtler than the full-page .empty-state — fits the - narrow sidebar context. */ -.streamer-list-empty { - padding: 12px 14px; - margin: 4px 8px; - color: var(--text-secondary); - font-size: 12px; - line-height: 1.45; - border: 1px dashed var(--border-soft); - border-radius: 6px; - text-align: center; - background: rgba(255, 255, 255, 0.02); -} - -/* ============================================ - VOD DURATION BADGE — Twitch-style pill on the thumbnail - ============================================ - Sits inside .vod-thumb-wrap so the absolute positioning anchors - to the thumbnail bounds, not the whole card (which would push - the badge past the action buttons at the bottom — regression - reported in 4.6.44 screenshot). */ -.vod-thumb-wrap { - position: relative; - line-height: 0; -} - -.vod-thumb-wrap .vod-thumbnail { - display: block; -} - -.vod-duration-badge { - position: absolute; - bottom: 8px; - right: 8px; - background: rgba(0, 0, 0, 0.78); - color: #fff; - font-size: 11px; - font-weight: 600; - padding: 3px 7px; - border-radius: 3px; - z-index: 1; - letter-spacing: 0.3px; - backdrop-filter: blur(2px); - pointer-events: none; -} - -.vod-card:hover .vod-duration-badge { - background: rgba(0, 0, 0, 0.88); -} - -.vod-card.preview-active .vod-downloaded-badge { - opacity: 0; - transition: opacity 0.2s; -} - -/* ============================================ - CHAT VIEWER — Twitch-chat-like message rows - ============================================ - Replaces the inline-style chat row inside the chat-viewer modal - with proper class-based styling. Renderer still uses inline - per-message colour for the username (driven by Twitch's IRC color - metadata). */ -.chat-viewer-row { - box-sizing: border-box; - display: flex; - align-items: center; - height: 29px; - padding: 4px 8px; - line-height: 1.55; - border-radius: 4px; - transition: background 0.12s; - font-size: 13px; - overflow: hidden; - white-space: nowrap; -} - -.chat-viewer-virtual-canvas { - position: relative; - min-height: 100%; -} - -.chat-viewer-virtual-rows { - position: absolute; - inset: 0 0 auto; - width: 100%; -} - -.chat-viewer-row:hover { - background: rgba(255, 255, 255, 0.04); -} - -.chat-viewer-row[aria-selected="true"], -.event-viewer-row[aria-selected="true"] { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.viewer-detail-modal { - display: none; - position: fixed; - inset: 0; - z-index: 2200; - align-items: center; - justify-content: center; - padding: 24px; - background: rgba(0, 0, 0, 0.66); -} - -.viewer-detail-modal.show { - display: flex; -} - -.viewer-detail-dialog { - width: min(760px, 100%); - max-height: min(680px, 100%); - display: flex; - flex-direction: column; - border: 1px solid var(--border-soft); - border-radius: 10px; - background: var(--bg-card); - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); -} - -.viewer-detail-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 16px 18px; - border-bottom: 1px solid var(--border-soft); -} - -.viewer-detail-header h2 { - margin: 0; - font-size: 16px; -} - -.viewer-detail-close { - width: 32px; - height: 32px; - border: 0; - border-radius: 6px; - color: var(--text); - background: transparent; - font-size: 24px; - line-height: 1; -} - -.viewer-detail-close:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.viewer-detail-text { - overflow: auto; - padding: 18px; - color: var(--text); - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.chat-viewer-row .chat-viewer-time { - color: var(--text-secondary); - margin-right: 8px; - font-size: 10px; - opacity: 0.7; - font-family: 'Segoe UI Mono', 'Consolas', monospace; -} - -.chat-viewer-row .chat-viewer-user { - font-weight: 700; - margin-right: 4px; - color: var(--accent); -} - -.chat-viewer-row > span:last-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-viewer-row .chat-viewer-tag { - color: var(--accent); - font-style: italic; - font-size: 11px; - margin-right: 6px; - background: rgba(145, 70, 255, 0.12); - padding: 1px 6px; - border-radius: 3px; - border: 1px solid rgba(145, 70, 255, 0.3); -} - -.chat-viewer-row.is-system { - background: rgba(145, 70, 255, 0.05); - border-left: 2px solid rgba(145, 70, 255, 0.45); - padding-left: 10px; -} - -/* ============================================ - EVENTS VIEWER — timeline rows - ============================================ - Per-event-type colours live here via [data-type] attribute - selectors so the renderer just stamps the type and the CSS - handles the palette. Add a new event type by extending this - block, not the renderer. */ -.event-viewer-row { - box-sizing: border-box; - display: flex; - align-items: center; - height: 36px; - padding: 8px 10px; - border-bottom: 1px solid var(--border-soft); - font-size: 12px; - gap: 8px; - overflow: hidden; - white-space: nowrap; -} - -.event-viewer-virtual-canvas { - position: relative; - min-height: 100%; -} - -.event-viewer-virtual-rows { - position: absolute; - inset: 0 0 auto; - width: 100%; -} - -.event-viewer-row:last-child { - border-bottom: none; -} - -.event-viewer-time { - color: var(--text-secondary); - flex: 0 0 auto; - font-family: 'Consolas', 'Segoe UI Mono', monospace; -} - -/* Empty state inside the events-viewer modal — shown when an events - file exists but contains no parsed entries. */ -.event-viewer-empty { - color: var(--text-secondary); - padding: 12px; - text-align: center; -} - -.event-viewer-tag { - font-weight: 600; - flex: 0 0 auto; - color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.3px; - font-size: 11px; - padding: 2px 7px; - border-radius: 3px; - background: rgba(145, 70, 255, 0.10); - border: 1px solid rgba(145, 70, 255, 0.25); -} - -.event-viewer-tag[data-type="recording_start"] { - color: #00c853; - background: rgba(0, 200, 83, 0.10); - border-color: rgba(0, 200, 83, 0.30); -} - -.event-viewer-tag[data-type="recording_end"] { - color: #9146ff; - background: rgba(145, 70, 255, 0.10); - border-color: rgba(145, 70, 255, 0.30); -} - -.event-viewer-tag[data-type="recording_resume"] { - color: #2196f3; - background: rgba(33, 150, 243, 0.10); - border-color: rgba(33, 150, 243, 0.30); -} - -.event-viewer-tag[data-type="title_change"] { - color: #ffab00; - background: rgba(255, 171, 0, 0.10); - border-color: rgba(255, 171, 0, 0.30); -} - -.event-viewer-tag[data-type="game_change"] { - color: #ff4444; - background: rgba(255, 68, 68, 0.10); - border-color: rgba(255, 68, 68, 0.30); -} - -.event-viewer-detail { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text); -} - -/* ============================================ - STREAMER PROFILE HEADER - ============================================ - Polished channel-page-style header that shows up above the VOD grid - when a streamer is selected. Modeled on Twitch's own profile header - for instant familiarity, but trimmed for the desktop-app context. */ -.streamer-profile-header { - position: sticky; - top: -25px; /* negate the .content top padding so the header pins flush with the visible top edge */ - z-index: 100; - display: block; - padding: 0; - margin-top: -2px; - margin-bottom: 14px; - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: 12px; - overflow: hidden; - animation: profile-fade-in 0.32s ease-out; - isolation: isolate; /* new stacking context so VODs below cannot leak above */ - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35); -} - -/* Dimming gradient sits ABOVE the banner-bg but BELOW the content row. - Gives the banner room to breathe while keeping name + bio readable. */ -.streamer-profile-header::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(135deg, rgba(15, 15, 18, 0.55) 0%, rgba(15, 15, 18, 0.78) 100%); - z-index: 1; - pointer-events: none; -} - -.streamer-profile-row { - position: relative; - z-index: 2; - display: flex; - gap: 18px; - align-items: center; - padding: 18px 22px; -} - -.streamer-profile-banner-bg { - position: absolute; - inset: 0; - background-size: cover; - background-position: center; - filter: blur(10px) saturate(1.35); - opacity: 1; - pointer-events: none; - z-index: 0; - transform: scale(1.12); /* hide the blur edge bleed inside the rounded corner clip */ -} - -@keyframes profile-fade-in { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } -} - -.streamer-profile-header.is-live::before { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - border-radius: 12px; - box-shadow: inset 0 0 0 1px rgba(233, 25, 22, 0.4); -} - -.streamer-profile-avatar-wrap { - position: relative; - flex-shrink: 0; - cursor: pointer; - transition: transform 0.2s; -} - -.streamer-profile-avatar-wrap:hover { - transform: scale(1.04); -} - -.streamer-profile-avatar-wrap:focus-visible { - outline: none; - border-radius: 50%; - box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.55); -} - -.streamer-profile-live-card:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(233, 25, 22, 0.55), 0 6px 22px rgba(233, 25, 22, 0.20); -} - -.streamer-profile-avatar { - width: 88px; - height: 88px; - border-radius: 50%; - object-fit: cover; - background: var(--bg-elevated); - border: 3px solid rgba(145, 70, 255, 0.6); - box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30); -} - -.streamer-profile-avatar.is-live { - border-color: #e91916; - animation: profile-live-ring 1.6s ease-in-out infinite; -} - -@keyframes profile-live-ring { - 0%, 100% { box-shadow: 0 0 0 0 rgba(233, 25, 22, 0.5), 0 4px 18px rgba(0, 0, 0, 0.30); } - 50% { box-shadow: 0 0 0 8px rgba(233, 25, 22, 0), 0 4px 18px rgba(0, 0, 0, 0.30); } -} - -.streamer-profile-avatar-fallback { - width: 88px; - height: 88px; - border-radius: 50%; - background: linear-gradient(135deg, #9146ff, #00c853); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 32px; - font-weight: 700; - border: 3px solid rgba(145, 70, 255, 0.6); - box-shadow: 0 4px 18px rgba(0, 0, 0, 0.30); -} - -.streamer-profile-body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -.streamer-profile-name-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.streamer-profile-display-name { - font-size: 22px; - font-weight: 700; - color: var(--text); - line-height: 1.1; - letter-spacing: -0.2px; -} - -.streamer-profile-login { - font-size: 13px; - color: var(--text-secondary); - font-weight: 500; -} - -.streamer-profile-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 10px; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.4px; -} - -.streamer-profile-badge.partner { - background: rgba(145, 70, 255, 0.18); - color: #9146ff; - border: 1px solid rgba(145, 70, 255, 0.5); -} - -.streamer-profile-badge.affiliate { - background: rgba(0, 200, 83, 0.15); - color: #00c853; - border: 1px solid rgba(0, 200, 83, 0.45); -} - -.streamer-profile-badge.live { - background: #e91916; - color: #fff; - border: 1px solid #e91916; - animation: profile-live-blink 1.6s ease-in-out infinite; -} - -.streamer-profile-badge.live::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 50%; - background: #fff; -} - -@keyframes profile-live-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.75; } -} - -.streamer-profile-bio { - font-size: 13px; - color: var(--text-secondary); - line-height: 1.45; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - margin-top: 2px; -} - -.streamer-profile-live-info { - font-size: 13px; - color: var(--text); - background: rgba(233, 25, 22, 0.08); - border-left: 3px solid #e91916; - padding: 6px 10px; - border-radius: 0 4px 4px 0; - margin-top: 4px; -} - -.streamer-profile-live-info strong { - color: #ff6b6b; - font-weight: 600; -} - -.streamer-profile-stats { - display: flex; - gap: 18px; - flex-wrap: wrap; - margin-top: 6px; -} - -.streamer-profile-stat { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-secondary); -} - -.streamer-profile-stat strong { - color: var(--text); - font-weight: 600; - font-size: 13px; -} - -.streamer-profile-stat svg { - width: 14px; - height: 14px; - opacity: 0.7; -} - -.streamer-profile-actions { - display: flex; - flex-direction: column; - gap: 6px; - flex-shrink: 0; -} - -.streamer-profile-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 14px; - background: var(--bg-elevated); - border: 1px solid var(--border-soft); - color: var(--text); - border-radius: 8px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.18s; - text-decoration: none; - white-space: nowrap; -} - -.streamer-profile-btn:hover { - background: rgba(145, 70, 255, 0.18); - border-color: rgba(145, 70, 255, 0.6); - color: var(--text); - transform: translateY(-1px); -} - -.streamer-profile-btn.primary { - background: #9146ff; - border-color: #9146ff; - color: #fff; -} - -.streamer-profile-btn.primary:hover { - background: #a970ff; - border-color: #a970ff; - transform: translateY(-1px); - box-shadow: 0 4px 14px rgba(145, 70, 255, 0.4); -} - -/* Focus-visible for the profile action buttons (Record now, Open on - Twitch, Refresh). Default variant gets a purple ring; the primary - variant already has a purple background so it gets the inner-white - + outer-purple double ring used elsewhere for purple-bg buttons. */ -.streamer-profile-btn:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(145, 70, 255, 0.65); - border-color: rgba(145, 70, 255, 0.6); -} - -.streamer-profile-btn.primary:focus-visible { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85), 0 0 0 4px rgba(145, 70, 255, 0.55); -} - -/* Skeleton loading state — switches the profile-header from its - regular block layout to a flex row so the avatar + body sit - side-by-side. The element itself was previously flipped via inline - .style.display='flex' in renderStreamerProfileSkeleton(). */ -.streamer-profile-skeleton { - display: flex; -} - -.streamer-profile-skeleton .streamer-profile-skel-block { - background: linear-gradient(90deg, var(--bg-elevated) 0%, rgba(255,255,255,0.06) 50%, var(--bg-elevated) 100%); - background-size: 200% 100%; - animation: profile-skel-shimmer 1.4s linear infinite; - border-radius: 4px; -} - -/* Pre-shaped skeleton block variants — each matches one of the - real-profile-card slots so the loading silhouette previews the - final layout. Replaces inline width/height/border-radius declarations. */ -.streamer-profile-skel-block.avatar { - width: 88px; - height: 88px; - border-radius: 50%; - flex-shrink: 0; -} - -.streamer-profile-skel-block.name { - width: 180px; - height: 24px; -} - -.streamer-profile-skel-block.badge { - width: 90px; - height: 18px; - border-radius: 10px; -} - -.streamer-profile-skel-block.subtitle { - width: 60%; - height: 14px; - margin-top: 6px; -} - -.streamer-profile-skel-stats { - margin-top: 8px; -} - -@keyframes profile-skel-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } -} - -@media (max-width: 720px) { - .streamer-profile-row { - flex-direction: column; - align-items: flex-start; - } - .streamer-profile-actions { - flex-direction: row; - width: 100%; - } -} - -/* ============================================ - LIVE PREVIEW CARD — inside the profile header - ============================================ */ -.streamer-profile-live-card { - position: relative; - z-index: 1; - display: flex; - gap: 14px; - margin: 0 14px 14px; - padding: 12px; - background: rgba(233, 25, 22, 0.10); - border: 1px solid rgba(233, 25, 22, 0.5); - border-radius: 10px; - cursor: pointer; - transition: transform 0.18s, box-shadow 0.18s, background 0.18s; - animation: profile-fade-in 0.4s ease-out; -} - -.streamer-profile-live-card:hover { - transform: translateY(-2px); - background: rgba(233, 25, 22, 0.16); - box-shadow: 0 6px 22px rgba(233, 25, 22, 0.20); -} - -.streamer-profile-live-thumb { - width: 240px; - height: 135px; - object-fit: cover; - border-radius: 6px; - flex-shrink: 0; - background: #000; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); -} - -.streamer-profile-live-thumb-fallback { - width: 240px; - height: 135px; - border-radius: 6px; - flex-shrink: 0; - background: linear-gradient(135deg, #2a0a0a, #1a0606); - display: flex; - align-items: center; - justify-content: center; - color: rgba(233, 25, 22, 0.5); -} - -.streamer-profile-live-thumb-fallback svg { - width: 48px; - height: 48px; -} - -.streamer-profile-live-body { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 6px; - justify-content: center; -} - -.streamer-profile-live-badge-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.streamer-profile-live-viewers { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 12px; - color: var(--text); - font-weight: 600; -} - -.streamer-profile-live-viewers svg { - width: 14px; - height: 14px; - opacity: 0.85; -} - -.streamer-profile-live-title { - font-size: 16px; - font-weight: 600; - color: var(--text); - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.streamer-profile-live-game { - font-size: 13px; - color: var(--text-secondary); -} - -.streamer-profile-live-rec-btn { - margin-top: 6px; - align-self: flex-start; - background: #e91916 !important; - border-color: #e91916 !important; -} - -.streamer-profile-live-rec-btn:hover { - background: #ff3733 !important; - border-color: #ff3733 !important; - box-shadow: 0 4px 14px rgba(233, 25, 22, 0.4); -} - -@media (max-width: 720px) { - .streamer-profile-live-card { flex-direction: column; } - .streamer-profile-live-thumb, - .streamer-profile-live-thumb-fallback { width: 100%; height: 180px; } -} - -/* ============================================ - VOD HOVER PREVIEW — storyboard sprite cycling - ============================================ - Overlay sits as a direct child of .vod-card, positioned over the - thumbnail's bounding box. Width matches the card; aspect-ratio - 16/9 anchors the height to align with the thumbnail. */ -.vod-storyboard-preview { - /* Position + size werden vollstaendig per JS gesetzt (siehe - renderer-vod-hover.ts). Wir geben hier nur Visual + Stacking. */ - position: absolute; - background-repeat: no-repeat; - opacity: 0; - transition: opacity 0.22s ease-out; - pointer-events: none; - z-index: 2; - border-radius: 8px 8px 0 0; - overflow: hidden; -} - -.vod-card.preview-active .vod-storyboard-preview { - opacity: 1; -} - -.vod-card.preview-active .vod-thumbnail { - filter: brightness(0.92); - transition: filter 0.3s; -} - -.vod-storyboard-preview::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.18) 100%); - pointer-events: none; -} - -/* ============================================ - REDUCED MOTION — respect OS-level user preference - ============================================ - Users who set "Reduce motion" in their OS accessibility settings - (Windows: Settings > Accessibility > Visual Effects > Animation - effects; macOS: System Settings > Accessibility > Display > Reduce - motion) get animations and transitions effectively disabled. - - Suppresses things like the empty-state-float loop, the btn-icon-spin - on Refresh hover, the vod-bulk-bar slide-in, the storyboard fade-in, - and the multitude of transition: all 0.2s declarations — anything - that involves motion. Critical for users with vestibular disorders - and a baseline accessibility expectation in 2025. */ -/* Generic hide utility. Use when an element's visible-state display - differs (button = inline-block, bulk-bar = flex, etc.) so a single - class can hide any of them without per-element .shown modifiers. - The !important wins over the base class's display declaration. */ -.is-hidden { - display: none !important; -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -/* ============================================ - CONTEXT MENU — generic right-click menu base - ============================================ - Used by both the queue row context menu (renderer-queue.ts) and the - VOD card context menu (renderer-streamers.ts). left/top stay inline - on the container (set per-click); everything else lives here. */ -.context-menu { - position: fixed; - z-index: 9999; - background: var(--bg-card); - border: 1px solid var(--border-soft); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - padding: 4px; - min-width: 200px; -} - -.context-menu-item { - display: block; - width: 100%; - border: 0; - background: transparent; - text-align: left; - padding: 8px 12px; - cursor: pointer; - font-size: 13px; - color: var(--text); - border-radius: 4px; - transition: background 0.12s; -} - -.context-menu-item:hover:not(.disabled) { - background: rgba(145, 70, 255, 0.15); -} - -.context-menu-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.context-menu-item.disabled { - color: var(--text-secondary); - opacity: 0.55; - cursor: not-allowed; -} - -.context-menu-separator { - height: 1px; - margin: 4px 6px; - background: var(--border-soft); -} - -/* Output-row appended to the queue-item detail panel when a job - completed. Lists the file actions (Open file / Show in folder / - View chat / View events) followed by a tiny secondary-colour file - label. */ -.queue-output-row { - display: flex; - gap: 6px; - margin-top: 6px; - flex-wrap: wrap; - align-items: center; -} - -.queue-output-label { - color: var(--text-secondary); - font-size: 11px; - word-break: break-all; -} - -/* Command Palette (Pillar 5 — added in 5.1.0-alpha.1) */ -.command-palette { - max-width: 540px; - width: 90%; - padding: 16px; - display: flex; - flex-direction: column; - gap: 10px; -} - -.cp-title { - font-size: 13px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.08em; - margin: 0; -} - -.cp-input { - width: 100%; - padding: 10px 12px; - font-size: 16px; - background: var(--bg-main); - color: var(--text); - border: 1px solid var(--border-soft); - border-radius: 4px; - outline: none; -} - -.cp-input:focus { - border-color: var(--accent); -} - -.cp-list { - list-style: none; - padding: 0; - margin: 0; - max-height: 320px; - overflow-y: auto; - border: 1px solid var(--border-soft); - border-radius: 4px; -} - -.cp-list:empty { - display: none; -} - -.cp-item { - padding: 8px 12px; - cursor: pointer; - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - color: var(--text); - border-bottom: 1px solid var(--border-soft); -} - -.cp-item:last-child { - border-bottom: none; -} - -.cp-item:hover, -.cp-item.cp-active { - background: var(--accent); - color: #fff; -} - -.cp-item-label { - flex: 1; - font-size: 14px; -} - -.cp-item-hint { - font-size: 11px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.cp-item:hover .cp-item-hint, -.cp-item.cp-active .cp-item-hint { - color: rgba(255, 255, 255, 0.85); -} - -.cp-hint { - margin: 0; - font-size: 11px; - color: var(--text-secondary); - text-align: right; -} diff --git a/src/tools.ts b/src/tools.ts index c0c565a..df64a51 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -83,6 +83,12 @@ function getCommandCacheKey(command: string, args: string[]): string { return [command, ...args].join('\u0000'); } +let managedToolExecutionObserver: ((command: string) => void) | null = null; + +export function setManagedToolExecutionObserver(observer: ((command: string) => void) | null): void { + managedToolExecutionObserver = observer; +} + export function canExecute(cmd: string): boolean { try { execSync(cmd, { stdio: 'ignore', windowsHide: true }); @@ -94,6 +100,7 @@ export function canExecute(cmd: string): boolean { export function canExecuteCommand(command: string, args: string[]): boolean { try { + managedToolExecutionObserver?.(command); const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true }); return result.status === 0; } catch { diff --git a/src/types.ts b/src/types.ts index 641d8d0..647fe25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,11 +21,13 @@ export interface MergeGroup { downloadedFiles: Record; mergedFile?: string; splitFiles?: string[]; + splitTempFiles?: string[]; totalDurationSec?: number; } export interface QueueItem { id: string; + createdAt?: string; title: string; url: string; date: string; @@ -43,6 +45,8 @@ export interface QueueItem { last_error?: string; customClip?: CustomClip; mergeGroup?: MergeGroup; + mergeRecoveryBlocked?: boolean; + artifactRoot?: string; // 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. @@ -80,3 +84,6 @@ export interface DownloadResult { error?: string; outputFiles?: string[]; } + +export type QueueAdditionRejectionReason = import('./main/domain/queue-addition').QueueAdditionRejectionReason; +export type QueueAdditionResult = import('./main/domain/queue-addition').QueueAdditionResult; diff --git a/src/workspace-refinements.css b/src/workspace-refinements.css new file mode 100644 index 0000000..d42589a --- /dev/null +++ b/src/workspace-refinements.css @@ -0,0 +1,1325 @@ + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.topbar-navigation-cluster { + display: flex; + flex: 0 0 auto; + width: 540px; + min-width: 0; + height: 40px; + align-items: center; + overflow: hidden; + background: var(--workspace-panel); + border: 1px solid var(--workspace-border); + border-radius: 8px; +} + +.topbar-navigation-cluster .topbar-brand { + flex: 0 0 190px; + min-width: 190px; + height: 38px; + padding: 0 12px; + background: transparent; + border: 0; + border-right: 1px solid var(--workspace-border); + border-radius: 0; +} + +.topbar-navigation-cluster .topbar-brand #logoText { + overflow: hidden; + color: var(--workspace-text); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.025em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-navigation-cluster .top-nav { + flex: 1 1 auto; + height: 38px; + padding: 5px 6px; + gap: 5px; + background: transparent; + border: 0; + border-radius: 0; +} + +.topbar-navigation-cluster .top-nav-item.nav-item { + width: 44px; + min-width: 44px; + height: 28px; +} + +.topbar-brand .topbar-brand-mark, +.topbar-navigation-cluster .top-nav-item.nav-item svg { + fill: none; +} + +.topbar-actions { + height: 40px; + align-items: stretch; + gap: 8px; + margin-left: auto; +} + +.topbar-icon-button, +.topbar-account-button { + height: 40px; + color: var(--workspace-text-muted); + background: var(--workspace-panel); + border: 1px solid var(--workspace-border); + border-radius: 8px; + cursor: pointer; +} + +.topbar-icon-button { + width: 40px; +} + +.topbar-account-button { + display: inline-flex; + min-width: 68px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 10px; + color: var(--workspace-text); + font-size: 12px; + font-weight: 600; +} + +.topbar-account-button:hover, +.topbar-account-button:focus-visible, +.topbar-icon-button:hover { + color: var(--workspace-text); + background: var(--workspace-control); +} + +.topbar-account-button svg { + width: 14px; + height: 14px; +} + +.update-banner.workspace-update { + position: relative; + display: none; + width: auto; + min-width: 90px; + height: 40px; + align-items: stretch; + padding: 0; + overflow: visible; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + transform: none; +} + +.update-banner.workspace-update.show:not([hidden]) { + display: flex; +} + +#workspaceUpdateButton, +.workspace-update-button { + position: relative; + width: 90px; + min-width: 90px; + height: 40px; + padding: 0 12px; + border-radius: 8px; + font-size: 13px; +} + +#workspaceUpdateButton:hover, +.workspace-update-button:hover { + background: #8aa5dc; + border-color: #8aa5dc; +} + +.workspace-update.show #workspaceUpdateButton::after { + position: absolute; + top: 4px; + right: 4px; + width: 7px; + height: 7px; + background: #ff9e98; + border: 1px solid var(--workspace-primary); + border-radius: 50%; + content: ""; +} + +.workspace-update-popover { + position: absolute; + top: calc(100% + 7px); + right: 0; + z-index: 600; + display: flex; + visibility: hidden; + width: 216px; + min-height: 68px; + flex-direction: column; + align-items: stretch; + gap: 7px; + padding: 8px; + color: var(--workspace-popover-text); + background: var(--workspace-popover-bg); + border: 1px solid var(--workspace-popover-border); + border-radius: 5px; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + opacity: 0; + pointer-events: none; + transition: visibility 120ms ease, opacity 120ms ease; +} + +.workspace-update-popover::before { + position: absolute; + top: -5px; + right: 39px; + width: 9px; + height: 9px; + background: var(--workspace-popover-bg); + border-top: 1px solid var(--workspace-popover-border); + border-left: 1px solid var(--workspace-popover-border); + content: ""; + transform: rotate(45deg); +} + +.workspace-update-popover::after { + position: absolute; + top: -7px; + right: 0; + left: 0; + height: 7px; + content: ""; +} + +.workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover, +.workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover { + visibility: visible; + opacity: 1; + pointer-events: auto; +} + +.workspace-update:not(.show) .workspace-update-popover #updateButton, +.workspace-update:not(.show) .workspace-update-popover .update-banner-progress-wrap { + display: none !important; +} + +.workspace-update-popover #updateButton { + position: relative; + z-index: 1; + min-height: 30px; + padding: 0 9px; + color: var(--workspace-primary-text); + background: var(--workspace-primary); + border: 1px solid var(--workspace-primary); + border-radius: var(--workspace-radius-small); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.workspace-update-popover #updateButton:hover:not(:disabled) { + background: var(--workspace-primary-hover); + border-color: var(--workspace-primary-hover); +} + +.workspace-update-popover .update-banner-progress-wrap { + width: 100%; + margin: 0; +} + +.workspace-update-popover .update-banner-progress-track { + width: 100%; + height: 5px; +} + +.context-switcher { + display: flex; + position: relative; + isolation: isolate; + flex: 0 0 auto; + width: calc(100% - 24px); + min-height: 36px; + align-items: center; + margin: 12px 12px 6px; + padding: 4px; + gap: 4px; + background: var(--workspace-border); + border-radius: 6px; +} + +.context-switcher::before, +.language-picker::before, +[data-context-for="settings"] .context-list::before { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: var(--segment-active-width, 0px); + height: var(--segment-active-height, 0px); + pointer-events: none; + background: var(--workspace-primary); + border-radius: 5px; + content: ''; + opacity: 0; + transform: translate3d(var(--segment-active-x, 0px), var(--segment-active-y, 0px), 0); +} + +.context-switcher.segmented-indicator-visible::before, +.language-picker.segmented-indicator-visible::before, +[data-context-for="settings"] .context-list.segmented-indicator-visible::before { + opacity: 1; +} + +.context-switcher.segmented-indicator-ready::before, +.language-picker.segmented-indicator-ready::before, +[data-context-for="settings"] .context-list.segmented-indicator-ready::before { + transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1); +} + +.context-switcher button { + display: inline-flex; + position: relative; + z-index: 1; + flex: 1 1 0; + height: 28px; + align-items: center; + justify-content: center; + padding: 0 8px; + color: var(--workspace-text); + background: transparent; + border: 0; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.context-switcher button:hover { + background: var(--workspace-control); +} + +.context-switcher button.active { + color: var(--workspace-primary-text); + background: transparent; +} + +.context-switcher button.active:hover { + background: transparent; +} + +#settingsTab .language-picker { + display: grid; + position: relative; + isolation: isolate; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + padding: 4px; + overflow: hidden; + background: var(--workspace-border); + border-radius: 6px; +} + +#settingsTab .language-picker .lang-option { + position: relative; + z-index: 1; + justify-content: center; + min-width: 0; + background: transparent; + border-color: transparent; + box-shadow: none; +} + +#settingsTab .language-picker .lang-option > span:not(.flag-icon) { + color: var(--workspace-text-muted); + mix-blend-mode: normal; + transition: color 120ms ease; +} + +#settingsTab .language-picker .lang-option:hover { + background: var(--workspace-control); +} + +#settingsTab .language-picker .lang-option.active { + color: var(--workspace-primary-text); + background: transparent; + border-color: transparent; + box-shadow: none; +} + +#settingsTab .language-picker .lang-option.active > span:not(.flag-icon) { + color: var(--workspace-primary-text); +} + +#settingsTab .language-picker .lang-option.active:hover { + background: transparent; +} + +.context-panel-heading { + display: flex; + flex: 0 0 auto; + min-height: 36px; + align-items: center; + margin: 10px 12px 6px; + padding: 0 10px; + color: var(--workspace-primary); + background: var(--workspace-control); + border-radius: 6px; + font-size: 14px; + font-weight: 600; +} + +.context-list { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 2px; + padding: 0 12px 12px; + overflow-x: hidden; + overflow-y: auto; +} + +[data-context-for="settings"] .context-list { + position: relative; + isolation: isolate; +} + +[data-context-for="settings"] .context-list::before { + background: var(--workspace-control); + border-radius: 6px; +} + +.context-link { + display: flex; + flex: 0 0 auto; + width: 100%; + min-height: 36px; + align-items: center; + gap: 10px; + padding: 0 10px; + overflow: hidden; + color: var(--workspace-text); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + text-align: left; + cursor: pointer; +} + +.context-link:hover { + background: var(--workspace-panel-raised); +} + +.context-link.active, +.context-link:focus-visible { + color: var(--workspace-primary); + background: var(--workspace-control); +} + +.context-link svg { + width: 16px; + height: 16px; + flex: 0 0 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.context-link span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-context-for="settings"] .context-link { + position: relative; + z-index: 1; +} + +[data-context-for="settings"] .context-link.active, +[data-context-for="settings"] .context-link.active:hover { + background: transparent; +} + +.context-sidebar .section-title, +.context-sidebar .streamer-item, +.context-sidebar .queue-title { + font-size: 14px; +} + +.workspace-toolbar .toolbar-context { + display: flex; + flex: 0 0 auto; + min-width: 0; + align-items: center; + gap: 4px; + overflow: visible; +} + +.workspace-toolbar .toolbar-primary { + height: 36px; + min-height: 36px; +} + +.workspace-toolbar .toolbar-primary svg, +.workspace-toolbar .toolbar-icon-button svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.workspace-toolbar .toolbar-icon-button { + width: 36px; + min-width: 36px; + height: 36px; + color: var(--workspace-text-muted); + background: var(--workspace-control); + border: 1px solid var(--workspace-border); + border-radius: var(--workspace-radius-small); +} + +.workspace-title { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.workspace-toolbar-actions { + display: flex; + flex: 0 1 550px; + min-width: 160px; + align-items: center; + margin-left: auto; +} + +.workspace-search { + position: relative; + display: flex; + width: 100%; + min-width: 0; + height: 36px; + align-items: center; +} + +.workspace-toolbar .workspace-search input { + width: 100%; + height: 36px; + padding: 0 42px 0 12px; + background: var(--workspace-control); +} + +.workspace-search #btnAddStreamer { + position: absolute; + right: 4px; + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + padding: 0; + color: var(--workspace-text-muted); + background: transparent; + border: 0; + border-radius: var(--workspace-radius-small); + font-size: 18px; + cursor: pointer; +} + +.workspace-search #btnAddStreamer:hover { + color: var(--workspace-text); + background: var(--workspace-control-hover); +} + +.vod-grid > .empty-state { + grid-column: 1 / -1; + width: 100%; +} + +#settingsTab .settings-card h3 { + font-size: 18px; + line-height: 24px; +} + +#settingsTab input:not([type="checkbox"]):not([type="radio"]), +#settingsTab select { + min-height: 44px; + padding-right: 12px; + padding-left: 12px; + color: var(--workspace-text); + background: var(--workspace-panel-raised); + border-color: var(--workspace-border-strong); +} + +.workspace-theme-picker { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 2px; +} + +.workspace-theme-choice { + display: inline-flex; + width: 90px; + min-width: 90px; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 0; + color: var(--workspace-text-muted); + background: transparent; + border: 0; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + cursor: pointer; +} + +.workspace-theme-choice:hover, +.workspace-theme-choice.active, +.workspace-theme-choice[aria-pressed="true"] { + color: var(--workspace-text); +} + +.workspace-theme-preview { + position: relative; + display: block; + width: 90px; + height: 59px; + overflow: hidden; + background: #202020; + border: 1px solid var(--workspace-border-strong); + border-radius: 5px; +} + +.workspace-theme-choice.active .workspace-theme-preview, +.workspace-theme-choice[aria-pressed="true"] .workspace-theme-preview { + border: 3px solid #055ff0; +} + +.workspace-theme-preview > span { + position: absolute; + z-index: 2; + display: block; + border-radius: 1px; +} + +.workspace-theme-preview > span:nth-child(1) { + top: 6px; + right: 6px; + left: 6px; + height: 5px; +} + +.workspace-theme-preview > span:nth-child(2) { + top: 16px; + bottom: 6px; + left: 6px; + width: 19px; +} + +.workspace-theme-preview > span:nth-child(3) { + top: 16px; + right: 6px; + bottom: 6px; + left: 30px; +} + +.theme-preview-light { + background: #f0f2f5; + border-color: #c7cbd2; +} + +.theme-preview-light > span:nth-child(1) { + background: #a7c6ff; +} + +.theme-preview-light > span:nth-child(2) { + background: #dfe3e9; +} + +.theme-preview-light > span:nth-child(3) { + background: #ffffff; + border: 1px solid #d8dce2; +} + +.theme-preview-dark { + background: #171717; +} + +.theme-preview-dark > span:nth-child(1) { + background: #8fb5ff; +} + +.theme-preview-dark > span:nth-child(2) { + background: #2b2b2b; +} + +.theme-preview-dark > span:nth-child(3) { + background: #333333; + border: 1px solid #424242; +} + +.theme-preview-system { + background: #202020; +} + +.theme-preview-system::before { + position: absolute; + inset: 0 50% 0 0; + z-index: 1; + background: #f0f2f5; + content: ""; +} + +.theme-preview-system > span:nth-child(1) { + background: #8fb5ff; +} + +.theme-preview-system > span:nth-child(2) { + background: #dfe3e9; +} + +.theme-preview-system > span:nth-child(3) { + background: #333333; + border: 1px solid #555555; +} + +.theme-select-fallback { + position: absolute; + width: 1px !important; + min-width: 1px !important; + height: 1px !important; + min-height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0 !important; +} + +@media (max-width: 1754px) { + .workspace-toolbar-actions { + flex-basis: clamp(240px, 32vw, 550px); + } +} + +@media (max-width: 1180px) { + .topbar-navigation-cluster { + width: auto; + max-width: calc(100vw - 290px); + } + + .topbar-navigation-cluster .topbar-brand { + flex-basis: 46px; + min-width: 46px; + padding: 0 13px; + } + + .topbar-navigation-cluster .topbar-brand #logoText { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .workspace-toolbar-actions { + flex-basis: 240px; + } +} + +@media (max-width: 980px) { + .context-panel-heading { + width: 42px; + min-height: 38px; + justify-content: center; + margin: 8px auto 6px; + padding: 0; + overflow: hidden; + color: var(--workspace-primary); + font-size: 0; + } + + .context-list { + align-items: center; + padding: 0 8px 8px; + } + + .context-link { + width: 42px; + height: 38px; + min-height: 38px; + justify-content: center; + padding: 0; + } + + .context-link span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .context-switcher { + display: none; + } + + .workspace-toolbar-actions { + flex-basis: 190px; + } +} + +.top-nav-item.nav-item.active:hover { + color: var(--workspace-primary-text); + background: transparent; + border-color: transparent; +} + +.top-nav .top-nav-item:focus-visible { + outline: 2px solid var(--workspace-focus-ring); + outline-offset: 2px; + box-shadow: none; +} + +#settingsTab .settings-card:has(#downloadPath) { + width: 100%; +} + +#settingsTab .settings-card[data-settings-pane="storage"] { + width: 720px; +} + +#settingsTab.active[data-settings-pane="debug"], +#settingsTab.active[data-settings-pane="metrics"] { + display: flex; + flex-direction: column; + overflow: hidden; +} + +#settingsTab[data-settings-pane="debug"] .settings-card[data-settings-pane="debug"], +#settingsTab[data-settings-pane="metrics"] .settings-card[data-settings-pane="metrics"] { + display: flex; + width: 100%; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + padding-bottom: 0; +} + +#debugLogOutput, +#runtimeMetricsOutput { + width: 100%; + min-height: 0; + max-height: none; + flex: 1 1 auto; +} + +#settingsTab .form-row:has(#downloadPath) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; +} + +#settingsTab #downloadPath { + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.download-settings-storage { + margin-bottom: 14px; +} + +.download-settings-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 16px; +} + +.download-settings-column { + display: grid; + min-width: 0; + gap: 16px; +} + +#settingsTab .download-settings-section { + min-width: 0; + padding: 16px; + background: var(--workspace-panel-raised); + border: 1px solid var(--workspace-border); + border-radius: var(--workspace-radius); +} + +#settingsTab .download-settings-section h4 { + margin: 0 0 14px; + font-size: 14px; + line-height: 20px; +} + +.download-settings-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +#settingsTab .download-settings-section-heading h4 { + margin: 0; +} + +.download-settings-section .form-group:last-child, +.download-settings-section .filename-template-grid:last-child { + margin-bottom: 0; +} + +.download-settings-toggles { + display: grid; + gap: 5px; +} + +#settingsTab .download-settings-toggles .toggle-row { + min-height: 32px; + gap: 10px; + margin: 0; + padding: 5px 7px; + border-radius: var(--workspace-radius-small); + font-size: 13px; + line-height: 18px; +} + +#settingsTab .download-settings-toggles .toggle-row:hover { + background: var(--workspace-control-hover); +} + +#settingsTab .download-settings-toggles input[type="checkbox"], +#settingsTab .sidebar-layout-setting input[type="checkbox"] { + width: 20px; + height: 20px; + flex: 0 0 20px; + background-size: 15px; +} + +.metadata-cache-setting { + margin-top: 14px; + margin-bottom: 0; + padding-top: 14px; + border-top: 1px solid var(--workspace-border); +} + +.template-presets { + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +#settingsTab .download-settings-section #filenameTemplateHint { + margin-top: 9px; +} + +#settingsTab .download-policy-settings textarea { + min-height: 72px; +} + +#settingsTab .download-policy-settings #downloadPolicyValidation:empty { + display: none; +} + +#settingsTab .download-policy-settings #downloadPolicyStatus { + margin: 8px 0 12px; +} + +.sidebar-layout-setting { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--workspace-border); +} + +#settingsTab .sidebar-layout-setting .toggle-row { + gap: 10px; + font-size: 13px; + font-weight: 600; +} + +#settingsTab .sidebar-layout-setting .form-note { + margin: 7px 0 0 30px; +} + +#clipsTab.active { + display: grid; + grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr); + align-content: start; + align-items: start; + gap: 16px; +} + +#clipsTab .clip-input { + display: grid; + width: 100%; + max-width: none; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + margin: 0; + padding: 16px; + text-align: left; + background: var(--workspace-panel-raised); + border: 1px solid var(--workspace-border); + border-radius: var(--workspace-radius); +} + +#clipsTab .clip-input h2 { + grid-column: 1 / -1; + margin: 0 0 2px; + font-size: 16px; + line-height: 22px; +} + +#clipsTab .clip-input #clipUrl { + width: 100%; + min-width: 0; + min-height: 38px; + margin: 0; +} + +#clipsTab .clip-input #btnClip { + min-height: 38px; + white-space: nowrap; +} + +#clipsTab .clip-input #clipStatus { + grid-column: 1 / -1; + min-height: 20px; + margin: 0; + font-size: 12px; + line-height: 20px; +} + +#clipsTab > .settings-card.centered { + width: 100%; + max-width: none; + align-self: start; + margin: 0; +} + +.workspace-settings-search { + position: relative; + display: flex; + flex: 0 1 550px; + min-width: 180px; + height: 36px; + align-items: center; + margin-left: auto; +} + +.workspace-toolbar .toolbar-context[data-toolbar-for="settings"] { + flex: 1 1 auto; + width: 100%; +} + +.workspace-settings-search input { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 36px 0 34px; + color: var(--workspace-text); + background: var(--workspace-control); + border: 1px solid var(--workspace-border); + border-radius: var(--workspace-radius-small); + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.workspace-settings-search > svg { + position: absolute; + left: 11px; + z-index: 1; + width: 14px; + height: 14px; + color: var(--workspace-text-muted); + fill: none; + stroke: currentColor; + pointer-events: none; +} + +.workspace-settings-search > button { + position: absolute; + right: 4px; + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + padding: 0; + color: var(--workspace-text-muted); + background: transparent; + border: 0; + border-radius: var(--workspace-radius-small); + cursor: pointer; +} + +.workspace-settings-search > button:hover { + color: var(--workspace-text); + background: var(--workspace-control-hover); +} + +.workspace-update-popover { + width: 260px; +} + +.workspace-update-popover-header { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + color: var(--workspace-popover-text); +} + +.workspace-update-popover-header #updateText { + flex: 1 1 auto; + min-width: 0; +} + +#workspaceUpdateDismiss { + display: inline-flex; + width: 24px; + min-width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0; + color: var(--workspace-popover-muted); + background: transparent; + border: 1px solid transparent; + border-radius: var(--workspace-radius-small); + cursor: pointer; +} + +#workspaceUpdateDismiss:hover { + color: var(--workspace-popover-text); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.18); +} + +#workspaceUpdateDismiss::before { + content: "\00d7"; + font-size: 17px; + font-weight: 500; + line-height: 1; +} + +.workspace-update-popover-actions { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.workspace-update-popover-actions #updateButton { + flex: 1 1 auto; +} + +#workspaceUpdateLater { + display: inline-flex; + min-height: 30px; + align-items: center; + justify-content: center; + padding: 0 9px; + color: var(--workspace-popover-text); + background: transparent; + border: 1px solid var(--workspace-popover-muted); + border-radius: var(--workspace-radius-small); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +#workspaceUpdateLater:hover { + background: rgba(255, 255, 255, 0.12); +} + +.workspace-update-popover .update-banner-progress-track { + background: rgba(255, 255, 255, 0.22); +} + +@media (min-width: 1180px) { + .topbar-navigation-cluster { + flex: 1 1 auto; + width: auto; + max-width: none; + } + + .topbar-navigation-cluster .topbar-brand { + flex-basis: 190px; + min-width: 190px; + padding: 0 12px; + } + + .topbar-navigation-cluster .topbar-brand #logoText { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: hidden; + clip: auto; + white-space: nowrap; + text-overflow: ellipsis; + border: 0; + } + + .topbar-navigation-cluster .top-nav { + min-width: 0; + overflow: hidden; + } + + .topbar-navigation-cluster .top-nav-item.nav-item { + flex: 1 0 var(--top-nav-item-width, 100px); + width: auto; + min-width: 44px; + max-width: none; + padding: 0 8px; + gap: 6px; + } + + .topbar-navigation-cluster .top-nav-item .top-nav-label, + .topbar-navigation-cluster .top-nav-item.nav-item > span { + position: static; + display: block; + width: auto; + min-width: 0; + height: auto; + padding: 0; + margin: 0; + overflow: hidden; + clip: auto; + white-space: nowrap; + text-overflow: ellipsis; + border: 0; + font-size: 13px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="vods"] { + --top-nav-item-width: 110px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="clips"] { + --top-nav-item-width: 110px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="cutter"] { + --top-nav-item-width: 138px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="merge"] { + --top-nav-item-width: 180px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="stats"] { + --top-nav-item-width: 110px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="archive"] { + --top-nav-item-width: 90px; + } + + .topbar-navigation-cluster .top-nav-item[data-tab="settings"] { + --top-nav-item-width: 120px; + } +} + +@media (max-width: 1179px) { + .topbar-navigation-cluster .top-nav-item .top-nav-label, + .topbar-navigation-cluster .top-nav-item.nav-item > span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .topbar-navigation-cluster .top-nav-item.nav-item { + width: 44px; + min-width: 44px; + padding: 0; + gap: 0; + } +} + +@media (max-width: 1350px) { + .workspace-settings-search { + flex-basis: 280px; + } +} + +@media (max-width: 1200px) { + .download-settings-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 820px) { + #clipsTab.active { + grid-template-columns: minmax(0, 1fr); + } + + #settingsTab .form-row:has(#downloadPath) { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/workspace.css b/src/workspace.css index f27d268..58cde12 100644 --- a/src/workspace.css +++ b/src/workspace.css @@ -2706,1328 +2706,3 @@ input[type="checkbox"].vod-select-checkbox:focus-visible { transition-duration: 0.01ms !important; } } - -[hidden] { - display: none !important; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.topbar-navigation-cluster { - display: flex; - flex: 0 0 auto; - width: 540px; - min-width: 0; - height: 40px; - align-items: center; - overflow: hidden; - background: var(--workspace-panel); - border: 1px solid var(--workspace-border); - border-radius: 8px; -} - -.topbar-navigation-cluster .topbar-brand { - flex: 0 0 190px; - min-width: 190px; - height: 38px; - padding: 0 12px; - background: transparent; - border: 0; - border-right: 1px solid var(--workspace-border); - border-radius: 0; -} - -.topbar-navigation-cluster .topbar-brand #logoText { - overflow: hidden; - color: var(--workspace-text); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.025em; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topbar-navigation-cluster .top-nav { - flex: 1 1 auto; - height: 38px; - padding: 5px 6px; - gap: 5px; - background: transparent; - border: 0; - border-radius: 0; -} - -.topbar-navigation-cluster .top-nav-item.nav-item { - width: 44px; - min-width: 44px; - height: 28px; -} - -.topbar-brand .topbar-brand-mark, -.topbar-navigation-cluster .top-nav-item.nav-item svg { - fill: none; -} - -.topbar-actions { - height: 40px; - align-items: stretch; - gap: 8px; - margin-left: auto; -} - -.topbar-icon-button, -.topbar-account-button { - height: 40px; - color: var(--workspace-text-muted); - background: var(--workspace-panel); - border: 1px solid var(--workspace-border); - border-radius: 8px; - cursor: pointer; -} - -.topbar-icon-button { - width: 40px; -} - -.topbar-account-button { - display: inline-flex; - min-width: 68px; - align-items: center; - justify-content: center; - gap: 8px; - padding: 0 10px; - color: var(--workspace-text); - font-size: 12px; - font-weight: 600; -} - -.topbar-account-button:hover, -.topbar-account-button:focus-visible, -.topbar-icon-button:hover { - color: var(--workspace-text); - background: var(--workspace-control); -} - -.topbar-account-button svg { - width: 14px; - height: 14px; -} - -.update-banner.workspace-update { - position: relative; - display: none; - width: auto; - min-width: 90px; - height: 40px; - align-items: stretch; - padding: 0; - overflow: visible; - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - transform: none; -} - -.update-banner.workspace-update.show:not([hidden]) { - display: flex; -} - -#workspaceUpdateButton, -.workspace-update-button { - position: relative; - width: 90px; - min-width: 90px; - height: 40px; - padding: 0 12px; - border-radius: 8px; - font-size: 13px; -} - -#workspaceUpdateButton:hover, -.workspace-update-button:hover { - background: #8aa5dc; - border-color: #8aa5dc; -} - -.workspace-update.show #workspaceUpdateButton::after { - position: absolute; - top: 4px; - right: 4px; - width: 7px; - height: 7px; - background: #ff9e98; - border: 1px solid var(--workspace-primary); - border-radius: 50%; - content: ""; -} - -.workspace-update-popover { - position: absolute; - top: calc(100% + 7px); - right: 0; - z-index: 600; - display: flex; - visibility: hidden; - width: 216px; - min-height: 68px; - flex-direction: column; - align-items: stretch; - gap: 7px; - padding: 8px; - color: var(--workspace-popover-text); - background: var(--workspace-popover-bg); - border: 1px solid var(--workspace-popover-border); - border-radius: 5px; - font-size: 13px; - font-weight: 600; - line-height: 1.35; - opacity: 0; - pointer-events: none; - transition: visibility 120ms ease, opacity 120ms ease; -} - -.workspace-update-popover::before { - position: absolute; - top: -5px; - right: 39px; - width: 9px; - height: 9px; - background: var(--workspace-popover-bg); - border-top: 1px solid var(--workspace-popover-border); - border-left: 1px solid var(--workspace-popover-border); - content: ""; - transform: rotate(45deg); -} - -.workspace-update-popover::after { - position: absolute; - top: -7px; - right: 0; - left: 0; - height: 7px; - content: ""; -} - -.workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover, -.workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover { - visibility: visible; - opacity: 1; - pointer-events: auto; -} - -.workspace-update:not(.show) .workspace-update-popover #updateButton, -.workspace-update:not(.show) .workspace-update-popover .update-banner-progress-wrap { - display: none !important; -} - -.workspace-update-popover #updateButton { - position: relative; - z-index: 1; - min-height: 30px; - padding: 0 9px; - color: var(--workspace-primary-text); - background: var(--workspace-primary); - border: 1px solid var(--workspace-primary); - border-radius: var(--workspace-radius-small); - font-size: 11px; - font-weight: 600; - cursor: pointer; -} - -.workspace-update-popover #updateButton:hover:not(:disabled) { - background: var(--workspace-primary-hover); - border-color: var(--workspace-primary-hover); -} - -.workspace-update-popover .update-banner-progress-wrap { - width: 100%; - margin: 0; -} - -.workspace-update-popover .update-banner-progress-track { - width: 100%; - height: 5px; -} - -.context-switcher { - display: flex; - position: relative; - isolation: isolate; - flex: 0 0 auto; - width: calc(100% - 24px); - min-height: 36px; - align-items: center; - margin: 12px 12px 6px; - padding: 4px; - gap: 4px; - background: var(--workspace-border); - border-radius: 6px; -} - -.context-switcher::before, -.language-picker::before, -[data-context-for="settings"] .context-list::before { - position: absolute; - z-index: 0; - top: 0; - left: 0; - width: var(--segment-active-width, 0px); - height: var(--segment-active-height, 0px); - pointer-events: none; - background: var(--workspace-primary); - border-radius: 5px; - content: ''; - opacity: 0; - transform: translate3d(var(--segment-active-x, 0px), var(--segment-active-y, 0px), 0); -} - -.context-switcher.segmented-indicator-visible::before, -.language-picker.segmented-indicator-visible::before, -[data-context-for="settings"] .context-list.segmented-indicator-visible::before { - opacity: 1; -} - -.context-switcher.segmented-indicator-ready::before, -.language-picker.segmented-indicator-ready::before, -[data-context-for="settings"] .context-list.segmented-indicator-ready::before { - transition: transform 360ms cubic-bezier(0.22, 0.76, 0.22, 1), width 360ms cubic-bezier(0.22, 0.76, 0.22, 1), height 360ms cubic-bezier(0.22, 0.76, 0.22, 1); -} - -.context-switcher button { - display: inline-flex; - position: relative; - z-index: 1; - flex: 1 1 0; - height: 28px; - align-items: center; - justify-content: center; - padding: 0 8px; - color: var(--workspace-text); - background: transparent; - border: 0; - border-radius: 5px; - font-size: 12px; - font-weight: 600; - cursor: pointer; -} - -.context-switcher button:hover { - background: var(--workspace-control); -} - -.context-switcher button.active { - color: var(--workspace-primary-text); - background: transparent; -} - -.context-switcher button.active:hover { - background: transparent; -} - -#settingsTab .language-picker { - display: grid; - position: relative; - isolation: isolate; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px; - padding: 4px; - overflow: hidden; - background: var(--workspace-border); - border-radius: 6px; -} - -#settingsTab .language-picker .lang-option { - position: relative; - z-index: 1; - justify-content: center; - min-width: 0; - background: transparent; - border-color: transparent; - box-shadow: none; -} - -#settingsTab .language-picker .lang-option > span:not(.flag-icon) { - color: var(--workspace-text-muted); - mix-blend-mode: normal; - transition: color 120ms ease; -} - -#settingsTab .language-picker .lang-option:hover { - background: var(--workspace-control); -} - -#settingsTab .language-picker .lang-option.active { - color: var(--workspace-primary-text); - background: transparent; - border-color: transparent; - box-shadow: none; -} - -#settingsTab .language-picker .lang-option.active > span:not(.flag-icon) { - color: var(--workspace-primary-text); -} - -#settingsTab .language-picker .lang-option.active:hover { - background: transparent; -} - -.context-panel-heading { - display: flex; - flex: 0 0 auto; - min-height: 36px; - align-items: center; - margin: 10px 12px 6px; - padding: 0 10px; - color: var(--workspace-primary); - background: var(--workspace-control); - border-radius: 6px; - font-size: 14px; - font-weight: 600; -} - -.context-list { - display: flex; - flex: 1 1 auto; - min-height: 0; - flex-direction: column; - gap: 2px; - padding: 0 12px 12px; - overflow-x: hidden; - overflow-y: auto; -} - -[data-context-for="settings"] .context-list { - position: relative; - isolation: isolate; -} - -[data-context-for="settings"] .context-list::before { - background: var(--workspace-control); - border-radius: 6px; -} - -.context-link { - display: flex; - flex: 0 0 auto; - width: 100%; - min-height: 36px; - align-items: center; - gap: 10px; - padding: 0 10px; - overflow: hidden; - color: var(--workspace-text); - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - text-align: left; - cursor: pointer; -} - -.context-link:hover { - background: var(--workspace-panel-raised); -} - -.context-link.active, -.context-link:focus-visible { - color: var(--workspace-primary); - background: var(--workspace-control); -} - -.context-link svg { - width: 16px; - height: 16px; - flex: 0 0 16px; - fill: none; - stroke: currentColor; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.context-link span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-context-for="settings"] .context-link { - position: relative; - z-index: 1; -} - -[data-context-for="settings"] .context-link.active, -[data-context-for="settings"] .context-link.active:hover { - background: transparent; -} - -.context-sidebar .section-title, -.context-sidebar .streamer-item, -.context-sidebar .queue-title { - font-size: 14px; -} - -.workspace-toolbar .toolbar-context { - display: flex; - flex: 0 0 auto; - min-width: 0; - align-items: center; - gap: 4px; - overflow: visible; -} - -.workspace-toolbar .toolbar-primary { - height: 36px; - min-height: 36px; -} - -.workspace-toolbar .toolbar-primary svg, -.workspace-toolbar .toolbar-icon-button svg { - width: 16px; - height: 16px; - fill: none; - stroke: currentColor; - stroke-width: 1.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.workspace-toolbar .toolbar-icon-button { - width: 36px; - min-width: 36px; - height: 36px; - color: var(--workspace-text-muted); - background: var(--workspace-control); - border: 1px solid var(--workspace-border); - border-radius: var(--workspace-radius-small); -} - -.workspace-title { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.workspace-toolbar-actions { - display: flex; - flex: 0 1 550px; - min-width: 160px; - align-items: center; - margin-left: auto; -} - -.workspace-search { - position: relative; - display: flex; - width: 100%; - min-width: 0; - height: 36px; - align-items: center; -} - -.workspace-toolbar .workspace-search input { - width: 100%; - height: 36px; - padding: 0 42px 0 12px; - background: var(--workspace-control); -} - -.workspace-search #btnAddStreamer { - position: absolute; - right: 4px; - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - padding: 0; - color: var(--workspace-text-muted); - background: transparent; - border: 0; - border-radius: var(--workspace-radius-small); - font-size: 18px; - cursor: pointer; -} - -.workspace-search #btnAddStreamer:hover { - color: var(--workspace-text); - background: var(--workspace-control-hover); -} - -.vod-grid > .empty-state { - grid-column: 1 / -1; - width: 100%; -} - -#settingsTab .settings-card h3 { - font-size: 18px; - line-height: 24px; -} - -#settingsTab input:not([type="checkbox"]):not([type="radio"]), -#settingsTab select { - min-height: 44px; - padding-right: 12px; - padding-left: 12px; - color: var(--workspace-text); - background: var(--workspace-panel-raised); - border-color: var(--workspace-border-strong); -} - -.workspace-theme-picker { - display: flex; - align-items: flex-start; - gap: 6px; - margin-top: 2px; -} - -.workspace-theme-choice { - display: inline-flex; - width: 90px; - min-width: 90px; - flex-direction: column; - align-items: center; - gap: 5px; - padding: 0; - color: var(--workspace-text-muted); - background: transparent; - border: 0; - border-radius: 6px; - font-size: 11px; - font-weight: 500; - cursor: pointer; -} - -.workspace-theme-choice:hover, -.workspace-theme-choice.active, -.workspace-theme-choice[aria-pressed="true"] { - color: var(--workspace-text); -} - -.workspace-theme-preview { - position: relative; - display: block; - width: 90px; - height: 59px; - overflow: hidden; - background: #202020; - border: 1px solid var(--workspace-border-strong); - border-radius: 5px; -} - -.workspace-theme-choice.active .workspace-theme-preview, -.workspace-theme-choice[aria-pressed="true"] .workspace-theme-preview { - border: 3px solid #055ff0; -} - -.workspace-theme-preview > span { - position: absolute; - z-index: 2; - display: block; - border-radius: 1px; -} - -.workspace-theme-preview > span:nth-child(1) { - top: 6px; - right: 6px; - left: 6px; - height: 5px; -} - -.workspace-theme-preview > span:nth-child(2) { - top: 16px; - bottom: 6px; - left: 6px; - width: 19px; -} - -.workspace-theme-preview > span:nth-child(3) { - top: 16px; - right: 6px; - bottom: 6px; - left: 30px; -} - -.theme-preview-light { - background: #f0f2f5; - border-color: #c7cbd2; -} - -.theme-preview-light > span:nth-child(1) { - background: #a7c6ff; -} - -.theme-preview-light > span:nth-child(2) { - background: #dfe3e9; -} - -.theme-preview-light > span:nth-child(3) { - background: #ffffff; - border: 1px solid #d8dce2; -} - -.theme-preview-dark { - background: #171717; -} - -.theme-preview-dark > span:nth-child(1) { - background: #8fb5ff; -} - -.theme-preview-dark > span:nth-child(2) { - background: #2b2b2b; -} - -.theme-preview-dark > span:nth-child(3) { - background: #333333; - border: 1px solid #424242; -} - -.theme-preview-system { - background: #202020; -} - -.theme-preview-system::before { - position: absolute; - inset: 0 50% 0 0; - z-index: 1; - background: #f0f2f5; - content: ""; -} - -.theme-preview-system > span:nth-child(1) { - background: #8fb5ff; -} - -.theme-preview-system > span:nth-child(2) { - background: #dfe3e9; -} - -.theme-preview-system > span:nth-child(3) { - background: #333333; - border: 1px solid #555555; -} - -.theme-select-fallback { - position: absolute; - width: 1px !important; - min-width: 1px !important; - height: 1px !important; - min-height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0 !important; -} - -@media (max-width: 1754px) { - .workspace-toolbar-actions { - flex-basis: clamp(240px, 32vw, 550px); - } -} - -@media (max-width: 1180px) { - .topbar-navigation-cluster { - width: auto; - max-width: calc(100vw - 290px); - } - - .topbar-navigation-cluster .topbar-brand { - flex-basis: 46px; - min-width: 46px; - padding: 0 13px; - } - - .topbar-navigation-cluster .topbar-brand #logoText { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - .workspace-toolbar-actions { - flex-basis: 240px; - } -} - -@media (max-width: 980px) { - .context-panel-heading { - width: 42px; - min-height: 38px; - justify-content: center; - margin: 8px auto 6px; - padding: 0; - overflow: hidden; - color: var(--workspace-primary); - font-size: 0; - } - - .context-list { - align-items: center; - padding: 0 8px 8px; - } - - .context-link { - width: 42px; - height: 38px; - min-height: 38px; - justify-content: center; - padding: 0; - } - - .context-link span { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - .context-switcher { - display: none; - } - - .workspace-toolbar-actions { - flex-basis: 190px; - } -} - -.top-nav-item.nav-item.active:hover { - color: var(--workspace-primary-text); - background: transparent; - border-color: transparent; -} - -.top-nav .top-nav-item:focus-visible { - outline: 2px solid var(--workspace-focus-ring); - outline-offset: 2px; - box-shadow: none; -} - -#settingsTab .settings-card:has(#downloadPath) { - width: 100%; -} - -#settingsTab .settings-card[data-settings-pane="storage"] { - width: 720px; -} - -#settingsTab.active[data-settings-pane="debug"], -#settingsTab.active[data-settings-pane="metrics"] { - display: flex; - flex-direction: column; - overflow: hidden; -} - -#settingsTab[data-settings-pane="debug"] .settings-card[data-settings-pane="debug"], -#settingsTab[data-settings-pane="metrics"] .settings-card[data-settings-pane="metrics"] { - display: flex; - width: 100%; - min-height: 0; - flex: 1 1 auto; - flex-direction: column; - padding-bottom: 0; -} - -#debugLogOutput, -#runtimeMetricsOutput { - width: 100%; - min-height: 0; - max-height: none; - flex: 1 1 auto; -} - -#settingsTab .form-row:has(#downloadPath) { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 8px; -} - -#settingsTab #downloadPath { - width: 100%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.download-settings-storage { - margin-bottom: 14px; -} - -.download-settings-layout { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: start; - gap: 16px; -} - -.download-settings-column { - display: grid; - min-width: 0; - gap: 16px; -} - -#settingsTab .download-settings-section { - min-width: 0; - padding: 16px; - background: var(--workspace-panel-raised); - border: 1px solid var(--workspace-border); - border-radius: var(--workspace-radius); -} - -#settingsTab .download-settings-section h4 { - margin: 0 0 14px; - font-size: 14px; - line-height: 20px; -} - -.download-settings-section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 14px; -} - -#settingsTab .download-settings-section-heading h4 { - margin: 0; -} - -.download-settings-section .form-group:last-child, -.download-settings-section .filename-template-grid:last-child { - margin-bottom: 0; -} - -.download-settings-toggles { - display: grid; - gap: 5px; -} - -#settingsTab .download-settings-toggles .toggle-row { - min-height: 32px; - gap: 10px; - margin: 0; - padding: 5px 7px; - border-radius: var(--workspace-radius-small); - font-size: 13px; - line-height: 18px; -} - -#settingsTab .download-settings-toggles .toggle-row:hover { - background: var(--workspace-control-hover); -} - -#settingsTab .download-settings-toggles input[type="checkbox"], -#settingsTab .sidebar-layout-setting input[type="checkbox"] { - width: 20px; - height: 20px; - flex: 0 0 20px; - background-size: 15px; -} - -.metadata-cache-setting { - margin-top: 14px; - margin-bottom: 0; - padding-top: 14px; - border-top: 1px solid var(--workspace-border); -} - -.template-presets { - flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; -} - -#settingsTab .download-settings-section #filenameTemplateHint { - margin-top: 9px; -} - -#settingsTab .download-policy-settings textarea { - min-height: 72px; -} - -#settingsTab .download-policy-settings #downloadPolicyValidation:empty { - display: none; -} - -#settingsTab .download-policy-settings #downloadPolicyStatus { - margin: 8px 0 12px; -} - -.sidebar-layout-setting { - margin-top: 18px; - padding-top: 16px; - border-top: 1px solid var(--workspace-border); -} - -#settingsTab .sidebar-layout-setting .toggle-row { - gap: 10px; - font-size: 13px; - font-weight: 600; -} - -#settingsTab .sidebar-layout-setting .form-note { - margin: 7px 0 0 30px; -} - -#clipsTab.active { - display: grid; - grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr); - align-content: start; - align-items: start; - gap: 16px; -} - -#clipsTab .clip-input { - display: grid; - width: 100%; - max-width: none; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - margin: 0; - padding: 16px; - text-align: left; - background: var(--workspace-panel-raised); - border: 1px solid var(--workspace-border); - border-radius: var(--workspace-radius); -} - -#clipsTab .clip-input h2 { - grid-column: 1 / -1; - margin: 0 0 2px; - font-size: 16px; - line-height: 22px; -} - -#clipsTab .clip-input #clipUrl { - width: 100%; - min-width: 0; - min-height: 38px; - margin: 0; -} - -#clipsTab .clip-input #btnClip { - min-height: 38px; - white-space: nowrap; -} - -#clipsTab .clip-input #clipStatus { - grid-column: 1 / -1; - min-height: 20px; - margin: 0; - font-size: 12px; - line-height: 20px; -} - -#clipsTab > .settings-card.centered { - width: 100%; - max-width: none; - align-self: start; - margin: 0; -} - -.workspace-settings-search { - position: relative; - display: flex; - flex: 0 1 550px; - min-width: 180px; - height: 36px; - align-items: center; - margin-left: auto; -} - -.workspace-toolbar .toolbar-context[data-toolbar-for="settings"] { - flex: 1 1 auto; - width: 100%; -} - -.workspace-settings-search input { - width: 100%; - min-width: 0; - height: 36px; - padding: 0 36px 0 34px; - color: var(--workspace-text); - background: var(--workspace-control); - border: 1px solid var(--workspace-border); - border-radius: var(--workspace-radius-small); - transition: border-color 120ms ease, box-shadow 120ms ease; -} - -.workspace-settings-search > svg { - position: absolute; - left: 11px; - z-index: 1; - width: 14px; - height: 14px; - color: var(--workspace-text-muted); - fill: none; - stroke: currentColor; - pointer-events: none; -} - -.workspace-settings-search > button { - position: absolute; - right: 4px; - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - padding: 0; - color: var(--workspace-text-muted); - background: transparent; - border: 0; - border-radius: var(--workspace-radius-small); - cursor: pointer; -} - -.workspace-settings-search > button:hover { - color: var(--workspace-text); - background: var(--workspace-control-hover); -} - -.workspace-update-popover { - width: 260px; -} - -.workspace-update-popover-header { - position: relative; - z-index: 1; - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - color: var(--workspace-popover-text); -} - -.workspace-update-popover-header #updateText { - flex: 1 1 auto; - min-width: 0; -} - -#workspaceUpdateDismiss { - display: inline-flex; - width: 24px; - min-width: 24px; - height: 24px; - align-items: center; - justify-content: center; - padding: 0; - color: var(--workspace-popover-muted); - background: transparent; - border: 1px solid transparent; - border-radius: var(--workspace-radius-small); - cursor: pointer; -} - -#workspaceUpdateDismiss:hover { - color: var(--workspace-popover-text); - background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.18); -} - -#workspaceUpdateDismiss::before { - content: "\00d7"; - font-size: 17px; - font-weight: 500; - line-height: 1; -} - -.workspace-update-popover-actions { - position: relative; - z-index: 1; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 6px; -} - -.workspace-update-popover-actions #updateButton { - flex: 1 1 auto; -} - -#workspaceUpdateLater { - display: inline-flex; - min-height: 30px; - align-items: center; - justify-content: center; - padding: 0 9px; - color: var(--workspace-popover-text); - background: transparent; - border: 1px solid var(--workspace-popover-muted); - border-radius: var(--workspace-radius-small); - font-size: 11px; - font-weight: 600; - cursor: pointer; -} - -#workspaceUpdateLater:hover { - background: rgba(255, 255, 255, 0.12); -} - -.workspace-update-popover .update-banner-progress-track { - background: rgba(255, 255, 255, 0.22); -} - -@media (min-width: 1180px) { - .topbar-navigation-cluster { - flex: 1 1 auto; - width: auto; - max-width: none; - } - - .topbar-navigation-cluster .topbar-brand { - flex-basis: 190px; - min-width: 190px; - padding: 0 12px; - } - - .topbar-navigation-cluster .topbar-brand #logoText { - position: static; - width: auto; - height: auto; - padding: 0; - margin: 0; - overflow: hidden; - clip: auto; - white-space: nowrap; - text-overflow: ellipsis; - border: 0; - } - - .topbar-navigation-cluster .top-nav { - min-width: 0; - overflow: hidden; - } - - .topbar-navigation-cluster .top-nav-item.nav-item { - flex: 1 0 var(--top-nav-item-width, 100px); - width: auto; - min-width: 44px; - max-width: none; - padding: 0 8px; - gap: 6px; - } - - .topbar-navigation-cluster .top-nav-item .top-nav-label, - .topbar-navigation-cluster .top-nav-item.nav-item > span { - position: static; - display: block; - width: auto; - min-width: 0; - height: auto; - padding: 0; - margin: 0; - overflow: hidden; - clip: auto; - white-space: nowrap; - text-overflow: ellipsis; - border: 0; - font-size: 13px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="vods"] { - --top-nav-item-width: 110px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="clips"] { - --top-nav-item-width: 110px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="cutter"] { - --top-nav-item-width: 138px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="merge"] { - --top-nav-item-width: 180px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="stats"] { - --top-nav-item-width: 110px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="archive"] { - --top-nav-item-width: 90px; - } - - .topbar-navigation-cluster .top-nav-item[data-tab="settings"] { - --top-nav-item-width: 120px; - } -} - -@media (max-width: 1179px) { - .topbar-navigation-cluster .top-nav-item .top-nav-label, - .topbar-navigation-cluster .top-nav-item.nav-item > span { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - - .topbar-navigation-cluster .top-nav-item.nav-item { - width: 44px; - min-width: 44px; - padding: 0; - gap: 0; - } -} - -@media (max-width: 1350px) { - .workspace-settings-search { - flex-basis: 280px; - } -} - -@media (max-width: 1200px) { - .download-settings-layout { - grid-template-columns: minmax(0, 1fr); - } -} - -@media (max-width: 820px) { - #clipsTab.active { - grid-template-columns: minmax(0, 1fr); - } - - #settingsTab .form-row:has(#downloadPath) { - grid-template-columns: minmax(0, 1fr); - } -}
  • J_~KMys>+rQ$98DYINzqXeR4I+GJoEhku2d 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�mr~x7%5M`K- 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ǢIkaBZy|?a@^m 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≈r5iHy z8DfMdb7lc)(%o)d)Ln6pyTS$p`AlR?1`~ILUoLWu2u2M#vXi6VHa3z}HIcqxnKoF= z(ISa`nB5{$F(k+4V?ytb9s4W~9cZEtD!Ci90;R2EU3Dxl7-F#g@ZY+c9*pY}@(syb z)*DT^5A>JFw*^QYHMQu_L-&(0L?EtCEZ_4>;QQm<@6<#M=#cP*j|BkHH^R+&8nwLA z^Yo%8?tc#?Y)*YnPEp2p|725`VA#qVZf!F&O4m;>zpW;`K@>tkxlr60PNP6=6MU=t zrYKq1YYuy4aCr9=S4QJ?ZY}&H4?*=KKMK+J5z zkG$0)xWGK)k^$r{u0yCHqgKSGc+CzB9EO?3V9E9)VQ-2=nPIX->m{!^}|jb_7( z$2KcEm);nd7%cW}x`zfz9&I zjA5g$>EeuG^!LF9g0S)gf-clW9MuL!*FFo)SuG&2C94+=Qvwzx0`qj2YN{1w)`I*2 zd7QC!28*VgoMx8kLR_Rmt*&yY-M8ZCi3DbCBd-@!5KQX9$wkZW`tJ=rWPh5~VoY?V z9XZ>S{!)^*ef4Lad#?#)%LAAgA7F3p3#ZICBvg6)q>^_xj#6CKg{-u+zUuz63v^Cz z@-#Ecw=2FzjH9b-sKc4D`^XNa2^eDQ?t-0p2Uj*P66yH*=BdDU`Gbx5$B$sGoFI{i z4|lk-OhK;jpJTh-Anv%$LNvPBk_(HkL5}opD^Wm4C|vmV!HSX zqep5kXJkg3~ zNDs}Sf>$8-zQzR;a#_HY+oe1KiF*Ex&*R}<^(=v8v9k#zD92zs*r@%a1H(l;f!@zsJMgXV4V=n>GDq#T0*k;kZFm&+yF~0`v;&#YVFS z^L8N2uf&a9lH2Y(w;`$?Z0uTb27v|^_O^yo(x=fYg!DD1_!mPeRE2hKbG9;m0&)X~ zys7DHdXk15^f#27j+@Fy3gLR8Xp02*Y12y1)F7(ImL|Z$^Uu+yDbpg#EwC zBd>k|SH^;i9Kg)f-0*HS{TG2RIBGfU%I+UmY1QroMsNdq&psaRjV9d-Z|zYOfd+9K z(LEwUw|_``+$DgEXBhNNS7DT;t!RV0DmKpHVJ^Njz#e_7O1g;kCS0GNx1Y8A$C*Eg zPw&tJeTMwu?63i=>3JeC{e5E*+OjM21v3ZmFxboZ7$>)ucuA4>*Y^;tD!4ij*91qL zOfJbkT?>aJj2qZFy(3^v>aurqBt`86y5{D_+P?K{pusRSwrQ-4>bhd!@ch#S!jG>A zSe?&T|C2=MO~kOu`X3qkF#hlVHv{$G67K)2*9Oh$|JVP6`M*r+{~vAq`(4Y-pZTD_ npP&84iCCaNV4*4*OYs# 1) duplicates.add(`${prefix}.${key}`); + } +} + +function parseMappingField(line, indentation, listItem = false) { + let leadingSpaces = 0; + while (line[leadingSpaces] === ' ') leadingSpaces += 1; + if (leadingSpaces !== indentation) return undefined; + let body = line.slice(indentation); + if (listItem) { + if (!body.startsWith('- ')) return undefined; + body = body.slice(2); + } + const colon = body.indexOf(':'); + if (colon <= 0) return undefined; + const key = body.slice(0, colon); + if (![...key].every((character) => /[A-Za-z0-9_-]/.test(character))) return undefined; + return [key, body.slice(colon + 1).trimStart()]; +} + +function parseWorkflow(source) { + const lines = source.split(/\r?\n/); + const duplicateKeys = new Set(); + const topLevelFields = new Map(); + for (const line of lines) { + const field = parseMappingField(line, 0); + if (field) addField(topLevelFields, field[0], field[1]); + } + recordDuplicateFields(duplicateKeys, topLevelFields, 'workflow'); + const jobsStart = lines.findIndex((line) => line === 'jobs:'); + const jobs = new Map(); + if (jobsStart >= 0) { + const jobsEndOffset = lines.slice(jobsStart + 1).findIndex((line) => /^\S/.test(line)); + const jobsEnd = jobsEndOffset < 0 ? lines.length : jobsStart + 1 + jobsEndOffset; + const jobStarts = []; + for (let index = jobsStart + 1; index < jobsEnd; index += 1) { + const field = parseMappingField(lines[index], 2); + if (field?.[1] === '') jobStarts.push({ index, name: field[0] }); + } + const jobNames = new Map(); + for (const jobStart of jobStarts) addField(jobNames, jobStart.name, ''); + recordDuplicateFields(duplicateKeys, jobNames, 'jobs'); + for (let jobIndex = 0; jobIndex < jobStarts.length; jobIndex += 1) { + const start = jobStarts[jobIndex].index; + const end = jobStarts[jobIndex + 1]?.index || jobsEnd; + const fields = new Map(); + for (let index = start + 1; index < end; index += 1) { + const field = parseMappingField(lines[index], 4); + if (field) addField(fields, field[0], field[1]); + } + const jobPath = `jobs.${jobStarts[jobIndex].name}`; + recordDuplicateFields(duplicateKeys, fields, jobPath); + const env = new Map(); + const envStart = lines.findIndex((line, index) => index > start && index < end && line === ' env:'); + if (envStart >= 0) { + for (let index = envStart + 1; index < end; index += 1) { + if (lines[index].trim() && !lines[index].startsWith(' ')) break; + const field = parseMappingField(lines[index], 6); + if (field) addField(env, field[0], field[1]); + } + } + recordDuplicateFields(duplicateKeys, env, `${jobPath}.env`); + const stepsStart = lines.findIndex((line, index) => index > start && index < end && line === ' steps:'); + const steps = []; + if (stepsStart >= 0) { + const stepStarts = []; + for (let index = stepsStart + 1; index < end; index += 1) { + if (/^ {6}-\s+/.test(lines[index])) stepStarts.push(index); + } + for (let stepIndex = 0; stepIndex < stepStarts.length; stepIndex += 1) { + const stepStart = stepStarts[stepIndex]; + const stepEnd = stepStarts[stepIndex + 1] || end; + const stepFields = new Map(); + const firstField = parseMappingField(lines[stepStart], 6, true); + if (firstField) addField(stepFields, firstField[0], firstField[1]); + for (let index = stepStart + 1; index < stepEnd; index += 1) { + const field = parseMappingField(lines[index], 8); + if (field) addField(stepFields, field[0], field[1]); + } + const stepPath = `${jobPath}.steps[${stepIndex}]`; + recordDuplicateFields(duplicateKeys, stepFields, stepPath); + const stepEnv = new Map(); + const stepEnvStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' env:'); + if (stepEnvStart >= 0) { + for (let index = stepEnvStart + 1; index < stepEnd; index += 1) { + if (lines[index].trim() && !lines[index].startsWith(' ')) break; + const field = parseMappingField(lines[index], 10); + if (field) addField(stepEnv, field[0], field[1]); + } + } + recordDuplicateFields(duplicateKeys, stepEnv, `${stepPath}.env`); + const stepWith = new Map(); + const stepWithStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' with:'); + if (stepWithStart >= 0) { + for (let index = stepWithStart + 1; index < stepEnd; index += 1) { + if (lines[index].trim() && !lines[index].startsWith(' ')) break; + const field = parseMappingField(lines[index], 10); + if (field) addField(stepWith, field[0], field[1]); + } + } + recordDuplicateFields(duplicateKeys, stepWith, `${stepPath}.with`); + steps.push({ + env: stepEnv, + fields: stepFields, + name: singleField(stepFields, 'name'), + raw: lines.slice(stepStart, stepEnd).join('\n'), + with: stepWith + }); + } + } + jobs.set(jobStarts[jobIndex].name, { + env, + fields, + header: lines.slice(start + 1, stepsStart >= 0 ? stepsStart : end).join('\n'), + name: jobStarts[jobIndex].name, + steps + }); + } + } + + const dispatchInputs = new Map(); + const onStart = lines.findIndex((line) => line === 'on:'); + if (onStart >= 0) { + const onEndOffset = lines.slice(onStart + 1).findIndex((line) => /^\S/.test(line)); + const onEnd = onEndOffset < 0 ? lines.length : onStart + 1 + onEndOffset; + const dispatchStart = lines.findIndex((line, index) => index > onStart && index < onEnd && line === ' workflow_dispatch:'); + const inputsStart = lines.findIndex((line, index) => index > dispatchStart && index < onEnd && line === ' inputs:'); + if (dispatchStart >= 0 && inputsStart >= 0) { + for (let index = inputsStart + 1; index < onEnd; index += 1) { + const field = parseMappingField(lines[index], 6); + if (field?.[1] === '') addField(dispatchInputs, field[0], ''); + } + recordDuplicateFields(duplicateKeys, dispatchInputs, 'on.workflow_dispatch.inputs'); + } + } + + return { dispatchInputs, duplicateKeys: [...duplicateKeys], jobs }; +} + +function positiveTimeout(fields) { + const values = fields.get('timeout-minutes') || []; + return values.length === 1 && /^\d+$/.test(values[0]) && Number(values[0]) > 0; +} + +function validateTimeouts(workflow, label, errors) { + for (const job of workflow.jobs.values()) { + if (!positiveTimeout(job.fields)) errors.push(`${label} job ${job.name} must define exactly one positive job-level timeout-minutes`); + for (let index = 0; index < job.steps.length; index += 1) { + const step = job.steps[index]; + const executionFields = (step.fields.get('run') || []).length + (step.fields.get('uses') || []).length; + if (executionFields === 0) continue; + const stepLabel = step.name || `unnamed step ${index + 1}`; + if (executionFields !== 1) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one of run or uses`); + if (!positiveTimeout(step.fields)) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one positive timeout-minutes`); + } + } +} + +function validateCheckouts(workflow, label, errors) { + for (const job of workflow.jobs.values()) { + const checkouts = job.steps.filter((step) => /^actions\/checkout@/.test(singleField(step.fields, 'uses') || '')); + if (checkouts.length !== 1 || singleField(checkouts[0].fields, 'uses') !== 'actions/checkout@v4') { + errors.push(`${label} job ${job.name} must define exactly one actions/checkout@v4 step`); + } + } +} + +function validateSetupNode(workflow, label, errors) { + for (const job of workflow.jobs.values()) { + const setupSteps = job.steps.filter((step) => /^actions\/setup-node@/.test(singleField(step.fields, 'uses') || '')); + if (setupSteps.length !== 1 || singleField(setupSteps[0].fields, 'uses') !== 'actions/setup-node@v4' || singleField(setupSteps[0].with, 'node-version') !== "'24.11.1'") { + errors.push(`${label} job ${job.name} must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1`); + } + } +} + +function stepByName(job, name) { + const matches = job?.steps.filter((step) => step.name === name) || []; + return matches.length === 1 ? matches[0] : undefined; +} + +function stepIndexByRun(job, command) { + return job?.steps.findIndex((step) => singleField(step.fields, 'run') === command) ?? -1; +} + +function hasTrimmedLine(step, expected) { + return step?.raw.split(/\r?\n/).some((line) => line.trim() === expected) || false; +} + +const strictVersionPattern = "'^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$'"; + +function hasStrictUpdaterVersions(step) { + return hasTrimmedLine(step, '$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()') + && hasTrimmedLine(step, '$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()') + && hasTrimmedLine(step, `$versionPattern = ${strictVersionPattern}`) + && hasTrimmedLine(step, 'if ($sourceVersionText -notmatch $versionPattern) {') + && hasTrimmedLine(step, 'if ($updateVersionText -notmatch $versionPattern) {') + && hasTrimmedLine(step, '$sourceVersion = [version]$sourceVersionText') + && hasTrimmedLine(step, '$updateVersion = [version]$updateVersionText') + && !step.raw.includes('TrimStart'); +} + +function hasExactChainedCommand(script, expected) { + if (typeof script !== 'string') return false; + return script.split(/\s*&&\s*/).filter((command) => command === expected).length === 1; +} + +function validateManualGate(job, gate, label, errors) { + const expected = `github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == '${gate}'`; + if (singleField(job?.fields || new Map(), 'if') !== expected) errors.push(`${label} ${job?.name || gate} must be manual-only for ${gate}`); + if (singleField(job?.fields || new Map(), 'needs') !== 'verify') errors.push(`${label} ${job?.name || gate} must require verify`); +} + +function findSecretLeaks(job, allowedStep, secretNames) { + const leaks = []; + for (const name of secretNames) { + if (job.env.has(name) || job.header.includes(`secrets.${name}`)) leaks.push(`job:${name}`); + for (let index = 0; index < job.steps.length; index += 1) { + const step = job.steps[index]; + if (step === allowedStep) continue; + if (step.env.has(name) || step.raw.includes(`secrets.${name}`)) leaks.push(`step:${index + 1}:${name}`); + } + } + return leaks; +} + +const parserFixture = parseWorkflow(`jobs: + fixture: + timeout-minutes: 30 + steps: + - name: named + uses: actions/checkout@v4 + timeout-minutes: 10 + timeout-minutes: 11 + - run: npm test`); +const parserFixtureErrors = []; +validateTimeouts(parserFixture, 'fixture', parserFixtureErrors); +check(parserFixture.jobs.get('fixture')?.steps.length === 2, 'CI parser does not recognize an unnamed step boundary'); +check(parserFixtureErrors.includes('fixture fixture named must define exactly one positive timeout-minutes'), 'CI timeout contract accepts duplicate step timeouts'); +check(parserFixtureErrors.includes('fixture fixture unnamed step 2 must define exactly one positive timeout-minutes'), 'CI timeout contract accepts a missing timeout hidden by another step'); + +const gateFixture = parseWorkflow(`jobs: + updater-live-postpublish: + if: false + needs: verify + timeout-minutes: 30 + steps: + - run: Write-Output "github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'" + timeout-minutes: 10`); +const gateFixtureErrors = []; +validateManualGate(gateFixture.jobs.get('updater-live-postpublish'), 'updater-postpublish', 'fixture', gateFixtureErrors); +check(gateFixtureErrors.includes('fixture updater-live-postpublish must be manual-only for updater-postpublish'), 'CI manual gate contract accepts a condition embedded in run text'); + +const secretNames = ['TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID']; +const secretFixture = parseWorkflow(`jobs: + twitch-live: + timeout-minutes: 30 + steps: + - name: provider + run: npm run provider + timeout-minutes: 10 + - run: npm run unrelated + env: + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: \${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }} + timeout-minutes: 10`); +const secretFixtureJob = secretFixture.jobs.get('twitch-live'); +check(findSecretLeaks(secretFixtureJob, stepByName(secretFixtureJob, 'provider'), secretNames).includes('step:2:TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'), 'CI secret scope contract misses a leak in an unnamed following step'); + +const releaseCommandPrefixFixture = 'npm run build && npm run test:live-integration-contract-shadow'; +check(!hasExactChainedCommand(releaseCommandPrefixFixture, 'npm run test:live-integration-contract'), 'Release command contract accepts a longer command with the required command as a prefix'); +check(hasExactChainedCommand('npm run build && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract rejects an exact required command'); +check(!hasExactChainedCommand('npm run build && npm run test:unit', 'npm run test:live-integration-contract'), 'Release command contract accepts a missing required command'); +check(!hasExactChainedCommand('npm run test:live-integration-contract && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract accepts a duplicate required command'); + +const duplicateKeyFixture = parseWorkflow(`jobs: + fixture: + timeout-minutes: 30 + env: + CI: 'true' + CI: 'false' + env: + OTHER: value + steps: + - uses: actions/checkout@v4 + with: + ref: first + ref: second + with: + fetch-depth: 1 + timeout-minutes: 10`); +for (const duplicatePath of ['jobs.fixture.env', 'jobs.fixture.env.CI', 'jobs.fixture.steps[0].with', 'jobs.fixture.steps[0].with.ref']) { + check(duplicateKeyFixture.duplicateKeys?.includes(duplicatePath), `CI parser does not report duplicate YAML key ${duplicatePath}`); +} + +const duplicateJobFixture = parseWorkflow(`jobs: + fixture: + timeout-minutes: 10 + steps: + - run: npm test + timeout-minutes: 10 + fixture: + timeout-minutes: 10 + steps: + - run: npm test + timeout-minutes: 10`); +check(duplicateJobFixture.duplicateKeys?.includes('jobs.fixture'), 'CI parser does not report a duplicate job key'); + +const permissiveVersionPreflightFixture = { + raw: `$sourceVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.TrimStart('v')) +$updateVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.TrimStart('v'))` +}; +check(!hasStrictUpdaterVersions(permissiveVersionPreflightFixture), 'CI version preflight contract accepts prefixes, suffixes or leading zeroes'); + +const missingCheckoutFixture = parseWorkflow(`jobs: + fixture: + timeout-minutes: 30 + steps: + - uses: actions/setup-node@v4 + timeout-minutes: 10`); +const missingCheckoutErrors = []; +validateCheckouts(missingCheckoutFixture, 'fixture', missingCheckoutErrors); +check(missingCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts a job without checkout'); + +const extraCheckoutFixture = parseWorkflow(`jobs: + fixture: + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + timeout-minutes: 10 + - uses: actions/checkout@v3 + timeout-minutes: 10`); +const extraCheckoutErrors = []; +validateCheckouts(extraCheckoutFixture, 'fixture', extraCheckoutErrors); +check(extraCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts an additional checkout version'); + +const extraSetupNodeFixture = parseWorkflow(`jobs: + fixture: + timeout-minutes: 30 + steps: + - uses: actions/setup-node@v4 + timeout-minutes: 10 + with: + node-version: '24.11.1' + - uses: actions/setup-node@v3 + timeout-minutes: 10`); +const extraSetupNodeErrors = []; +validateSetupNode(extraSetupNodeFixture, 'fixture', extraSetupNodeErrors); +check(extraSetupNodeErrors.includes('fixture job fixture must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1'), 'CI job action contract accepts an additional setup-node version'); + +function hasSingleConditionalRetry(source, command) { + const lines = source.split(/\r?\n/); + const commandIndexes = lines + .map((line, index) => line.trim() === command ? index : -1) + .filter((index) => index >= 0); + if (commandIndexes.length !== 2) return false; + const [first, second] = commandIndexes; + return second === first + 2 + && lines[first + 1]?.trim() === 'if ($LASTEXITCODE -ne 0) {' + && lines[first + 3]?.trim() === 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }' + && lines[first + 4]?.trim() === '}'; +} + +const retryFixture = (command) => `${command}\nif ($LASTEXITCODE -ne 0) {\n ${command}\n if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n}`; +for (const command of ['npx install-electron --no', 'npm run pack', 'npm run dist:ci']) { + check(hasSingleConditionalRetry(retryFixture(command), command), `CI retry contract rejects a valid ${command} retry`); + check(!hasSingleConditionalRetry(`${retryFixture(command)}\n${command}`, command), `CI retry contract accepts more than one ${command} retry`); + check(!hasSingleConditionalRetry(`${command}\n${command}`, command), `CI retry contract accepts an unconditional ${command} retry`); +} + const requiredScripts = { lint: 'eslint .', 'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js', 'test:security': 'node --test scripts/security-check.test.js', 'test:lint-config': 'node --test scripts/lint-config.test.mjs', 'test:ci-contract': 'node scripts/smoke-test-ci-contract.js', + 'test:installer-contract': 'node --test scripts/smoke-test-installer.test.js', + 'test:managed-tools-contract': 'node --test scripts/smoke-test-managed-tools-live.test.js', + 'test:cutter-matrix-contract': 'node --test scripts/smoke-test-cutter-media-matrix.test.js', + 'test:managed-tools-live': 'node scripts/smoke-test-managed-tools-live.js', + 'test:live-integration-contract': 'npm run build && node --test scripts/smoke-test-live-integration.test.js', + 'test:live:twitch': 'node scripts/smoke-test-live-integration.js twitch', + 'test:live:updater-postpublish': 'node scripts/smoke-test-live-integration.js updater', + 'test:e2e:cutter-matrix': 'npm run build && node scripts/smoke-test-cutter-media-matrix.js', 'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui', 'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js', 'test:installer': 'node scripts/smoke-test-installer.js', @@ -25,50 +414,110 @@ for (const [name, command] of Object.entries(requiredScripts)) { check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`); } +for (const contract of ['test:installer-contract', 'test:managed-tools-contract', 'test:cutter-matrix-contract', 'test:live-integration-contract']) { + check(hasExactChainedCommand(packageJson.scripts?.['test:e2e:release'], `npm run ${contract}`), `release verification does not include exactly one ${contract} command`); +} + +const workflowSources = new Map(); for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) { const absolutePath = path.join(root, relativePath); check(fs.existsSync(absolutePath), `${relativePath} is missing`); if (!fs.existsSync(absolutePath)) continue; const source = fs.readFileSync(absolutePath, 'utf8'); + workflowSources.set(relativePath, source); + const workflow = parseWorkflow(source); + const verifyJob = workflow.jobs.get('verify'); + const twitchLiveJob = workflow.jobs.get('twitch-live'); + const updaterLiveJob = workflow.jobs.get('updater-live-postpublish'); const requiredCommands = [ 'npm ci', - 'npx install-electron --no', 'npm run lint', 'npm run test:lint-config', 'npm run security:check', 'npm run test:security', 'npm run test:ci-contract', + 'npm run test:installer-contract', + 'npm run test:managed-tools-contract', + 'npm run test:cutter-matrix-contract', + 'npm run test:live-integration-contract', 'npm run test:unit', 'npm run test:e2e:focused', 'npm run build', - 'npm run pack', + 'node scripts/smoke-test-cutter-media-matrix.js', + 'npm run test:managed-tools-live', 'npm run test:packaged-launch', - 'npm run dist:ci', 'npm run test:installer' ]; - check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`); - check(/node-version:\s*['"]?24\.11\.1['"]?/.test(source), `${relativePath} does not pin Node 24.11.1`); + check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`); + check(workflow.duplicateKeys.length === 0, `${relativePath} contains duplicate YAML keys: ${workflow.duplicateKeys.join(', ')}`); + validateTimeouts(workflow, relativePath, failures); + validateCheckouts(workflow, relativePath, failures); + validateSetupNode(workflow, relativePath, failures); + for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) { + check((workflow.dispatchInputs.get(input) || []).length === 1, `${relativePath} workflow_dispatch must define explicit ${input}`); + } + for (const job of workflow.jobs.values()) check(singleField(job.fields, 'runs-on') === 'windows-latest', `${relativePath} ${job.name} does not use a Windows runner`); for (const command of requiredCommands) { - check(source.includes(command), `${relativePath} is missing ${command}`); + check(stepIndexByRun(verifyJob, command) >= 0, `${relativePath} verify job is missing an exact ${command} run step`); } + const verifyBuildIndex = stepIndexByRun(verifyJob, 'npm run build'); + const verifyLiveContractIndex = stepIndexByRun(verifyJob, 'npm run test:live-integration-contract'); + check(verifyBuildIndex >= 0 && verifyBuildIndex < verifyLiveContractIndex, `${relativePath} verify must build before the live integration contract`); + check(verifyBuildIndex < stepIndexByRun(verifyJob, 'npm run test:managed-tools-live'), `${relativePath} runs the live managed-tools check before build`); for (const command of ['npm run pack', 'npm run dist:ci']) { - check(source.match(new RegExp(command.replace(/[:]/g, '\\:'), 'g'))?.length >= 2, `${relativePath} does not retry transient ${command} failures`); + check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', command), `${relativePath} does not retry transient ${command} failures exactly once`); } - check(source.match(/npx install-electron --no/g)?.length >= 2, `${relativePath} does not retry Electron binary provisioning`); - const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line)); - const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line)); - check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`); - check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`); + check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', 'npx install-electron --no'), `${relativePath} does not retry Electron binary provisioning exactly once`); + check(findSecretLeaks(verifyJob, undefined, secretNames).length === 0, `${relativePath} exposes Twitch live inputs to normal CI`); + validateManualGate(twitchLiveJob, 'twitch', relativePath, failures); + const twitchProviderStep = stepByName(twitchLiveJob, 'Twitch provider OAuth, Helix and bounded VOD gate'); + for (const name of secretNames) { + check(singleField(twitchProviderStep?.env || new Map(), name) === `\${{ secrets.${name} }}`, `${relativePath} Twitch live ${name.toLowerCase()} is not scoped to the final provider step`); + } + check(findSecretLeaks(twitchLiveJob, twitchProviderStep, secretNames).length === 0, `${relativePath} exposes Twitch secrets outside the final provider step`); + check(singleField(twitchProviderStep?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && singleField(twitchProviderStep?.fields || new Map(), 'run') === 'npm run test:live:twitch', `${relativePath} Twitch live gate is not explicitly opted in at the final provider step`); + check(twitchLiveJob?.steps.some((step) => step.name === 'Provision pinned media tools' && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH') && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH')), `${relativePath} Twitch live gate does not provision its real media tools`); + check(stepIndexByRun(twitchLiveJob, 'npm run build') >= 0 && stepIndexByRun(twitchLiveJob, 'npm run build') < stepIndexByRun(twitchLiveJob, 'npm run test:live-integration-contract'), `${relativePath} Twitch live gate must build before its integration contract`); + validateManualGate(updaterLiveJob, 'updater-postpublish', relativePath, failures); + for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) { + check(singleField(updaterLiveJob?.env || new Map(), `TWITCH_VOD_MANAGER_LIVE_${input.toUpperCase()}`) === `\${{ github.event.inputs.${input} }}`, `${relativePath} updater live gate does not bind explicit ${input}`); + } + check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA') === '${{ github.sha }}', `${relativePath} updater live gate does not bind provenance to github.sha`); + const updaterCheckout = updaterLiveJob?.steps.filter((step) => singleField(step.fields, 'uses') === 'actions/checkout@v4') || []; + check(updaterCheckout.length === 1 && singleField(updaterCheckout[0].with, 'ref') === '${{ github.sha }}', `${relativePath} updater checkout is not explicitly bound to github.sha`); + check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && stepIndexByRun(updaterLiveJob, 'npm run test:live:updater-postpublish') >= 0, `${relativePath} updater live gate is not explicitly opted in`); + check(stepIndexByRun(updaterLiveJob, 'npm run build') >= 0 && stepIndexByRun(updaterLiveJob, 'npm run build') < stepIndexByRun(updaterLiveJob, 'npm run test:live-integration-contract'), `${relativePath} updater live gate must build before its integration contract`); + const updaterPreflight = stepByName(updaterLiveJob, 'Require explicit post-publish updater inputs'); + check(updaterPreflight && secretNames.every((name) => !updaterPreflight.raw.includes(name)), `${relativePath} updater preflight references Twitch credentials`); + check(hasTrimmedLine(updaterPreflight, "'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'") && hasTrimmedLine(updaterPreflight, "if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {") && hasTrimmedLine(updaterPreflight, 'if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {'), `${relativePath} updater preflight does not verify commit provenance against GITHUB_SHA`); + check(hasStrictUpdaterVersions(updaterPreflight) && hasTrimmedLine(updaterPreflight, 'if ($sourceVersion -ge $updateVersion) {'), `${relativePath} updater preflight does not require exact source_version < update_version`); + check(hasTrimmedLine(updaterPreflight, '$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version') && hasTrimmedLine(updaterPreflight, 'if ($updateVersionText -ne $packageVersion) {') && hasTrimmedLine(updaterPreflight, '$expectedRef = "refs/tags/v$updateVersionText"') && hasTrimmedLine(updaterPreflight, 'if ($env:GITHUB_REF -ne $expectedRef) {'), `${relativePath} updater preflight does not bind the exact update version text to package.json and its release tag`); + const updaterExecutionStep = stepByName(updaterLiveJob, 'Verify published updater path'); + check(singleField(updaterExecutionStep?.fields || new Map(), 'timeout-minutes') === '25', `${relativePath} updater live gate does not allow 25 minutes for a real installer download`); } +check(workflowSources.get('.github/workflows/windows-ci.yml') === workflowSources.get('.gitea/workflows/windows-ci.yml'), 'GitHub and Gitea workflows are not byte-identical'); + +const liveIntegrationSource = fs.readFileSync(path.join(root, 'scripts/smoke-test-live-integration.js'), 'utf8'); +check(/dist['"],\s*['"]main['"],\s*['"]twitch['"]/.test(liveIntegrationSource), 'Twitch provider live gate does not load the built Twitch product module'); +check(liveIntegrationSource.includes('TwitchAppTokenService') && liveIntegrationSource.includes('requestTwitchAppAccessToken'), 'Twitch provider live gate bypasses the product token service'); + for (const relativePath of [ 'scripts/security-check.js', 'scripts/security-check.test.js', 'scripts/lint-config.test.mjs', 'scripts/smoke-test-packaged-launch.js', - 'scripts/smoke-test-installer.js' + 'scripts/smoke-test-installer.js', + 'scripts/smoke-test-installer.test.js', + 'scripts/smoke-test-managed-tools-live.js', + 'scripts/smoke-test-managed-tools-live.test.js', + 'scripts/smoke-test-live-integration-contract.js', + 'scripts/smoke-test-live-integration.js', + 'scripts/smoke-test-live-integration.test.js', + 'scripts/smoke-test-cutter-media-matrix.js', + 'scripts/smoke-test-cutter-media-matrix.test.js' ]) { check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`); } diff --git a/scripts/smoke-test-cutter-media-matrix.js b/scripts/smoke-test-cutter-media-matrix.js new file mode 100644 index 0000000..ee6896e --- /dev/null +++ b/scripts/smoke-test-cutter-media-matrix.js @@ -0,0 +1,1052 @@ +const { _electron: electron } = require('playwright'); +const nodeCrypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { requireFileCapability } = require('./file-capability-contract'); +const { + cleanupE2eEnvironment, + createE2eEnvironment, + getElectronLaunchOptions, + verifyE2eIsolation +} = require('./e2e-test-environment'); + +const fixtureDuration = 3; +const exportTrimStart = 0.25; +const exportTrimEnd = 2.75; +const expectedExportDuration = exportTrimEnd - exportTrimStart; +const projectRoot = path.resolve(__dirname, '..'); +const offlineProxy = 'http://127.0.0.1:1'; +const cutterMatrixTimeouts = Object.freeze({ + prepareSource: 90000, + exportSource: 180000, + appClose: 15000, + appProcessExit: 5000, + diagnostics: 30000 +}); +const markerFrequencies = Object.freeze({ + initial: 220, + start: 440, + end: 1760 +}); +const markerTimes = Object.freeze({ + initialEnd: 0.2, + startEnd: 0.55, + endStart: 2.55, + sourceStartSample: 0.3, + sourceEndSample: 2.65, + outputStartSample: 0.05, + outputEndSample: expectedExportDuration - 0.15 +}); +const markerAudioSampleRate = 48000; + +function assertPathInside(targetPath, parentPath, label) { + const resolvedTarget = path.resolve(targetPath); + const resolvedParent = path.resolve(parentPath); + const relative = path.relative(resolvedParent, resolvedTarget); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`${label} is outside the owned managed-tool directory: ${resolvedTarget}`); + } +} + +function isDigitCharacter(character) { + return character >= '0' && character <= '9'; +} + +function isBuildSuffixCharacter(character) { + return isDigitCharacter(character) + || (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') + || character === '.' + || character === '_' + || character === '-' + || character === '+'; +} + +function parseVersionToken(versionToken) { + if (!versionToken) return null; + const delimiterIndexes = [versionToken.indexOf('-'), versionToken.indexOf('+')].filter((index) => index >= 0); + const delimiterIndex = delimiterIndexes.length > 0 ? Math.min(...delimiterIndexes) : -1; + const numericVersion = delimiterIndex >= 0 ? versionToken.slice(0, delimiterIndex) : versionToken; + const suffix = delimiterIndex >= 0 ? versionToken.slice(delimiterIndex + 1) : null; + const delimiter = delimiterIndex >= 0 ? versionToken[delimiterIndex] : null; + const numericSegments = numericVersion.split('.'); + if (numericSegments.length < 2 || numericSegments.some((segment) => segment.length === 0 || [...segment].some((character) => !isDigitCharacter(character)))) return null; + if (suffix !== null && (suffix.length === 0 || [...suffix].some((character) => !isBuildSuffixCharacter(character)))) return null; + return { numericVersion, delimiter, suffix }; +} + +function assertPinnedVersion(output, version, label) { + const executable = String(label).toLowerCase().includes('streamlink') + ? 'streamlink' + : String(label).toLowerCase().includes('ffprobe') + ? 'ffprobe' + : 'ffmpeg'; + const firstLine = String(output).split(/\r?\n/, 1)[0].trim(); + const tokens = firstLine.split(/\s+/); + const versionToken = executable === 'streamlink' ? tokens[1] : tokens[2]; + const hasExpectedFormat = executable === 'streamlink' || tokens[1]?.toLowerCase() === 'version'; + const parsedVersion = parseVersionToken(versionToken); + if (tokens[0]?.toLowerCase() !== executable || !hasExpectedFormat || !parsedVersion) { + throw new Error(`${label} does not expose a parseable version: ${output}`); + } + if (parsedVersion.numericVersion !== String(version)) { + throw new Error(`${label} version output does not match pinned ${version}: ${output}`); + } + const expectedGyanSuffix = executable === 'streamlink' ? null : 'essentials_build-www.gyan.dev'; + if (parsedVersion.suffix !== null && (parsedVersion.delimiter !== '-' || parsedVersion.suffix !== expectedGyanSuffix)) { + throw new Error(`${label} does not expose a parseable version: ${output}`); + } +} + +async function withOperationTimeout(operation, label, timeoutMs) { + let timeout; + try { + return await Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }) + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function runBinary(binary, args, timeout = 120000, execute = spawnSync) { + const result = execute(binary, args, { + windowsHide: true, + stdio: 'pipe', + encoding: 'utf8', + timeout + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr.slice(0, 2000) || `${binary} exited with ${result.status}`); + return result.stdout; +} + +function runBinaryBuffer(binary, args, timeout = 120000, execute = spawnSync) { + const result = execute(binary, args, { + windowsHide: true, + stdio: 'pipe', + encoding: null, + timeout + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(Buffer.from(result.stderr || '').toString('utf8').slice(0, 2000) || `${binary} exited with ${result.status}`); + return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || ''); +} + +function runVersionCheck(executablePath, args, label) { + const output = runBinary(executablePath, args, 60000); + if (!output.trim()) throw new Error(`${label} version check produced no output`); + return output.split(/\r?\n/, 1)[0]; +} + +function loadBuiltToolArtifacts() { + const toolsPath = path.join(projectRoot, 'dist', 'tools.js'); + const manifestPath = path.join(projectRoot, 'dist', 'main', 'domain', 'tool-manifest.js'); + if (!fs.existsSync(toolsPath)) throw new Error('Build output is missing; run npm run build first'); + if (!fs.existsSync(manifestPath)) throw new Error('Built tool manifest is missing; run npm run build first'); + return { + tools: require(toolsPath), + manifest: require(manifestPath).APPLICATION_TOOL_MANIFEST + }; +} + +function assertVerifiedManagedStatus(status, expectedVersion, label) { + if (!status?.verified || status.state !== 'verified' || status.version !== expectedVersion) { + throw new Error(`${label} is not verified at pinned ${expectedVersion}: ${JSON.stringify(status)}`); + } +} + +async function provisionManagedCutterTools(environment, options = {}) { + const streamlinkDirectory = path.join(environment.appDataDir, 'tools', 'streamlink'); + const ffmpegDirectory = path.join(environment.appDataDir, 'tools', 'ffmpeg'); + const temporaryDirectory = path.join(environment.rootDir, 'managed-tools-temp'); + for (const directory of [streamlinkDirectory, ffmpegDirectory, temporaryDirectory]) { + assertPathInside(directory, environment.rootDir, 'Managed-tool directory'); + fs.mkdirSync(directory, { recursive: true }); + } + const loadArtifacts = options.loadBuiltArtifacts || loadBuiltToolArtifacts; + const checkVersion = options.runVersionCheck || runVersionCheck; + const { tools, manifest } = loadArtifacts(); + tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory); + const repair = await tools.repairManagedTools(); + if (!repair.success) throw new Error(`Pinned product-tool provisioning failed: ${JSON.stringify(repair.statuses)}`); + assertVerifiedManagedStatus(repair.statuses.streamlink, manifest.streamlink.version, 'Streamlink'); + assertVerifiedManagedStatus(repair.statuses.ffmpeg, manifest.ffmpeg.version, 'FFmpeg'); + const paths = { + streamlink: fs.realpathSync.native(tools.getStreamlinkPath()), + ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()), + ffprobe: fs.realpathSync.native(tools.getFFprobePath()) + }; + assertPathInside(paths.streamlink, streamlinkDirectory, 'Streamlink path'); + assertPathInside(paths.ffmpeg, ffmpegDirectory, 'FFmpeg path'); + assertPathInside(paths.ffprobe, ffmpegDirectory, 'FFprobe path'); + const versions = { + streamlink: checkVersion(paths.streamlink, ['--version'], 'Streamlink'), + ffmpeg: checkVersion(paths.ffmpeg, ['-version'], 'FFmpeg'), + ffprobe: checkVersion(paths.ffprobe, ['-version'], 'FFprobe') + }; + assertPinnedVersion(versions.streamlink, manifest.streamlink.version, 'Streamlink'); + assertPinnedVersion(versions.ffmpeg, manifest.ffmpeg.version, 'FFmpeg'); + assertPinnedVersion(versions.ffprobe, manifest.ffmpeg.version, 'FFprobe'); + return { manifest, paths, statuses: repair.statuses, versions }; +} + +function createManagedMediaRuntime(paths, execute = spawnSync) { + if (!path.isAbsolute(paths.ffmpeg) || !path.isAbsolute(paths.ffprobe)) { + throw new Error('Managed media runtime requires absolute product-tool paths'); + } + return { + ffmpeg: (args, timeout) => runBinary(paths.ffmpeg, args, timeout, execute), + ffmpegBuffer: (args, timeout) => runBinaryBuffer(paths.ffmpeg, args, timeout, execute), + ffprobe: (args, timeout) => runBinary(paths.ffprobe, args, timeout, execute) + }; +} + +function setEnvironmentValue(environmentVariables, expectedKey, value, snapshots) { + const key = Object.keys(environmentVariables).find((candidate) => candidate.toLowerCase() === expectedKey.toLowerCase()) || expectedKey; + snapshots.push({ key, existed: Object.prototype.hasOwnProperty.call(environmentVariables, key), value: environmentVariables[key] }); + environmentVariables[key] = value; +} + +function getEnvironmentValue(environmentVariables, expectedKey) { + const key = Object.keys(environmentVariables).find((candidate) => candidate.toLowerCase() === expectedKey.toLowerCase()); + return key ? environmentVariables[key] : undefined; +} + +function activateOfflineRunnerEnvironment(environment, environmentVariables = process.env) { + const directories = { + path: path.join(environment.rootDir, 'offline-path'), + localAppData: path.join(environment.rootDir, 'localappdata'), + roamingAppData: path.join(environment.rootDir, 'roamingappdata'), + temp: path.join(environment.rootDir, 'runtime-temp') + }; + for (const directory of Object.values(directories)) { + assertPathInside(directory, environment.rootDir, 'Offline runner directory'); + fs.mkdirSync(directory, { recursive: true }); + } + const snapshots = []; + const values = { + PATH: directories.path, + HTTP_PROXY: offlineProxy, + HTTPS_PROXY: offlineProxy, + ALL_PROXY: offlineProxy, + NO_PROXY: '', + http_proxy: offlineProxy, + https_proxy: offlineProxy, + all_proxy: offlineProxy, + no_proxy: '', + LOCALAPPDATA: directories.localAppData, + APPDATA: directories.roamingAppData, + TEMP: directories.temp, + TMP: directories.temp, + PROGRAMDATA: environment.programDataDir || path.dirname(environment.appDataDir) + }; + for (const [key, value] of Object.entries(values)) setEnvironmentValue(environmentVariables, key, value, snapshots); + let restored = false; + return () => { + if (restored) return; + restored = true; + for (const snapshot of snapshots.reverse()) { + if (snapshot.existed) environmentVariables[snapshot.key] = snapshot.value; + else delete environmentVariables[snapshot.key]; + } + }; +} + +function resolvePowerShellExecutable() { + const windowsDirectory = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows'; + const executable = path.join(windowsDirectory, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (!fs.existsSync(executable)) throw new Error(`PowerShell is missing: ${executable}`); + return fs.realpathSync.native(executable); +} + +function probeMedia(runtime, filePath) { + const args = ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams']; + args.push(filePath); + return JSON.parse(runtime.ffprobe(args)); +} + +function probeVideoTimestamps(runtime, filePath) { + const output = runtime.ffprobe([ + '-v', 'error', '-print_format', 'json', '-select_streams', 'v:0', + '-show_frames', '-show_entries', 'frame=best_effort_timestamp_time', filePath + ]); + const frames = JSON.parse(output).frames || []; + return frames.map((frame) => Number(frame.best_effort_timestamp_time)).filter(Number.isFinite); +} + +function assertVideoCadence(runtime, definition, filePath) { + const timestamps = probeVideoTimestamps(runtime, filePath); + assertCondition(timestamps.length > 1, `${definition.name} output has fewer than two decoded video frames`); + assertCondition(timestamps.every((timestamp, index) => index === 0 || timestamp > timestamps[index - 1]), `${definition.name} output frame timestamps are not strictly monotone`); + const deltas = timestamps.slice(1).map((timestamp, index) => timestamp - timestamps[index]); + const expectedDelta = 1 / definition.expectedOutputRealFps; + const cadenceTolerance = Math.max(0.0015, expectedDelta * 0.08); + if (definition.variableFrameRate) { + const cadenceMultiples = deltas.map((delta) => Math.round(delta / expectedDelta)); + assertCondition(new Set(cadenceMultiples).size >= 2, `${definition.name} output lost its variable frame cadence`); + assertCondition(deltas.every((delta, index) => cadenceMultiples[index] >= 1 && cadenceMultiples[index] <= 2 && Math.abs(delta - cadenceMultiples[index] * expectedDelta) <= cadenceTolerance), `${definition.name} output cadence contains unexpected VFR deltas: ${JSON.stringify([...new Set(deltas.map((delta) => Number(delta.toFixed(6))))])}`); + } else { + assertCondition(deltas.every((delta) => Math.abs(delta - expectedDelta) <= cadenceTolerance), `${definition.name} output cadence contains unexpected CFR deltas: ${JSON.stringify([...new Set(deltas.map((delta) => Number(delta.toFixed(6))))])}`); + } + return { + frames: timestamps.length, + start: timestamps[0], + end: timestamps.at(-1) + expectedDelta, + distinctDeltas: [...new Set(deltas.map((delta) => Number(delta.toFixed(6))))] + }; +} + +function probePacketTimeline(runtime, filePath, streamSelector) { + const output = runtime.ffprobe([ + '-v', 'error', '-print_format', 'json', '-select_streams', streamSelector, + '-show_packets', '-show_entries', 'packet=pts_time,duration_time', filePath + ]); + const packets = (JSON.parse(output).packets || []).map((packet) => ({ + start: Number(packet.pts_time), + duration: Number(packet.duration_time) + })).filter((packet) => Number.isFinite(packet.start)); + assertCondition(packets.length > 0, `${path.basename(filePath)} has no ${streamSelector} packets`); + return { + start: packets[0].start, + end: Math.max(...packets.map((packet) => packet.start + (Number.isFinite(packet.duration) ? packet.duration : 0))), + packets: packets.length + }; +} + +function decodeMedia(runtime, filePath) { + runtime.ffmpeg(['-v', 'error', '-i', filePath, '-map', '0:v:0', '-map', '0:a:0?', '-f', 'null', '-']); +} + +function sha256(filePath) { + return nodeCrypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function mediaDuration(probe) { + return Number(probe.format?.duration || probe.streams?.find((stream) => stream.codec_type === 'video')?.duration || 0); +} + +function parseRate(value) { + if (typeof value !== 'string') return 0; + const [numerator, denominator = '1'] = value.split('/').map(Number); + return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 ? numerator / denominator : 0; +} + +function assertCondition(condition, message) { + if (!condition) throw new Error(message); +} + +function assertNoExportArtifacts(directory, outputName) { + const outputExtension = path.extname(outputName); + const outputStem = path.basename(outputName, outputExtension); + const artifacts = fs.readdirSync(directory).filter((name) => + name.includes(`${outputStem}.`) && (name.includes('.tvm-edit') || name.includes('.tvm-backup')) + ); + assertCondition(artifacts.length === 0, `Temporary export artifacts remain for ${outputName}: ${JSON.stringify(artifacts)}`); +} + +function createVideoMarkerSource(rate) { + return [ + `color=c=blue:size=320x180:rate=${rate}:duration=${fixtureDuration}`, + `drawbox=color=red:t=fill:enable='lt(t,${markerTimes.initialEnd})'`, + `drawbox=color=lime:t=fill:enable='between(t,${markerTimes.initialEnd},${markerTimes.startEnd})'`, + `drawbox=color=magenta:t=fill:enable='between(t,2.2,${markerTimes.endStart})'`, + `drawbox=color=cyan:t=fill:enable='gte(t,${markerTimes.endStart})'` + ].join(','); +} + +function createAudioMarkerSource(middleFrequency) { + const expression = [ + `if(lt(t\\,${markerTimes.initialEnd})\\,sin(2*PI*${markerFrequencies.initial}*t)`, + `if(lt(t\\,${markerTimes.startEnd})\\,sin(2*PI*${markerFrequencies.start}*t)`, + `if(lt(t\\,${markerTimes.endStart})\\,sin(2*PI*${middleFrequency}*t)`, + `sin(2*PI*${markerFrequencies.end}*t))))` + ].join('\\,'); + return `aevalsrc=${expression}:s=${markerAudioSampleRate}:d=${fixtureDuration}`; +} + +function sampleVideoRgb(runtime, filePath, time) { + const output = runtime.ffmpegBuffer([ + '-v', 'error', '-i', filePath, '-ss', String(time), + '-frames:v', '1', '-vf', 'scale=1:1:flags=area,format=rgb24', + '-f', 'rawvideo', 'pipe:1' + ]); + assertCondition(output.length >= 3, `${path.basename(filePath)} produced no decoded RGB marker`); + return [output[0], output[1], output[2]]; +} + +function estimatePcmFrequency(buffer, sampleRate = markerAudioSampleRate) { + if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer.length % 2 !== 0) return 0; + const crossings = []; + let state = 0; + for (let index = 0; index < buffer.length / 2; index += 1) { + const sample = buffer.readInt16LE(index * 2); + if (sample <= -500) state = -1; + else if (sample >= 500 && state === -1) { + crossings.push(index); + state = 1; + } + } + if (crossings.length < 2) return 0; + return sampleRate * (crossings.length - 1) / (crossings.at(-1) - crossings[0]); +} + +function sampleAudioFrequency(runtime, filePath, time) { + const output = runtime.ffmpegBuffer([ + '-v', 'error', '-i', filePath, '-ss', String(time), '-t', '0.1', + '-map', '0:a:0', '-ac', '1', '-ar', String(markerAudioSampleRate), + '-f', 's16le', 'pipe:1' + ]); + return estimatePcmFrequency(output, markerAudioSampleRate); +} + +function classifyVideoMarker(rgb) { + const [red, green, blue] = rgb; + if (green > red + 35 && green > blue + 35) return 'green'; + if (green > red + 35 && blue > red + 35 && Math.abs(green - blue) <= 80) return 'cyan'; + if (red > green + 35 && red > blue + 35) return 'red'; + if (red > green + 35 && blue > green + 35) return 'magenta'; + return 'unknown'; +} + +function assertTrimBoundaryMarkers(observation, label) { + const startVideoMarker = classifyVideoMarker(observation.startVideoRgb); + const endVideoMarker = classifyVideoMarker(observation.endVideoRgb); + assertCondition(startVideoMarker === 'green', `${label} start video marker is ${startVideoMarker}: ${JSON.stringify(observation.startVideoRgb)}`); + assertCondition(endVideoMarker === 'cyan', `${label} end video marker is ${endVideoMarker}: ${JSON.stringify(observation.endVideoRgb)}`); + assertCondition(Math.abs(observation.startAudioFrequency - markerFrequencies.start) <= markerFrequencies.start * 0.12, `${label} start audio marker is ${observation.startAudioFrequency}`); + assertCondition(Math.abs(observation.endAudioFrequency - markerFrequencies.end) <= markerFrequencies.end * 0.12, `${label} end audio marker is ${observation.endAudioFrequency}`); +} + +function observeTrimBoundaryMarkers(runtime, filePath, startTime, endTime) { + return { + startVideoRgb: sampleVideoRgb(runtime, filePath, startTime), + endVideoRgb: sampleVideoRgb(runtime, filePath, endTime), + startAudioFrequency: sampleAudioFrequency(runtime, filePath, startTime), + endAudioFrequency: sampleAudioFrequency(runtime, filePath, endTime) + }; +} + +function createFixture(environment, runtime, definition) { + const filePath = path.join(environment.mediaDir, definition.fileName); + runtime.ffmpeg(definition.ffmpegArgs(filePath)); + const probe = probeMedia(runtime, filePath); + const video = probe.streams.find((stream) => stream.codec_type === 'video'); + const audio = probe.streams.filter((stream) => stream.codec_type === 'audio'); + assertCondition(video?.codec_name === definition.sourceVideoCodec, `${definition.name} source codec is ${video?.codec_name}`); + assertCondition(audio.length === definition.audioFrequencies.length, `${definition.name} source has ${audio.length} audio streams`); + assertCondition(JSON.stringify(audio.map((stream) => stream.channels)) === JSON.stringify(definition.sourceAudioChannels), `${definition.name} source audio channels are ${JSON.stringify(audio.map((stream) => stream.channels))}`); + assertCondition(Math.abs(mediaDuration(probe) - fixtureDuration) <= 0.25, `${definition.name} source duration is ${mediaDuration(probe)}`); + const averageRate = parseRate(video.avg_frame_rate); + const realRate = parseRate(video.r_frame_rate); + assertCondition(Math.abs(averageRate - definition.sourceFps) <= definition.sourceFpsTolerance, `${definition.name} source FPS is ${averageRate}`); + assertCondition(Math.abs(realRate - definition.sourceRealFps) <= definition.sourceFpsTolerance, `${definition.name} source real FPS is ${realRate}`); + if (definition.variableFrameRate) { + assertCondition(Math.abs(averageRate - realRate) / Math.max(averageRate, realRate) > 0.005, `${definition.name} source rates do not satisfy production VFR detection: ${averageRate}/${realRate}`); + const timestamps = probeVideoTimestamps(runtime, filePath); + const deltas = timestamps.slice(1).map((timestamp, index) => Number((timestamp - timestamps[index]).toFixed(6))); + assertCondition(new Set(deltas).size >= 2, `${definition.name} fixture is not variable frame rate: ${JSON.stringify([...new Set(deltas)])}`); + } + const trimMarkers = observeTrimBoundaryMarkers(runtime, filePath, markerTimes.sourceStartSample, markerTimes.sourceEndSample); + assertTrimBoundaryMarkers(trimMarkers, `${definition.name} source`); + return { filePath, probe, hash: sha256(filePath), trimMarkers }; +} + +function createMatrixDefinitions() { + const createInputs = (rate, audioFrequencies) => [ + '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', createVideoMarkerSource(rate), + ...audioFrequencies.flatMap((frequency) => ['-f', 'lavfi', '-i', createAudioMarkerSource(frequency)]) + ]; + return [ + { + id: 'av1-multi', + name: 'MKV AV1 29.97 multi-audio', + fileName: 'matrix-av1-2997-multi.mkv', + outputName: 'matrix-av1-2997-multi-export.mp4', + profile: 'quality', + audioStreamIndex: 1, + audioFrequencies: [440, 880], + sourceAudioChannels: [1, 2], + expectedAudioChannels: 2, + sourceVideoCodec: 'av1', + sourceFps: 30000 / 1001, + sourceRealFps: 30000 / 1001, + sourceFpsTolerance: 0.01, + expectedOutputRealFps: 30000 / 1001, + outputFpsTolerance: 0.02, + variableFrameRate: false, + ffmpegArgs: (filePath) => [ + ...createInputs('30000/1001', [440, 880]), + '-map', '0:v:0', '-map', '1:a:0', '-map', '2:a:0', + '-t', String(fixtureDuration), '-c:v', 'libaom-av1', '-cpu-used', '8', '-crf', '42', '-b:v', '0', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-ac:a:0', '1', '-ac:a:1', '2', '-metadata:s:a:0', 'language=eng', '-metadata:s:a:1', 'language=deu', '-shortest', '-y', filePath + ] + }, + { + id: 'hevc-ts', + name: 'MPEG-TS HEVC 59.94', + fileName: 'matrix-hevc-5994.ts', + outputName: 'matrix-hevc-5994-export.mp4', + profile: 'fast', + audioStreamIndex: 0, + audioFrequencies: [660], + sourceAudioChannels: [1], + expectedAudioChannels: 1, + sourceVideoCodec: 'hevc', + sourceFps: 60000 / 1001, + sourceRealFps: 60000 / 1001, + sourceFpsTolerance: 0.01, + expectedOutputRealFps: 60000 / 1001, + outputFpsTolerance: 0.02, + variableFrameRate: false, + ffmpegArgs: (filePath) => [ + ...createInputs('60000/1001', [660]), + '-t', String(fixtureDuration), '-c:v', 'libx265', '-preset', 'ultrafast', '-x265-params', 'log-level=error', '-crf', '35', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-ac', '1', '-f', 'mpegts', '-shortest', '-y', filePath + ] + }, + { + id: 'avi', + name: 'AVI MPEG-4', + fileName: 'matrix-mpeg4.avi', + outputName: 'matrix-mpeg4-export.mkv', + profile: 'archive', + audioStreamIndex: 0, + audioFrequencies: [330], + sourceAudioChannels: [1], + expectedAudioChannels: 1, + sourceVideoCodec: 'mpeg4', + sourceFps: 25, + sourceRealFps: 25, + sourceFpsTolerance: 0.01, + expectedOutputRealFps: 25, + outputFpsTolerance: 0.01, + variableFrameRate: false, + ffmpegArgs: (filePath) => [ + ...createInputs('25', [330]), + '-t', String(fixtureDuration), '-c:v', 'mpeg4', '-q:v', '4', '-pix_fmt', 'yuv420p', + '-c:a', 'libmp3lame', '-ac', '1', '-shortest', '-y', filePath + ] + }, + { + id: 'vfr', + name: 'MP4 H.264 VFR', + fileName: 'matrix-vfr.mp4', + outputName: 'matrix-vfr-export.mp4', + profile: 'balanced', + audioStreamIndex: 0, + audioFrequencies: [550], + sourceAudioChannels: [1], + expectedAudioChannels: 1, + sourceVideoCodec: 'h264', + sourceFps: 50, + sourceRealFps: 60, + sourceFpsTolerance: 1, + expectedOutputRealFps: 60, + outputFpsTolerance: 3, + variableFrameRate: true, + ffmpegArgs: (filePath) => [ + ...createInputs('60', [550]), + '-vf', "select='if(lt(t,1),not(mod(n,2)),1)'", '-fps_mode', 'vfr', + '-t', String(fixtureDuration), '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '32', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-ac', '1', '-shortest', '-y', filePath + ] + } + ]; +} + +async function createCutterCapability(win, filePath) { + const inputId = `cutter-matrix-${nodeCrypto.randomUUID()}`; + await win.evaluate((id) => { + const input = document.createElement('input'); + input.type = 'file'; + input.id = id; + document.body.appendChild(input); + }, inputId); + await win.locator(`#${inputId}`).setInputFiles(filePath); + const capability = await win.evaluate(async (id) => { + const input = document.getElementById(id); + const file = input instanceof HTMLInputElement ? input.files?.[0] : null; + const selection = file ? await window.api.selectDroppedVideo(file) : null; + input?.remove(); + return selection; + }, inputId); + return requireFileCapability(capability); +} + +async function prepareSource(win, filePath, options = {}) { + const timeoutMs = options.timeoutMs ?? cutterMatrixTimeouts.prepareSource; + const createCapability = options.createCapability || createCutterCapability; + return await withOperationTimeout(async () => { + const capability = await createCapability(win, filePath); + const media = await win.evaluate((selection) => window.api.prepareVideoEditorMedia(selection.token), capability); + return { capability, media }; + }, 'prepareSource', timeoutMs); +} + +async function exportSource(win, definition, source, timeoutMs = cutterMatrixTimeouts.exportSource) { + return await withOperationTimeout(() => win.evaluate(({ inputCapability, outputName, trimStart, trimEnd, profile, audioStreamIndex }) => window.api.exportVideoEdit({ + inputCapability, + outputName, + trimStart, + trimEnd, + cuts: [], + profile, + encoder: 'software', + audioStreamIndex + }), { + inputCapability: source.capability.token, + outputName: definition.outputName, + trimStart: exportTrimStart, + trimEnd: exportTrimEnd, + profile: definition.profile, + audioStreamIndex: definition.audioStreamIndex + }), 'exportSource', timeoutMs); +} + +function analyzeExport(environment, runtime, definition, source) { + const outputFile = path.join(environment.mediaDir, definition.outputName); + assertCondition(fs.existsSync(outputFile), `${definition.name} output was not created`); + const probe = probeMedia(runtime, outputFile); + const videoStreams = probe.streams.filter((stream) => stream.codec_type === 'video'); + const audioStreams = probe.streams.filter((stream) => stream.codec_type === 'audio'); + assertCondition(videoStreams.length === 1, `${definition.name} output has ${videoStreams.length} video streams`); + assertCondition(audioStreams.length === 1, `${definition.name} output has ${audioStreams.length} audio streams`); + assertCondition(videoStreams[0].codec_name === (definition.profile === 'archive' ? 'ffv1' : 'h264'), `${definition.name} output video codec is ${videoStreams[0].codec_name}`); + assertCondition(audioStreams[0].codec_name === (definition.profile === 'archive' ? 'flac' : 'aac'), `${definition.name} output audio codec is ${audioStreams[0].codec_name}`); + assertCondition(audioStreams[0].channels === definition.expectedAudioChannels, `${definition.name} selected audio stream has ${audioStreams[0].channels} channels instead of ${definition.expectedAudioChannels}`); + const duration = mediaDuration(probe); + assertCondition(Math.abs(duration - expectedExportDuration) <= 0.16, `${definition.name} output duration is ${duration}`); + const realRate = parseRate(videoStreams[0].r_frame_rate); + assertCondition(Math.abs(realRate - definition.expectedOutputRealFps) <= definition.outputFpsTolerance, `${definition.name} output real FPS is ${realRate}`); + const videoCadence = assertVideoCadence(runtime, definition, outputFile); + const audioTimeline = probePacketTimeline(runtime, outputFile, 'a:0'); + assertCondition(Math.abs(videoCadence.start - audioTimeline.start) <= 0.08, `${definition.name} A/V start differs by ${Math.abs(videoCadence.start - audioTimeline.start)}`); + assertCondition(Math.abs(videoCadence.end - audioTimeline.end) <= 0.12, `${definition.name} A/V end differs by ${Math.abs(videoCadence.end - audioTimeline.end)}`); + assertCondition(videoStreams[0].width === 320 && videoStreams[0].height === 180, `${definition.name} output dimensions are ${videoStreams[0].width}x${videoStreams[0].height}`); + decodeMedia(runtime, outputFile); + const trimMarkers = observeTrimBoundaryMarkers(runtime, outputFile, markerTimes.outputStartSample, markerTimes.outputEndSample); + assertTrimBoundaryMarkers(trimMarkers, `${definition.name} output`); + assertCondition(sha256(source.filePath) === source.hash, `${definition.name} source changed during export`); + assertNoExportArtifacts(environment.mediaDir, definition.outputName); + return { + outputName: definition.outputName, + duration, + videoCodec: videoStreams[0].codec_name, + audioCodec: audioStreams[0].codec_name, + videoRate: videoStreams[0].avg_frame_rate, + realVideoRate: videoStreams[0].r_frame_rate, + audioChannels: audioStreams[0].channels, + videoCadence, + audioTimeline, + trimMarkers + }; +} + +function sameResolvedPath(left, right) { + if (typeof left !== 'string' || typeof right !== 'string') return false; + const resolvedLeft = path.resolve(left); + const resolvedRight = path.resolve(right); + return process.platform === 'win32' + ? resolvedLeft.toLowerCase() === resolvedRight.toLowerCase() + : resolvedLeft === resolvedRight; +} + +function assertManagedExecutionDiagnostics(options) { + const { + diagnostics, + expectedPaths, + streamlinkDirectory, + ffmpegDirectory, + electronPath, + expectedElectronPath, + previousDiagnostics = null, + requiredTools = [], + label + } = options; + assertCondition(diagnostics !== null && typeof diagnostics === 'object', `${label} managed execution diagnostics are unavailable`); + const tools = [ + { key: 'ffmpeg', name: 'FFmpeg', expectedPath: expectedPaths.ffmpeg, directory: ffmpegDirectory }, + { key: 'ffprobe', name: 'FFprobe', expectedPath: expectedPaths.ffprobe, directory: ffmpegDirectory }, + { key: 'streamlink', name: 'Streamlink', expectedPath: expectedPaths.streamlink, directory: streamlinkDirectory } + ]; + for (const tool of tools) { + const record = diagnostics[tool.key]; + assertCondition(record !== null && typeof record === 'object' && Number.isInteger(record.count) && record.count >= 0, `${label} ${tool.name} execution record is invalid: ${JSON.stringify(record)}`); + if (record.count === 0) { + assertCondition(record.path === null, `${label} ${tool.name} reported a path without an execution: ${record.path}`); + } else { + assertCondition(sameResolvedPath(record.path, tool.expectedPath), `${label} did not execute the provisioned ${tool.name} path: ${record.path}`); + assertPathInside(record.path, tool.directory, `${label} ${tool.name} path`); + } + if (previousDiagnostics) { + const previousRecord = previousDiagnostics[tool.key]; + assertCondition(previousRecord !== null && typeof previousRecord === 'object' && Number.isInteger(previousRecord.count) && previousRecord.count >= 0, `${label} previous ${tool.name} execution record is invalid: ${JSON.stringify(previousRecord)}`); + assertCondition(record.count >= previousRecord.count, `${label} ${tool.name} execution count regressed from ${previousRecord.count} to ${record.count}`); + if (requiredTools.includes(tool.key)) assertCondition(record.count > previousRecord.count, `${label} did not record a new ${tool.name} execution`); + } + } + assertCondition(sameResolvedPath(electronPath, expectedElectronPath), `${label} Electron PATH escaped isolation: ${electronPath}`); +} + +async function readManagedExecutionDiagnostics(win, timeoutMs = cutterMatrixTimeouts.diagnostics) { + return await withOperationTimeout( + () => win.evaluate(() => window.api.getManagedToolExecutionDiagnostics()), + 'managed tool execution diagnostics', + timeoutMs + ); +} + +async function readDebugLog(win, timeoutMs = cutterMatrixTimeouts.diagnostics) { + return await withOperationTimeout( + () => win.evaluate(() => window.api.getDebugLog(1000)), + 'debug log read', + timeoutMs + ); +} + +function assertLockedTargetFailure({ result, debugBefore, debugAfter, outputFile, runtimeIssues }) { + assertCondition(!result?.rejected, `Locked target export must resolve through the product IPC: ${result?.rejected}`); + assertCondition(result?.success === false, `Locked target export unexpectedly succeeded: ${JSON.stringify(result)}`); + assertCondition(result.cancelled !== true, `Locked target export must not be reported as cancelled: ${JSON.stringify(result)}`); + assertCondition(runtimeIssues.length === 0, `Locked target export produced runtime issues: ${runtimeIssues.join(' | ')}`); + const delta = debugAfter.startsWith(debugBefore) ? debugAfter.slice(debugBefore.length) : debugAfter; + const hasMatchingFailure = delta.split(/\r?\n/).some((line) => { + if (!/video-editor-export-failed/.test(line) + || !/(?:EPERM|EBUSY|EACCES|operation not permitted|being used by another process|cannot access)/i.test(line)) return false; + const paths = extractRenamePaths(line); + if (!paths) return false; + const outputDirectory = path.dirname(outputFile); + const stagingPublish = sameResolvedPath(paths.destination, outputFile) + && sameResolvedPath(path.dirname(paths.source), outputDirectory) + && path.basename(paths.source).toLowerCase().includes('.tvm-edit'); + const backupPublish = sameResolvedPath(paths.source, outputFile) + && sameResolvedPath(path.dirname(paths.destination), outputDirectory) + && path.basename(paths.destination).startsWith(`${path.basename(outputFile)}.`) + && path.basename(paths.destination).toLowerCase().endsWith('.tvm-backup'); + return stagingPublish || backupPublish; + }); + assertCondition(hasMatchingFailure, `Locked target export did not emit an atomic publish lock diagnostic: ${delta}`); +} + +function extractRenamePaths(line) { + const renameIndex = line.toLowerCase().lastIndexOf('rename '); + if (renameIndex < 0) return null; + const remainder = line.slice(renameIndex + 7).trim(); + const sourceQuote = remainder[0]; + if (sourceQuote !== "'" && sourceQuote !== '"') return null; + const sourceEnd = remainder.indexOf(sourceQuote, 1); + if (sourceEnd < 1) return null; + const afterSource = remainder.slice(sourceEnd + 1).trim(); + if (!afterSource.startsWith('->')) return null; + const destinationPart = afterSource.slice(2).trim(); + const destinationQuote = destinationPart[0]; + if (destinationQuote !== "'" && destinationQuote !== '"') return null; + const destinationEnd = destinationPart.indexOf(destinationQuote, 1); + if (destinationEnd < 1 || destinationPart.slice(destinationEnd + 1).trim()) return null; + return { + source: remainder.slice(1, sourceEnd), + destination: destinationPart.slice(1, destinationEnd) + }; +} + +async function stopLockProcess(lockProcess, timeoutMs = 5000) { + const processExit = new Promise((resolve) => lockProcess.once('exit', resolve)); + lockProcess.kill(); + await withOperationTimeout(() => processExit, 'locked target helper exit', timeoutMs); +} + +async function runLockedTargetCase(win, environment, powerShellExecutable, definition, source, runtimeIssues) { + const outputFile = path.join(environment.mediaDir, definition.outputName); + const sentinel = Buffer.from(`locked-target-${nodeCrypto.randomUUID()}`); + fs.writeFileSync(outputFile, sentinel); + let lockProcess = null; + const originalDirectoryMode = fs.statSync(environment.mediaDir).mode & 0o777; + try { + if (process.platform === 'win32') { + const lockScript = "$stream=[IO.File]::Open($env:TWITCH_VOD_MANAGER_LOCK_TARGET,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None);[Console]::Out.WriteLine('ready');[Console]::Out.Flush();while($true){Start-Sleep -Milliseconds 250}"; + lockProcess = spawn(powerShellExecutable, ['-NoProfile', '-Command', lockScript], { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, TWITCH_VOD_MANAGER_LOCK_TARGET: outputFile } + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Locked target helper did not start')), 5000); + lockProcess.stdout.once('data', () => { + clearTimeout(timeout); + resolve(); + }); + lockProcess.once('exit', (code) => reject(new Error(`Locked target helper exited with ${code}`))); + }); + } else { + fs.chmodSync(environment.mediaDir, 0o500); + } + const debugBefore = await readDebugLog(win); + const result = await exportSource(win, definition, source); + const debugAfter = await readDebugLog(win); + assertLockedTargetFailure({ result, debugBefore, debugAfter, outputFile, runtimeIssues }); + if (lockProcess) { + await stopLockProcess(lockProcess); + lockProcess = null; + } + assertCondition(fs.readFileSync(outputFile).equals(sentinel), 'Locked target contents changed after failed export'); + assertNoExportArtifacts(environment.mediaDir, definition.outputName); + return result; + } finally { + if (lockProcess) await stopLockProcess(lockProcess).catch(() => {}); + if (process.platform !== 'win32' && fs.existsSync(environment.mediaDir)) fs.chmodSync(environment.mediaDir, originalDirectoryMode); + } +} + +async function runCorruptSourceCase(win, environment) { + const corruptFile = path.join(environment.mediaDir, 'matrix-corrupt.mkv'); + fs.writeFileSync(corruptFile, nodeCrypto.randomBytes(2048)); + const corruptHash = sha256(corruptFile); + const outputName = 'matrix-corrupt-export.mp4'; + const target = path.join(environment.mediaDir, outputName); + const sentinel = Buffer.from(`corrupt-target-${nodeCrypto.randomUUID()}`); + fs.writeFileSync(target, sentinel); + const prepared = await prepareSource(win, corruptFile); + assertCondition(prepared.media === null, `Corrupt source unexpectedly prepared: ${JSON.stringify(prepared.media)}`); + const result = await exportSource(win, { outputName, profile: 'balanced', audioStreamIndex: 0 }, prepared); + assertCondition(!result.success, `Corrupt source export unexpectedly succeeded: ${JSON.stringify(result)}`); + assertCondition(sha256(corruptFile) === corruptHash, 'Corrupt source changed during failed export'); + assertCondition(fs.readFileSync(target).equals(sentinel), 'Existing corrupt-source target changed after failed export'); + assertNoExportArtifacts(environment.mediaDir, outputName); + return result; +} + +async function terminateElectronProcess(app, timeoutMs) { + const child = app.process?.(); + if (!child || child.exitCode !== null) return; + await new Promise((resolve, reject) => { + let timeout; + const cleanup = () => { + if (timeout) clearTimeout(timeout); + child.removeListener('exit', onExit); + child.removeListener('error', onError); + }; + const settle = (error) => { + cleanup(); + if (error) reject(error); + else resolve(); + }; + const onExit = () => settle(); + const onError = (error) => settle(error); + child.once('exit', onExit); + child.once('error', onError); + timeout = setTimeout(() => settle(new Error(`Electron process exit timed out after ${timeoutMs}ms`)), timeoutMs); + try { + child.kill(); + if (child.exitCode !== null) settle(); + } catch (error) { + settle(error); + } + }); +} + +async function closeElectronApp(app, closeTimeoutMs = cutterMatrixTimeouts.appClose, processExitTimeoutMs = cutterMatrixTimeouts.appProcessExit) { + try { + return await withOperationTimeout(() => app.close(), 'app.close', closeTimeoutMs); + } catch (error) { + await terminateElectronProcess(app, processExitTimeoutMs); + throw error; + } +} + +async function runCutterMatrixLifecycle(options) { + const environment = options.createEnvironment(); + let app = null; + let restoreEnvironment = () => {}; + const closeApp = async () => { + const activeApp = app; + app = null; + if (activeApp) await options.closeApp(activeApp); + }; + try { + return await options.execute({ + environment, + closeApp, + setApp: (value) => { app = value; }, + setRestoreEnvironment: (value) => { restoreEnvironment = value; } + }); + } finally { + try { + await closeApp(); + } finally { + try { + restoreEnvironment(); + } finally { + options.cleanupEnvironment(environment); + } + } + } +} + +async function executeCutterMatrix({ environment, closeApp, setApp, setRestoreEnvironment }) { + assertCondition(process.platform === 'win32', 'Cutter media matrix requires the Windows product toolchain'); + let app; + const provisionedTools = await provisionManagedCutterTools(environment); + const runtime = createManagedMediaRuntime(provisionedTools.paths); + const powerShellExecutable = resolvePowerShellExecutable(); + setRestoreEnvironment(activateOfflineRunnerEnvironment(environment)); + const definitions = createMatrixDefinitions(); + const caseArgument = process.argv.find((argument) => argument.startsWith('--case=')); + const requestedCases = caseArgument ? new Set(caseArgument.slice('--case='.length).split(',').filter(Boolean)) : null; + const knownCases = new Set([...definitions.map((definition) => definition.id), 'errors']); + if (requestedCases) { + const unknownCases = [...requestedCases].filter((id) => !knownCases.has(id)); + assertCondition(unknownCases.length === 0, `Unknown matrix cases: ${unknownCases.join(', ')}`); + } + const selectedDefinitions = requestedCases ? definitions.filter((definition) => requestedCases.has(definition.id)) : definitions; + const includeErrors = !requestedCases || requestedCases.has('errors'); + const fixtureDefinitions = [...selectedDefinitions]; + if (includeErrors && !fixtureDefinitions.some((definition) => definition.id === definitions[0].id)) fixtureDefinitions.push(definitions[0]); + assertCondition(selectedDefinitions.length > 0 || includeErrors, 'No cutter matrix cases selected'); + const fixtures = fixtureDefinitions.map((definition) => ({ definition, source: createFixture(environment, runtime, definition) })); + if (process.argv.includes('--fixtures-only')) { + console.log(JSON.stringify({ + fixtures: fixtures.map((fixture) => ({ + case: fixture.definition.name, + fileName: path.basename(fixture.source.filePath), + duration: mediaDuration(fixture.source.probe), + videoRate: fixture.source.probe.streams.find((stream) => stream.codec_type === 'video')?.avg_frame_rate, + realVideoRate: fixture.source.probe.streams.find((stream) => stream.codec_type === 'video')?.r_frame_rate, + audioChannels: fixture.source.probe.streams.filter((stream) => stream.codec_type === 'audio').map((stream) => stream.channels) + })) + }, null, 2)); + return; + } + const launchOptions = getElectronLaunchOptions(environment); + launchOptions.env = { + ...launchOptions.env, + TWITCH_VOD_MANAGER_E2E_CUTTER_OUTPUT_ROOT: environment.mediaDir + }; + app = await electron.launch(launchOptions); + setApp(app); + const appProcessId = app.process().pid; + const win = await app.firstWindow(); + const runtimeIssues = []; + win.on('pageerror', (error) => runtimeIssues.push(`pageerror: ${String(error)}`)); + win.on('console', (message) => { + if (message.type() === 'error') runtimeIssues.push(`console.error: ${message.text()}`); + }); + await verifyE2eIsolation(app, win, environment); + const productToolStatuses = await win.evaluate(() => window.api.getManagedToolStatus()); + assertVerifiedManagedStatus(productToolStatuses.streamlink, provisionedTools.manifest.streamlink.version, 'Electron Streamlink'); + assertVerifiedManagedStatus(productToolStatuses.ffmpeg, provisionedTools.manifest.ffmpeg.version, 'Electron FFmpeg'); + const expectedElectronPath = path.join(environment.rootDir, 'offline-path'); + const electronPath = getEnvironmentValue(launchOptions.env, 'PATH'); + const streamlinkDirectory = path.join(environment.appDataDir, 'tools', 'streamlink'); + const ffmpegDirectory = path.join(environment.appDataDir, 'tools', 'ffmpeg'); + const verifyManagedExecution = async (label, previousDiagnostics = null, requiredTools = []) => { + const diagnostics = await readManagedExecutionDiagnostics(win); + assertManagedExecutionDiagnostics({ + diagnostics, + expectedPaths: provisionedTools.paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath, + expectedElectronPath, + previousDiagnostics, + requiredTools, + label + }); + return diagnostics; + }; + let executionDiagnostics = await verifyManagedExecution('Cutter startup'); + const matrix = []; + for (const entry of fixtures.filter((fixture) => selectedDefinitions.includes(fixture.definition))) { + const prepared = await prepareSource(win, entry.source.filePath); + assertCondition(prepared.media !== null, `${entry.definition.name} source was rejected during production media preparation`); + assertCondition(prepared.media.info.videoCodec === entry.definition.sourceVideoCodec, `${entry.definition.name} production probe reported ${prepared.media.info.videoCodec}`); + assertCondition(prepared.media.info.audioStreams.length === entry.definition.audioFrequencies.length, `${entry.definition.name} production probe found ${prepared.media.info.audioStreams.length} audio streams`); + if (entry.definition.variableFrameRate) assertCondition(prepared.media.info.variableFrameRate, `${entry.definition.name} production probe did not report VFR`); + const prepareDiagnostics = await verifyManagedExecution(`${entry.definition.name} after prepare`, executionDiagnostics, ['ffprobe']); + executionDiagnostics = prepareDiagnostics; + const result = await exportSource(win, entry.definition, prepared); + assertCondition(result.success, `${entry.definition.name} production export failed: ${JSON.stringify(result)}`); + const exportDiagnostics = await verifyManagedExecution(`${entry.definition.name} after export`, executionDiagnostics, ['ffmpeg', 'ffprobe']); + executionDiagnostics = exportDiagnostics; + matrix.push({ + case: entry.definition.name, + prepared: { + duration: prepared.media.info.duration, + fps: prepared.media.info.fps, + variableFrameRate: prepared.media.info.variableFrameRate, + audioStreams: prepared.media.info.audioStreams + }, + result, + output: analyzeExport(environment, runtime, entry.definition, entry.source), + executionDiagnostics: { prepare: prepareDiagnostics, export: exportDiagnostics } + }); + } + let lockedTarget = null; + let corruptSource = null; + if (includeErrors) { + const lockedDefinition = { ...definitions[0], outputName: 'matrix-locked-existing.mp4' }; + const lockedSource = fixtures.find((fixture) => fixture.definition.id === definitions[0].id).source; + const lockedPrepared = await prepareSource(win, lockedSource.filePath); + assertCondition(lockedPrepared.media !== null, 'Locked target source could not be prepared'); + executionDiagnostics = await verifyManagedExecution('Locked target after prepare', executionDiagnostics, ['ffprobe']); + lockedTarget = await runLockedTargetCase(win, environment, powerShellExecutable, lockedDefinition, { ...lockedSource, capability: lockedPrepared.capability }, runtimeIssues); + executionDiagnostics = await verifyManagedExecution('Locked target after export', executionDiagnostics, ['ffmpeg', 'ffprobe']); + corruptSource = await runCorruptSourceCase(win, environment); + await verifyManagedExecution('Corrupt source after export', executionDiagnostics, ['ffprobe']); + } + assertCondition(runtimeIssues.length === 0, `Cutter matrix runtime issues occurred: ${runtimeIssues.join(' | ')}`); + await closeApp(); + const cutterTempPrefixes = ['media', 'waveform', 'preview'].map((kind) => `tvm-editor-${kind}-${appProcessId}-`); + const cutterTempDirectoriesAfterShutdown = fs.readdirSync(os.tmpdir()).filter((name) => cutterTempPrefixes.some((prefix) => name.startsWith(prefix))); + assertCondition(cutterTempDirectoriesAfterShutdown.length === 0, `Cutter matrix left temporary directories after shutdown: ${JSON.stringify(cutterTempDirectoriesAfterShutdown)}`); + console.log(JSON.stringify({ + toolchain: { + statuses: provisionedTools.statuses, + versions: provisionedTools.versions, + paths: provisionedTools.paths + }, + matrix, + lockedTarget, + corruptSource, + runtimeIssues, + cutterTempDirectoriesAfterShutdown + }, null, 2)); +} + +async function run() { + return await runCutterMatrixLifecycle({ + createEnvironment: () => createE2eEnvironment('cutter-media-matrix', { language: 'en', theme: 'twitch' }), + cleanupEnvironment: cleanupE2eEnvironment, + closeApp: closeElectronApp, + execute: executeCutterMatrix + }); +} + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { + activateOfflineRunnerEnvironment, + assertLockedTargetFailure, + assertManagedExecutionDiagnostics, + assertPinnedVersion, + assertTrimBoundaryMarkers, + closeElectronApp, + createManagedMediaRuntime, + estimatePcmFrequency, + exportSource, + prepareSource, + provisionManagedCutterTools, + runCutterMatrixLifecycle, + sampleVideoRgb +}; diff --git a/scripts/smoke-test-cutter-media-matrix.test.js b/scripts/smoke-test-cutter-media-matrix.test.js new file mode 100644 index 0000000..beafcbf --- /dev/null +++ b/scripts/smoke-test-cutter-media-matrix.test.js @@ -0,0 +1,588 @@ +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + activateOfflineRunnerEnvironment, + assertLockedTargetFailure, + assertManagedExecutionDiagnostics, + assertPinnedVersion, + assertTrimBoundaryMarkers, + closeElectronApp, + createManagedMediaRuntime, + estimatePcmFrequency, + exportSource, + prepareSource, + provisionManagedCutterTools, + runCutterMatrixLifecycle, + sampleVideoRgb +} = require('./smoke-test-cutter-media-matrix'); + +function createEnvironment() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-cutter-toolchain-contract-')); + const appDataDir = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager'); + fs.mkdirSync(appDataDir, { recursive: true }); + return { rootDir, appDataDir }; +} + +function createProvisioningFixture() { + const manifest = { + streamlink: { id: 'streamlink', version: '8.4.0' }, + ffmpeg: { id: 'ffmpeg', version: '8.1.2' } + }; + let initializedDirectories = null; + const tools = { + initToolDirs(streamlinkDirectory, ffmpegDirectory, getTemporaryDirectory) { + initializedDirectories = { + streamlinkDirectory, + ffmpegDirectory, + temporaryDirectory: getTemporaryDirectory() + }; + }, + async repairManagedTools() { + const streamlinkPath = path.join(initializedDirectories.streamlinkDirectory, 'bin', 'streamlink.exe'); + const ffmpegPath = path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffmpeg.exe'); + const ffprobePath = path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffprobe.exe'); + for (const filePath of [streamlinkPath, ffmpegPath, ffprobePath]) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, path.basename(filePath)); + } + return { + success: true, + statuses: { + streamlink: { state: 'verified', verified: true, version: manifest.streamlink.version }, + ffmpeg: { state: 'verified', verified: true, version: manifest.ffmpeg.version } + } + }; + }, + getStreamlinkPath() { + return path.join(initializedDirectories.streamlinkDirectory, 'bin', 'streamlink.exe'); + }, + getFFmpegPath() { + return path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffmpeg.exe'); + }, + getFFprobePath() { + return path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffprobe.exe'); + } + }; + return { + manifest, + tools, + getInitializedDirectories: () => initializedDirectories + }; +} + +test('provisions and verifies the pinned product toolchain inside the isolated AppData tree', async (t) => { + const environment = createEnvironment(); + t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true })); + const fixture = createProvisioningFixture(environment); + + const result = await provisionManagedCutterTools(environment, { + loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }), + runVersionCheck: (executablePath, _args, label) => { + if (label.includes('Streamlink')) return 'Streamlink 8.4.0'; + if (path.basename(executablePath).toLowerCase() === 'ffprobe.exe') return 'ffprobe version 8.1.2'; + return 'ffmpeg version 8.1.2'; + } + }); + + const initialized = fixture.getInitializedDirectories(); + assert.deepEqual(initialized, { + streamlinkDirectory: path.join(environment.appDataDir, 'tools', 'streamlink'), + ffmpegDirectory: path.join(environment.appDataDir, 'tools', 'ffmpeg'), + temporaryDirectory: path.join(environment.rootDir, 'managed-tools-temp') + }); + assert.equal(result.statuses.streamlink.verified, true); + assert.equal(result.statuses.ffmpeg.verified, true); + assert.equal(result.versions.streamlink, 'Streamlink 8.4.0'); + assert.equal(result.versions.ffmpeg, 'ffmpeg version 8.1.2'); + assert.equal(result.versions.ffprobe, 'ffprobe version 8.1.2'); + assert.equal(result.paths.ffmpeg, fs.realpathSync.native(path.join(initialized.ffmpegDirectory, 'bin', 'ffmpeg.exe'))); + assert.equal(result.paths.ffprobe, fs.realpathSync.native(path.join(initialized.ffmpegDirectory, 'bin', 'ffprobe.exe'))); + assert.equal(result.paths.streamlink, fs.realpathSync.native(path.join(initialized.streamlinkDirectory, 'bin', 'streamlink.exe'))); +}); + +test('rejects a product tool path that escapes the owned installation directory', async (t) => { + const environment = createEnvironment(); + t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true })); + const fixture = createProvisioningFixture(environment); + const outsidePath = path.join(environment.rootDir, 'outside-ffmpeg.exe'); + fs.writeFileSync(outsidePath, 'outside'); + fixture.tools.getFFmpegPath = () => outsidePath; + + await assert.rejects(() => provisionManagedCutterTools(environment, { + loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }), + runVersionCheck: () => '8.1.2' + }), /outside the owned managed-tool directory/); +}); + +test('rejects a managed tool that is not verified at the manifest version', async (t) => { + const environment = createEnvironment(); + t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true })); + const fixture = createProvisioningFixture(); + const repairManagedTools = fixture.tools.repairManagedTools; + fixture.tools.repairManagedTools = async () => { + const result = await repairManagedTools(); + result.statuses.ffmpeg.verified = false; + result.statuses.ffmpeg.state = 'corrupt'; + return result; + }; + + await assert.rejects(() => provisionManagedCutterTools(environment, { + loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }), + runVersionCheck: () => '8.1.2' + }), /FFmpeg is not verified at pinned 8\.1\.2/); +}); + +test('rejects executable version output that differs from the pinned manifest', async (t) => { + const environment = createEnvironment(); + t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true })); + const fixture = createProvisioningFixture(); + + await assert.rejects(() => provisionManagedCutterTools(environment, { + loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }), + runVersionCheck: (_executablePath, _args, label) => label === 'FFmpeg' ? 'ffmpeg version 7.0.0' : label === 'FFprobe' ? 'ffprobe version 8.1.2' : 'Streamlink 8.4.0' + }), /FFmpeg version output does not match pinned 8\.1\.2/); +}); + +test('media runtime always executes the verified absolute product paths', () => { + const calls = []; + const runtime = createManagedMediaRuntime({ + ffmpeg: 'C:\\owned\\tools\\ffmpeg.exe', + ffprobe: 'C:\\owned\\tools\\ffprobe.exe' + }, (binary, args) => { + calls.push({ binary, args }); + return { status: 0, stdout: `${path.win32.basename(binary)}:${args.join(',')}`, stderr: '' }; + }); + + assert.equal(runtime.ffmpeg(['-i', 'fixture.mkv']), 'ffmpeg.exe:-i,fixture.mkv'); + assert.equal(runtime.ffprobe(['-show_streams', 'fixture.mkv']), 'ffprobe.exe:-show_streams,fixture.mkv'); + assert.deepEqual(calls, [ + { binary: 'C:\\owned\\tools\\ffmpeg.exe', args: ['-i', 'fixture.mkv'] }, + { binary: 'C:\\owned\\tools\\ffprobe.exe', args: ['-show_streams', 'fixture.mkv'] } + ]); +}); + +test('offline runner environment removes PATH and network fallback until restored', (t) => { + const environment = createEnvironment(); + t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true })); + const variables = { + PATH: 'C:\\system-tools', + HTTP_PROXY: 'http://proxy.example.test:8080', + HTTPS_PROXY: 'http://proxy.example.test:8080', + ALL_PROXY: 'http://proxy.example.test:8080', + NO_PROXY: 'localhost', + LOCALAPPDATA: 'C:\\Users\\regular\\AppData\\Local', + APPDATA: 'C:\\Users\\regular\\AppData\\Roaming', + TEMP: 'C:\\Windows\\Temp', + TMP: 'C:\\Windows\\Temp' + }; + + const restore = activateOfflineRunnerEnvironment(environment, variables); + assert.equal(variables.PATH, path.join(environment.rootDir, 'offline-path')); + assert.equal(variables.HTTP_PROXY, 'http://127.0.0.1:1'); + assert.equal(variables.HTTPS_PROXY, 'http://127.0.0.1:1'); + assert.equal(variables.ALL_PROXY, 'http://127.0.0.1:1'); + assert.equal(variables.NO_PROXY, ''); + assert.equal(variables.LOCALAPPDATA, path.join(environment.rootDir, 'localappdata')); + assert.equal(variables.APPDATA, path.join(environment.rootDir, 'roamingappdata')); + assert.equal(variables.TEMP, path.join(environment.rootDir, 'runtime-temp')); + assert.equal(variables.TMP, path.join(environment.rootDir, 'runtime-temp')); + assert.equal(fs.statSync(variables.PATH).isDirectory(), true); + + restore(); + assert.deepEqual(variables, { + PATH: 'C:\\system-tools', + HTTP_PROXY: 'http://proxy.example.test:8080', + HTTPS_PROXY: 'http://proxy.example.test:8080', + ALL_PROXY: 'http://proxy.example.test:8080', + NO_PROXY: 'localhost', + LOCALAPPDATA: 'C:\\Users\\regular\\AppData\\Local', + APPDATA: 'C:\\Users\\regular\\AppData\\Roaming', + TEMP: 'C:\\Windows\\Temp', + TMP: 'C:\\Windows\\Temp' + }); +}); + +test('requires the exact pinned executable version instead of a substring match', () => { + assert.doesNotThrow(() => assertPinnedVersion('ffmpeg version 8.1.2 Copyright FFmpeg developers', '8.1.2', 'FFmpeg')); + assert.doesNotThrow(() => assertPinnedVersion('ffmpeg version 8.1.2-essentials_build-www.gyan.dev Copyright FFmpeg developers', '8.1.2', 'FFmpeg')); + assert.doesNotThrow(() => assertPinnedVersion('ffprobe version 8.1.2 Copyright FFmpeg developers', '8.1.2', 'FFprobe')); + assert.doesNotThrow(() => assertPinnedVersion('ffprobe version 8.1.2-essentials_build-www.gyan.dev Copyright FFmpeg developers', '8.1.2', 'FFprobe')); + assert.doesNotThrow(() => assertPinnedVersion('Streamlink 8.4.0', '8.4.0', 'Streamlink')); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.20-essentials_build-www.gyan.dev', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/); + assert.throws(() => assertPinnedVersion('ffmpeg version 18.1.2-essentials_build-www.gyan.dev', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2+evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2---', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-evil!', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version v8.1.2', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffmpeg version 08.1.2', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/); + assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2.0', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/); + assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2evil', '8.1.2', 'FFprobe'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2-evil', '8.1.2', 'FFprobe'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffprobe version v8.1.2', '8.1.2', 'FFprobe'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('ffprobe version 08.1.2', '8.1.2', 'FFprobe'), /does not match pinned 8\.1\.2/); + assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2.0', '8.1.2', 'FFprobe'), /does not match pinned 8\.1\.2/); + assert.throws(() => assertPinnedVersion('Streamlink 8.4.0evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('Streamlink 8.4.0-evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('Streamlink 8.4.0+evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('Streamlink 8.4.0-essentials_build-www.gyan.dev', '8.4.0', 'Streamlink'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('Streamlink 8.4.0+', '8.4.0', 'Streamlink'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('Streamlink v8.4.0', '8.4.0', 'Streamlink'), /does not expose a parseable version/); + assert.throws(() => assertPinnedVersion('Streamlink 08.4.0', '8.4.0', 'Streamlink'), /does not match pinned 8\.4\.0/); + assert.throws(() => assertPinnedVersion('Streamlink 8.4.0.1', '8.4.0', 'Streamlink'), /does not match pinned 8\.4\.0/); + assert.throws(() => assertPinnedVersion('custom wrapper contains 8.1.2', '8.1.2', 'FFmpeg'), /does not expose a parseable version/); +}); + +test('prepare source has its own bounded operation timeout', async () => { + const win = { evaluate: () => new Promise(() => {}) }; + await assert.rejects(() => prepareSource(win, 'C:\\media\\source.mp4', { + timeoutMs: 15, + createCapability: async () => ({ token: 'a'.repeat(32), name: 'source.mp4' }) + }), /prepareSource timed out after 15ms/); +}); + +test('export source has its own bounded operation timeout', async () => { + const win = { evaluate: () => new Promise(() => {}) }; + await assert.rejects(() => exportSource(win, { + outputName: 'result.mp4', + profile: 'balanced', + audioStreamIndex: 0 + }, { + capability: { token: 'b'.repeat(32), name: 'source.mp4' } + }, 15), /exportSource timed out after 15ms/); +}); + +test('Electron app close has its own bounded operation timeout', async () => { + const app = { close: () => new Promise(() => {}) }; + await assert.rejects(() => closeElectronApp(app, 15), /app.close timed out after 15ms/); +}); + +test('runner exposes a bounded success shutdown that cannot hang and is not retried', async () => { + const environment = createEnvironment(); + const child = new EventEmitter(); + child.exitCode = null; + const events = []; + let closeCalls = 0; + let receivedClose = false; + child.kill = () => { + events.push('kill'); + setTimeout(() => { + child.exitCode = 1; + events.push('exit'); + child.emit('exit', 1, null); + }, 5); + return true; + }; + await assert.rejects(() => runCutterMatrixLifecycle({ + createEnvironment: () => environment, + cleanupEnvironment: (value) => { + events.push('cleanup'); + fs.rmSync(value.rootDir, { recursive: true, force: true }); + }, + closeApp: async (app) => { + closeCalls += 1; + await closeElectronApp(app, 15, 30); + }, + execute: async ({ setApp, closeApp }) => { + setApp({ + close: () => { + events.push('close'); + return new Promise(() => {}); + }, + process: () => child + }); + receivedClose = typeof closeApp === 'function'; + await closeApp(); + } + }), /app\.close timed out after 15ms/); + assert.equal(receivedClose, true); + assert.equal(closeCalls, 1); + assert.deepEqual(events, ['close', 'kill', 'exit', 'cleanup']); + assert.equal(fs.existsSync(environment.rootDir), false); +}); + +test('process exit fallback after app close timeout has its own bound', async () => { + const child = new EventEmitter(); + child.exitCode = null; + child.kill = () => true; + const app = { + close: () => new Promise(() => {}), + process: () => child + }; + await assert.rejects(() => closeElectronApp(app, 10, 15), /Electron process exit timed out after 15ms/); + assert.equal(child.listenerCount('exit'), 0); +}); + +test('runner releases a successfully closed app before lifecycle cleanup', async () => { + const environment = createEnvironment(); + let closeCalls = 0; + let closedBeforeExecuteReturned = false; + await runCutterMatrixLifecycle({ + createEnvironment: () => environment, + cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }), + closeApp: async () => { closeCalls += 1; }, + execute: async ({ setApp, closeApp }) => { + setApp({}); + await closeApp(); + closedBeforeExecuteReturned = true; + } + }); + assert.equal(closedBeforeExecuteReturned, true); + assert.equal(closeCalls, 1); + assert.equal(fs.existsSync(environment.rootDir), false); +}); + +test('runner lifecycle restores the process environment and removes its tree after failure', async () => { + const environment = createEnvironment(); + const variables = { PATH: 'C:\\original-tools' }; + const failure = new Error('matrix failed'); + await assert.rejects(() => runCutterMatrixLifecycle({ + createEnvironment: () => environment, + cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }), + closeApp: async () => {}, + execute: async ({ setApp, setRestoreEnvironment }) => { + setApp({}); + setRestoreEnvironment(activateOfflineRunnerEnvironment(environment, variables)); + assert.equal(variables.PATH, path.join(environment.rootDir, 'offline-path')); + throw failure; + } + }), (error) => error === failure); + assert.deepEqual(variables, { PATH: 'C:\\original-tools' }); + assert.equal(fs.existsSync(environment.rootDir), false); +}); + +test('runner lifecycle still restores and cleans up when bounded app close fails', async () => { + const environment = createEnvironment(); + const variables = { PATH: 'C:\\original-tools' }; + await assert.rejects(() => runCutterMatrixLifecycle({ + createEnvironment: () => environment, + cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }), + closeApp: async () => { throw new Error('app.close timed out after 15ms'); }, + execute: async ({ setApp, setRestoreEnvironment }) => { + setApp({}); + setRestoreEnvironment(activateOfflineRunnerEnvironment(environment, variables)); + } + }), /app\.close timed out after 15ms/); + assert.deepEqual(variables, { PATH: 'C:\\original-tools' }); + assert.equal(fs.existsSync(environment.rootDir), false); +}); + +test('locked target requires a resolved production publish failure with a Windows lock diagnostic', () => { + const before = '[2026-08-13T00:00:00.000Z] startup'; + const outputFile = 'C:\\media\\result.mp4'; + const after = `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> '${outputFile}'`; + assert.doesNotThrow(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: after, + outputFile, + runtimeIssues: [] + })); + assert.doesNotThrow(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EBUSY: resource busy or locked, rename '${outputFile}' -> '${outputFile}.42.123.tvm-backup'`, + outputFile, + runtimeIssues: [] + })); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, rejected: 'Error: IPC connection closed' }, + debugBefore: before, + debugAfter: after, + outputFile, + runtimeIssues: [] + }), /must resolve through the product IPC/); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] unrelated-failure`, + outputFile, + runtimeIssues: [] + }), /atomic publish lock diagnostic/); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, cancelled: true, outputName: null }, + debugBefore: before, + debugAfter: after, + outputFile, + runtimeIssues: [] + }), /must not be reported as cancelled/); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: after, + outputFile, + runtimeIssues: ['pageerror: renderer crashed'] + }), /runtime issues/); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed\n[2026-08-13T00:00:02.000Z] unrelated | Error: EPERM: operation not permitted, rename 'C:\\media\\.other.tvm-edit.mp4' -> 'C:\\media\\other.mp4'`, + outputFile, + runtimeIssues: [] + }), /atomic publish lock diagnostic/); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> '${outputFile}.backup'`, + outputFile, + runtimeIssues: [] + }), /atomic publish lock diagnostic/); + assert.throws(() => assertLockedTargetFailure({ + result: { success: false, outputName: null }, + debugBefore: before, + debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> 'C:\\media\\prefix-result.mp4'`, + outputFile, + runtimeIssues: [] + }), /atomic publish lock diagnostic/); +}); + +test('managed execution snapshots prove exact owned paths and operation-specific counter deltas', () => { + const rootDir = path.join('C:\\runner', 'matrix'); + const streamlinkDirectory = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager', 'tools', 'streamlink'); + const ffmpegDirectory = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager', 'tools', 'ffmpeg'); + const paths = { + streamlink: path.join(streamlinkDirectory, 'bin', 'streamlink.exe'), + ffmpeg: path.join(ffmpegDirectory, 'bin', 'ffmpeg.exe'), + ffprobe: path.join(ffmpegDirectory, 'bin', 'ffprobe.exe') + }; + const baseline = { + ffmpeg: { path: null, count: 0 }, + ffprobe: { path: null, count: 0 }, + streamlink: { path: null, count: 0 } + }; + const afterPrepare = { + ffmpeg: { path: null, count: 0 }, + ffprobe: { path: paths.ffprobe, count: 1 }, + streamlink: { path: null, count: 0 } + }; + const afterExport = { + ffmpeg: { path: paths.ffmpeg, count: 1 }, + ffprobe: { path: paths.ffprobe, count: 2 }, + streamlink: { path: null, count: 0 } + }; + assert.doesNotThrow(() => assertManagedExecutionDiagnostics({ + diagnostics: afterPrepare, + expectedPaths: paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath: path.join(rootDir, 'offline-path'), + expectedElectronPath: path.join(rootDir, 'offline-path'), + previousDiagnostics: baseline, + requiredTools: ['ffprobe'], + label: 'after prepare' + })); + assert.doesNotThrow(() => assertManagedExecutionDiagnostics({ + diagnostics: afterExport, + expectedPaths: paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath: path.join(rootDir, 'offline-path'), + expectedElectronPath: path.join(rootDir, 'offline-path'), + previousDiagnostics: afterPrepare, + requiredTools: ['ffmpeg', 'ffprobe'], + label: 'after export' + })); + assert.throws(() => assertManagedExecutionDiagnostics({ + diagnostics: { + ffmpeg: { path: 'C:\\system\\ffmpeg.exe', count: 1 }, + ffprobe: { path: paths.ffprobe, count: 2 }, + streamlink: { path: null, count: 0 } + }, + expectedPaths: paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath: path.join(rootDir, 'offline-path'), + expectedElectronPath: path.join(rootDir, 'offline-path'), + label: 'after export' + }), /did not execute the provisioned FFmpeg path/); + assert.throws(() => assertManagedExecutionDiagnostics({ + diagnostics: afterPrepare, + expectedPaths: paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath: 'C:\\Windows\\System32', + expectedElectronPath: path.join(rootDir, 'offline-path'), + label: 'after prepare' + }), /Electron PATH escaped isolation/); + assert.throws(() => assertManagedExecutionDiagnostics({ + diagnostics: afterPrepare, + expectedPaths: paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath: path.join(rootDir, 'offline-path'), + expectedElectronPath: path.join(rootDir, 'offline-path'), + previousDiagnostics: afterPrepare, + requiredTools: ['ffprobe'], + label: 'after prepare' + }), /did not record a new FFprobe execution/); + assert.throws(() => assertManagedExecutionDiagnostics({ + diagnostics: baseline, + expectedPaths: paths, + streamlinkDirectory, + ffmpegDirectory, + electronPath: path.join(rootDir, 'offline-path'), + expectedElectronPath: path.join(rootDir, 'offline-path'), + previousDiagnostics: afterExport, + requiredTools: [], + label: 'regressed snapshot' + }), /FFmpeg execution count regressed/); +}); + +function createPcmTone(frequency, durationSeconds = 0.1, sampleRate = 48000) { + const samples = Math.round(durationSeconds * sampleRate); + const buffer = Buffer.alloc(samples * 2); + for (let index = 0; index < samples; index += 1) { + buffer.writeInt16LE(Math.round(Math.sin(2 * Math.PI * frequency * index / sampleRate) * 24000), index * 2); + } + return buffer; +} + +test('decoded PCM frequency estimation distinguishes the trim boundary audio markers', () => { + assert.ok(Math.abs(estimatePcmFrequency(createPcmTone(440), 48000) - 440) <= 5); + assert.ok(Math.abs(estimatePcmFrequency(createPcmTone(1760), 48000) - 1760) <= 10); +}); + +test('video marker sampling decodes before seeking for timestamp-offset containers', () => { + let args = null; + const rgb = sampleVideoRgb({ + ffmpegBuffer(nextArgs) { + args = nextArgs; + return Buffer.from([12, 34, 56]); + } + }, 'fixture.ts', 0.3); + assert.deepEqual(rgb, [12, 34, 56]); + assert.ok(args.indexOf('-i') < args.indexOf('-ss')); +}); + +test('trim boundary validation rejects a same-duration export from the wrong source interval', () => { + const correctMarkers = { + startVideoRgb: [18, 150, 22], + endVideoRgb: [20, 170, 175], + startAudioFrequency: 440, + endAudioFrequency: 1760 + }; + assert.doesNotThrow(() => assertTrimBoundaryMarkers(correctMarkers, 'fixture')); + assert.throws(() => assertTrimBoundaryMarkers({ + ...correctMarkers, + startVideoRgb: [180, 20, 18] + }, 'wrong interval'), /start video marker/); + assert.throws(() => assertTrimBoundaryMarkers({ + ...correctMarkers, + endVideoRgb: [170, 20, 160] + }, 'wrong interval'), /end video marker/); + assert.throws(() => assertTrimBoundaryMarkers({ + ...correctMarkers, + startAudioFrequency: 220 + }, 'wrong interval'), /start audio marker/); + assert.throws(() => assertTrimBoundaryMarkers({ + ...correctMarkers, + endAudioFrequency: 880 + }, 'wrong interval'), /end audio marker/); +}); diff --git a/scripts/smoke-test-cutter.js b/scripts/smoke-test-cutter.js index be35973..fc6da93 100644 --- a/scripts/smoke-test-cutter.js +++ b/scripts/smoke-test-cutter.js @@ -75,6 +75,44 @@ async function loadCutterCapability(win, filePath) { return capability; } +async function verifyMultiAudioExport(win, environment, inputFile, outputFile) { + await loadCutterCapability(win, inputFile); + await win.waitForFunction(() => { + const video = document.getElementById('cutterVideo'); + const select = document.getElementById('cutterAudioStream'); + return video.readyState >= HTMLMediaElement.HAVE_METADATA && select.options.length === 2 && !select.disabled; + }, null, { timeout: 90000 }); + const selection = await win.evaluate(() => { + const select = document.getElementById('cutterAudioStream'); + const selectedValue = select.options[1].value; + window.setCutterAudioStream(selectedValue); + select.value = selectedValue; + return { + choices: [...select.options].map((option) => ({ value: option.value, text: option.textContent })), + selectedIndex: cutterAudioStreamIndex, + duration: cutterEditorState.duration + }; + }); + const exportResult = await win.evaluate(({ outputName, duration }) => window.api.exportVideoEdit({ + inputCapability: cutterFile.token, + outputName, + trimStart: 0, + trimEnd: duration, + cuts: [], + audioStreamIndex: cutterAudioStreamIndex + }), { outputName: path.basename(outputFile), duration: selection.duration }); + let probe = null; + if (fs.existsSync(outputFile)) { + const media = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_streams', outputFile])); + const audioStreams = media.streams.filter((stream) => stream.codec_type === 'audio'); + probe = { + audioStreams: audioStreams.length, + channels: audioStreams[0]?.channels || 0 + }; + } + return { selection, exportResult, probe }; +} + async function dropCutterFile(win, filePath) { const inputId = `cutter-drop-${Date.now()}-${Math.random().toString(36).slice(2)}`; await win.evaluate((id) => { @@ -110,6 +148,22 @@ function createTestVideo(environment) { return filePath; } +function createMultiAudioTestVideo(environment) { + const filePath = path.join(environment.mediaDir, 'Cutter Multi Audio.mp4'); + runBinary(resolveBinary(environment, 'ffmpeg'), [ + '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=25', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=48000', + '-map', '0:v:0', '-map', '1:a:0', '-map', '2:a:0', '-t', '4', + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', + '-ac:a:0', '1', '-ac:a:1', '2', + '-metadata:s:a:0', 'language=eng', '-metadata:s:a:1', 'language=deu', + '-shortest', '-y', filePath + ]); + return filePath; +} + function createScrubStressVideo(environment) { const filePath = path.join(environment.mediaDir, 'Scrub Stress 60fps.mp4'); runBinary(resolveBinary(environment, 'ffmpeg'), [ @@ -187,19 +241,23 @@ function createLongVideo(environment) { async function run() { const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' }); const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1'; - const inputFile = createTestVideo(environment); - const scrubStressInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) + const audioOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_AUDIO_ONLY === '1'; + const reducedFixtureMode = remaindersOnly || audioOnly; + const inputFile = audioOnly ? null : createTestVideo(environment); + const multiAudioInputFile = remaindersOnly ? null : createMultiAudioTestVideo(environment); + const scrubStressInputFile = reducedFixtureMode ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA) ? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA : createScrubStressVideo(environment); - const mediumInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) + const mediumInputFile = reducedFixtureMode ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA) ? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA : createMediumVideo(environment); - const additionalContainerFiles = remaindersOnly ? {} : createAdditionalContainerVideos(environment, inputFile); - const unsupportedInputFile = remaindersOnly ? null : createUnsupportedVideo(environment, inputFile); - const unsupportedImageFile = createUnsupportedImage(environment); - const silentInputFile = remaindersOnly ? null : createSilentPortraitVideo(environment); - const longInputFile = remaindersOnly ? null : createLongVideo(environment); + const additionalContainerFiles = reducedFixtureMode ? {} : createAdditionalContainerVideos(environment, inputFile); + const unsupportedInputFile = reducedFixtureMode ? null : createUnsupportedVideo(environment, inputFile); + const unsupportedImageFile = audioOnly ? null : createUnsupportedImage(environment); + const silentInputFile = reducedFixtureMode ? null : createSilentPortraitVideo(environment); + const longInputFile = reducedFixtureMode ? null : createLongVideo(environment); const outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4'); + const multiAudioOutputFile = path.join(environment.mediaDir, 'Cutter Multi Audio selected.mp4'); const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4'); const failures = []; const runtimeIssues = []; @@ -231,6 +289,21 @@ async function run() { await win.setViewportSize({ width: 1440, height: 900 }); await win.emulateMedia({ reducedMotion: 'reduce' }); await win.evaluate(() => window.showTab('cutter')); + if (audioOnly) { + const multiAudio = await verifyMultiAudioExport(win, environment, multiAudioInputFile, multiAudioOutputFile); + check( + multiAudio.selection.choices.length === 2 + && multiAudio.selection.selectedIndex === 1 + && multiAudio.exportResult.success + && multiAudio.probe?.audioStreams === 1 + && multiAudio.probe.channels === 2, + `The selected second audio track was not preserved in the real export: ${JSON.stringify(multiAudio)}` + ); + check(runtimeIssues.length === 0, `Cutter runtime errors occurred: ${runtimeIssues.join(' | ')}`); + console.log(JSON.stringify({ failures, runtimeIssues, multiAudio }, null, 2)); + if (failures.length > 0) process.exitCode = 1; + return; + } const cutterSourceVisibility = []; for (const viewport of [{ width: 1060, height: 700 }, { width: 1180, height: 900 }, { width: 1184, height: 661 }, { width: 1440, height: 679 }, { width: 1440, height: 900 }, { width: 2048, height: 1152 }]) { await win.setViewportSize(viewport); @@ -511,7 +584,8 @@ async function run() { && video.readyState >= HTMLMediaElement.HAVE_METADATA && document.querySelectorAll('#cutterThumbnailStrip img').length > 0 && window.__cutterAssetAudit.waveformLoads.length > 0 - && document.getElementById('cutterAudioStream').selectedOptions[0]?.textContent !== 'Keine Audiospur'; + && !document.getElementById('cutterAudioStream').disabled + && document.getElementById('cutterAudioStream').options.length > 0; }, null, { timeout: 90000 }); await win.waitForTimeout(480); const firstAssetQuality = await win.evaluate(async () => { @@ -629,6 +703,108 @@ async function run() { && loadedMinimumSource.previewWidth > loadedMinimumSource.sidebarWidth, `The source selector remains visible after a real load at the native minimum viewport: ${JSON.stringify(loadedMinimumSource)}` ); + const cutterProjectActions = await win.evaluate(() => { + const toolbar = document.querySelector('[data-toolbar-for="cutter"]'); + const toolbarRect = toolbar.getBoundingClientRect(); + const state = (id) => { + const button = document.getElementById(id); + const rect = button.getBoundingClientRect(); + return { disabled: button.disabled, visible: getComputedStyle(button).display !== 'none' && rect.width > 0 && rect.height > 0 }; + }; + return { + newVideo: state('cutterNewVideoBtn'), + open: state('cutterOpenProjectBtn'), + save: state('cutterSaveProjectBtn'), + toolbarOverflow: toolbar.scrollWidth - toolbar.clientWidth, + contained: toolbarRect.right <= window.innerWidth + 1 + }; + }); + check( + cutterProjectActions.newVideo.visible + && cutterProjectActions.open.visible + && !cutterProjectActions.open.disabled + && cutterProjectActions.save.visible + && !cutterProjectActions.save.disabled + && cutterProjectActions.toolbarOverflow <= 1 + && cutterProjectActions.contained, + `Loaded cutter project actions are hidden, disabled, or overflowing: ${JSON.stringify(cutterProjectActions)}` + ); + await win.evaluate(() => window.setLanguage('en')); + await win.locator('#cutterSaveProjectBtn').click(); + await win.waitForFunction(() => document.getElementById('appToast')?.textContent === UI_TEXT.cutter.projectSaved); + const savedProjectFeedback = await win.locator('#appToast').textContent(); + await win.locator('#cutterOpenProjectBtn').click(); + await win.waitForFunction(() => document.getElementById('appToast')?.textContent === UI_TEXT.cutter.projectOpened); + const openedProjectFeedback = await win.locator('#appToast').textContent(); + const cutterProjectFeedback = { savedProjectFeedback, openedProjectFeedback }; + check( + cutterProjectFeedback.savedProjectFeedback === 'Project saved' + && cutterProjectFeedback.openedProjectFeedback === 'Project opened', + `English cutter project feedback is not localized: ${JSON.stringify(cutterProjectFeedback)}` + ); + await app.evaluate(({ dialog }) => { + const originalShowOpenDialog = dialog.showOpenDialog; + globalThis.__cutterContextPickerCalls = 0; + dialog.showOpenDialog = async () => { + globalThis.__cutterContextPickerCalls += 1; + dialog.showOpenDialog = originalShowOpenDialog; + return { canceled: true, filePaths: [] }; + }; + }); + await win.locator('[data-context-for="cutter"] .context-link').first().click(); + await win.waitForTimeout(50); + const cutterContextPickerCalls = await app.evaluate(() => globalThis.__cutterContextPickerCalls || 0); + check(cutterContextPickerCalls === 1, `The loaded cutter context action did not open the video picker: ${cutterContextPickerCalls}`); + const englishCutterStrings = await win.evaluate(() => ({ + newVideo: document.getElementById('cutterNewVideoText').textContent, + openProject: document.getElementById('cutterOpenProjectBtn').getAttribute('aria-label'), + saveProject: document.getElementById('cutterSaveProjectBtn').getAttribute('aria-label'), + recovery: document.getElementById('cutterRecoveryText').textContent, + recover: document.getElementById('cutterRecoveryRestoreBtn').textContent, + discard: document.getElementById('cutterRecoveryDiscardBtn').textContent, + exportProfile: document.getElementById('cutterExportProfileLabel').textContent, + exportEncoder: document.getElementById('cutterExportEncoderLabel').textContent, + audioStream: document.getElementById('cutterAudioStreamLabel').textContent, + profiles: [...document.getElementById('cutterExportProfile').options].map((option) => option.textContent), + encoders: [...document.getElementById('cutterExportEncoder').options].map((option) => option.textContent), + audioChoices: [...document.getElementById('cutterAudioStream').options].map((option) => option.textContent) + })); + check( + englishCutterStrings.newVideo === 'New video' + && englishCutterStrings.openProject === 'Open project' + && englishCutterStrings.saveProject === 'Save project' + && englishCutterStrings.recovery === 'Saved edit found' + && englishCutterStrings.recover === 'Restore' + && englishCutterStrings.discard === 'Discard' + && englishCutterStrings.exportProfile === 'Export profile' + && englishCutterStrings.exportEncoder === 'Encoder' + && englishCutterStrings.audioStream === 'Audio track' + && JSON.stringify(englishCutterStrings.profiles) === JSON.stringify(['Quality', 'Balanced', 'Fast', 'Archive']) + && englishCutterStrings.encoders[0] === 'Software' + && englishCutterStrings.audioChoices.every((choice) => choice.startsWith('Audio track ')), + `English cutter strings still contain fallback or backend copy: ${JSON.stringify(englishCutterStrings)}` + ); + await win.evaluate(() => window.setLanguage('de')); + const germanCutterStrings = await win.evaluate(() => ({ + newVideo: document.getElementById('cutterNewVideoText').textContent, + openProject: document.getElementById('cutterOpenProjectBtn').getAttribute('aria-label'), + saveProject: document.getElementById('cutterSaveProjectBtn').getAttribute('aria-label'), + recovery: document.getElementById('cutterRecoveryText').textContent, + exportProfile: document.getElementById('cutterExportProfileLabel').textContent, + profiles: [...document.getElementById('cutterExportProfile').options].map((option) => option.textContent), + audioChoices: [...document.getElementById('cutterAudioStream').options].map((option) => option.textContent) + })); + check( + germanCutterStrings.newVideo === 'Neues Video' + && germanCutterStrings.openProject === 'Projekt öffnen' + && germanCutterStrings.saveProject === 'Projekt speichern' + && germanCutterStrings.recovery === 'Gespeicherte Bearbeitung gefunden' + && germanCutterStrings.exportProfile === 'Exportprofil' + && JSON.stringify(germanCutterStrings.profiles) === JSON.stringify(['Qualität', 'Ausgewogen', 'Schnell', 'Archiv']) + && germanCutterStrings.audioChoices.every((choice) => choice.startsWith('Audiospur ')), + `German cutter strings regressed while localizing English: ${JSON.stringify(germanCutterStrings)}` + ); + await win.evaluate(() => window.setLanguage('en')); const loadedCutterExportSelectPresentation = []; for (const viewport of [{ width: 1184, height: 661 }, { width: 1280, height: 800 }]) { await win.setViewportSize(viewport); @@ -840,7 +1016,7 @@ async function run() { ); if (remaindersOnly) { check(runtimeIssues.length === 0, runtimeIssues.join('\n')); - console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2)); + console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, cutterProjectActions, cutterProjectFeedback, cutterContextPickerCalls, englishCutterStrings, germanCutterStrings, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2)); if (failures.length > 0) process.exitCode = 1; return; } @@ -1187,7 +1363,8 @@ async function run() { window.__cutterScrubSyncRecording = true; const parseTimecode = (value, fps) => { const fields = value.split(':').map(Number); - return fields[0] * 60 + fields[1] + fields[2] / fps; + const [hours, minutes, seconds, frames] = fields.length === 4 ? fields : [0, ...fields]; + return hours * 3600 + minutes * 60 + seconds + frames / fps; }; const recordFrame = (_now, metadata) => { if (!window.__cutterScrubSyncRecording) return; @@ -1260,7 +1437,8 @@ async function run() { window.__cutterTrimSyncRecording = true; const parseTimecode = (value, fps) => { const fields = value.split(':').map(Number); - return fields[0] * 60 + fields[1] + fields[2] / fps; + const [hours, minutes, seconds, frames] = fields.length === 4 ? fields : [0, ...fields]; + return hours * 3600 + minutes * 60 + seconds + frames / fps; }; const recordFrame = (_now, metadata) => { if (!window.__cutterTrimSyncRecording) return; @@ -1508,8 +1686,8 @@ async function run() { window.addCutterCut(); }); const firstInputs = win.locator('.cutter-cut-row').first().locator('input'); - await firstInputs.nth(0).fill('00:02:00'); - await firstInputs.nth(1).fill('00:04:00'); + await firstInputs.nth(0).fill('00:00:02:00'); + await firstInputs.nth(1).fill('00:00:04:00'); await firstInputs.nth(1).press('Enter'); await win.evaluate(() => { const video = document.getElementById('cutterVideo'); @@ -1517,8 +1695,8 @@ async function run() { window.addCutterCut(); }); const secondInputs = win.locator('.cutter-cut-row').nth(1).locator('input'); - await secondInputs.nth(0).fill('00:06:00'); - await secondInputs.nth(1).fill('00:07:00'); + await secondInputs.nth(0).fill('00:00:06:00'); + await secondInputs.nth(1).fill('00:00:07:00'); await secondInputs.nth(1).press('Enter'); const edited = await win.evaluate(() => ({ cuts: cutterEditorState.cuts.map((cut) => ({ start: cut.start, end: cut.end })), @@ -1732,7 +1910,7 @@ async function run() { const cutInput = win.locator('.cutter-cut-row').first().locator('input').first(); const stateBeforeTextUndo = await win.evaluate(() => JSON.stringify(cutterEditorState)); await cutInput.focus(); - await cutInput.fill('00:02:01'); + await cutInput.fill('00:00:02:01'); await cutInput.press('Control+z'); const textUndoState = await win.evaluate((before) => ({ stateUnchanged: JSON.stringify(cutterEditorState) === before, activeTag: document.activeElement?.tagName }), stateBeforeTextUndo); check(textUndoState.stateUnchanged && textUndoState.activeTag === 'INPUT', `Text-field undo changed the whole editor: ${JSON.stringify(textUndoState)}`); @@ -2281,6 +2459,18 @@ async function run() { }); await win.waitForTimeout(250); await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true }); + const multiAudio = await verifyMultiAudioExport(win, environment, multiAudioInputFile, multiAudioOutputFile); + const multiAudioSelection = multiAudio.selection; + const multiAudioExportResult = multiAudio.exportResult; + const multiAudioProbe = multiAudio.probe; + check( + multiAudioSelection.choices.length === 2 + && multiAudioSelection.selectedIndex === 1 + && multiAudioExportResult.success + && multiAudioProbe?.audioStreams === 1 + && multiAudioProbe.channels === 2, + `The selected second audio track was not preserved in the real export: ${JSON.stringify({ multiAudioSelection, multiAudioExportResult, multiAudioProbe })}` + ); await loadCutterCapability(win, silentInputFile); await win.waitForFunction(() => { const video = document.getElementById('cutterVideo'); @@ -2327,7 +2517,7 @@ async function run() { const shutdownArtifacts = fs.readdirSync(environment.mediaDir) .filter((name) => name.includes('.tvm-edit.mp4') || name.includes('.tvm-backup') || name === path.basename(shutdownOutputFile)); check(cutterTempDirectoriesAfterShutdown.length === 0 && shutdownArtifacts.length === 0, `Shutdown left cutter artifacts: ${JSON.stringify({ cutterTempDirectoriesAfterShutdown, shutdownArtifacts })}`); - console.log(JSON.stringify({ failures, runtimeIssues, additionalContainerSupport, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter, revealAnimation, firstAssetQuality, firstAssetsReadyMs, initialAssetStability, verticalProfileQuality, loaded, edgeGeometry, timestampTypography, playerControlGeometry, cutterInfoAlignment, replacementPromptState, replacementPlaybackState, scrubMediaInfo, scrubFirstAssetsReadyMs, scrubFirstAssetQuality, realMaximumZoomState, scrubSyncProbe, trimScrubProbe, mediumWaveformReadyMs, mediumFirstAssetsReadyMs, mediumZoomReuseBefore, mediumZoomReuseAfter, longPlayerReadyMs, longAssetsReadyMs, longAssetTopology, longPreservedAfterAssetInterruptions, longScrubPresentation, rapidSwitch, memoryBeforeStressMb, memoryAfterStressMb, stressMemoryDeltaMb, preservedAfterUnsupported, edited, cutHandleVisualGeometry, cutterAria, germanCutLabels, draggedCutStart, collisionBoundedCut, reversibleTrimStart, reversibleTrimEnd, crossedCutTime, skippedTime, smoothTimecodeFrames, playbackPerformance, playbackAfterDrag, stoppedTime, settingsMenuLayout, escapedSettings, playbackRateState, tabLeaveState, layoutAudit, responsiveLayouts, expandedVolumeLayout, wheelZoom, zoomGeometryDelta, maximumZoomGeometryDelta, assetDensity, zoomWaveformReuse, zoom, sourceProtection, invalidRequest, cancelledExport, exportResult, manyCutsExport, changedSourceExport, silentState, silentExportResult, cutterTempDirectoriesAfterShutdown, shutdownArtifacts }, null, 2)); + console.log(JSON.stringify({ failures, runtimeIssues, additionalContainerSupport, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter, revealAnimation, firstAssetQuality, firstAssetsReadyMs, initialAssetStability, verticalProfileQuality, loaded, edgeGeometry, timestampTypography, playerControlGeometry, cutterInfoAlignment, replacementPromptState, replacementPlaybackState, scrubMediaInfo, scrubFirstAssetsReadyMs, scrubFirstAssetQuality, realMaximumZoomState, scrubSyncProbe, trimScrubProbe, mediumWaveformReadyMs, mediumFirstAssetsReadyMs, mediumZoomReuseBefore, mediumZoomReuseAfter, longPlayerReadyMs, longAssetsReadyMs, longAssetTopology, longPreservedAfterAssetInterruptions, longScrubPresentation, rapidSwitch, memoryBeforeStressMb, memoryAfterStressMb, stressMemoryDeltaMb, preservedAfterUnsupported, edited, cutHandleVisualGeometry, cutterAria, germanCutLabels, draggedCutStart, collisionBoundedCut, reversibleTrimStart, reversibleTrimEnd, crossedCutTime, skippedTime, smoothTimecodeFrames, playbackPerformance, playbackAfterDrag, stoppedTime, settingsMenuLayout, escapedSettings, playbackRateState, tabLeaveState, layoutAudit, responsiveLayouts, expandedVolumeLayout, wheelZoom, zoomGeometryDelta, maximumZoomGeometryDelta, assetDensity, zoomWaveformReuse, zoom, sourceProtection, invalidRequest, cancelledExport, exportResult, manyCutsExport, changedSourceExport, multiAudioSelection, multiAudioExportResult, multiAudioProbe, silentState, silentExportResult, cutterTempDirectoriesAfterShutdown, shutdownArtifacts }, null, 2)); if (failures.length > 0) process.exitCode = 1; } finally { if (app) await app.close(); diff --git a/scripts/smoke-test-installer.js b/scripts/smoke-test-installer.js index 09d2a5f..d9256ee 100644 --- a/scripts/smoke-test-installer.js +++ b/scripts/smoke-test-installer.js @@ -1,11 +1,11 @@ const fs = require('fs'); -const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); const root = path.resolve(__dirname, '..'); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); const appGuid = '08429788-303d-53b6-a4f9-894401712c7e'; +const shortcutName = packageJson.build.nsis.shortcutName; function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -22,20 +22,197 @@ function run(command, args, options = {}) { } function findUninstaller(installationDirectory) { + if (!fs.existsSync(installationDirectory)) return ''; return fs.readdirSync(installationDirectory) .filter((name) => /^uninstall.*\.exe$/i.test(name)) .map((name) => path.join(installationDirectory, name))[0] || ''; } -function assertCleanInstallerSmokeSurface() { - const userInstallKey = `HKCU\\Software\\${appGuid}`; - const machineInstallKey = `HKLM\\SOFTWARE\\${appGuid}`; - const query = (key) => spawnSync('reg', ['query', key], { encoding: 'utf8', windowsHide: true }); - const existingInstallations = [userInstallKey, machineInstallKey] - .filter((key) => query(key).status === 0); - if (existingInstallations.length > 0) { - throw new Error(`Installer smoke requires a clean Windows registration surface: ${existingInstallations.join(', ')}`); +function createInstallerPhases(smokeRoot, folders) { + if (!path.win32.isAbsolute(smokeRoot)) throw new Error(`Installer smoke root is not absolute: ${smokeRoot}`); + for (const name of ['commonDesktop', 'commonPrograms', 'currentDesktop', 'currentPrograms']) { + if (!path.win32.isAbsolute(folders[name] || '')) throw new Error(`Windows shell folder ${name} is invalid: ${folders[name] || ''}`); } + return [ + { + desktopShortcut: path.win32.join(folders.currentDesktop, `${shortcutName}.lnk`), + flag: '/currentuser', + hive: 'HKCU', + installationDirectory: path.win32.join(smokeRoot, 'currentuser', 'app'), + oppositeHive: 'HKLM', + startMenuShortcut: path.win32.join(folders.currentPrograms, `${shortcutName}.lnk`) + }, + { + desktopShortcut: path.win32.join(folders.commonDesktop, `${shortcutName}.lnk`), + flag: '/allusers', + hive: 'HKLM', + installationDirectory: path.win32.join(smokeRoot, 'allusers', 'app'), + oppositeHive: 'HKCU', + startMenuShortcut: path.win32.join(folders.commonPrograms, `${shortcutName}.lnk`) + } + ].map((phase) => ({ + ...phase, + executablePath: path.win32.join(phase.installationDirectory, `${packageJson.build.productName}.exe`), + iconPath: path.win32.join(phase.installationDirectory, 'resources', 'app-icons', `icon-${packageJson.version}.ico`), + installArguments: ['/S', phase.flag, `/D=${phase.installationDirectory}`] + })); +} + +function normalizeWindowsPath(candidate) { + return path.win32.resolve(String(candidate)).replaceAll('/', '\\').toLowerCase(); +} + +function assertPathInside(targetPath, parentPath) { + const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath)); + if (!relative || relative.startsWith('..\\') || relative === '..' || path.win32.isAbsolute(relative)) { + throw new Error(`Refusing recursive cleanup outside its parent: ${JSON.stringify({ targetPath, parentPath })}`); + } +} + +function assertFile(filePath, label) { + let isFile = false; + try { + isFile = fs.statSync(filePath).isFile(); + } catch {} + if (!isFile) throw new Error(`${label} is missing: ${filePath}`); +} + +function assertShortcutDetails(details, { expectedIcon, expectedTarget, pathExists = fs.existsSync }) { + const targetPath = String(details.targetPath || ''); + const iconPath = String(details.iconLocation || '').replace(/,\s*-?\d+$/, ''); + if (normalizeWindowsPath(targetPath) !== normalizeWindowsPath(expectedTarget)) { + throw new Error(`Shortcut target mismatch: ${JSON.stringify({ actual: targetPath, expected: expectedTarget })}`); + } + if (normalizeWindowsPath(iconPath) !== normalizeWindowsPath(expectedIcon)) { + throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`); + } + if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`); + if (!pathExists(iconPath)) throw new Error(`Shortcut icon is missing: ${iconPath}`); +} + +function registryKeys(hive) { + return { + install: `${hive}\\Software\\${appGuid}`, + uninstall: `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${appGuid}` + }; +} + +function assertInstalledRegistration(phase, { expectedUninstallerPath, keyExists, readRegistryValue }) { + const selectedKeys = registryKeys(phase.hive); + const oppositeKeys = registryKeys(phase.oppositeHive); + if (!keyExists(selectedKeys.install) || !keyExists(selectedKeys.uninstall)) { + throw new Error(`Installer did not register the ${phase.flag} installation in ${phase.hive}`); + } + if (keyExists(oppositeKeys.install) || keyExists(oppositeKeys.uninstall)) { + throw new Error(`Installer left registration in the opposite installation scope ${phase.oppositeHive}`); + } + + const registeredLocation = readRegistryValue(selectedKeys.install, 'InstallLocation'); + if (normalizeWindowsPath(registeredLocation) !== normalizeWindowsPath(phase.installationDirectory)) { + throw new Error(`Registered install location mismatch: ${JSON.stringify({ actual: registeredLocation, expected: phase.installationDirectory })}`); + } + + const uninstallString = String(readRegistryValue(selectedKeys.uninstall, 'UninstallString') || ''); + const quietUninstallString = String(readRegistryValue(selectedKeys.uninstall, 'QuietUninstallString') || ''); + const expectedUninstallString = `"${expectedUninstallerPath}" ${phase.flag}`; + const expectedQuietUninstallString = `${expectedUninstallString} /S`; + if (uninstallString.toLowerCase() !== expectedUninstallString.toLowerCase() || quietUninstallString.toLowerCase() !== expectedQuietUninstallString.toLowerCase()) { + throw new Error(`Registered uninstall command mismatch: ${JSON.stringify({ actual: { quietUninstallString, uninstallString }, expected: { quietUninstallString: expectedQuietUninstallString, uninstallString: expectedUninstallString } })}`); + } +} + +function assertInstallerSurfaceClean(phases, { keyExists, pathExists = fs.existsSync }) { + const registrySurface = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))]; + const shortcutSurface = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))]; + const existing = [ + ...registrySurface.filter(keyExists), + ...shortcutSurface.filter(pathExists) + ]; + if (existing.length > 0) { + throw new Error(`Installer smoke requires a clean registry and shortcut surface: ${existing.join(', ')}`); + } +} + +function registryKeyExists(key) { + const result = spawnSync('reg.exe', ['query', key], { encoding: 'utf8', timeout: 30000, windowsHide: true }); + if (result.error) throw result.error; + if (result.status === 0) return true; + if (result.status === 1) return false; + throw new Error(`Registry query failed: ${JSON.stringify({ key, status: result.status, stdout: result.stdout, stderr: result.stderr })}`); +} + +function readRegistryValue(key, name) { + const result = spawnSync('reg.exe', ['query', key, '/v', name], { encoding: 'utf8', timeout: 30000, windowsHide: true }); + if (result.error) throw result.error; + if (result.status === 1) return null; + if (result.status !== 0) { + throw new Error(`Registry value query failed: ${JSON.stringify({ key, name, status: result.status, stdout: result.stdout, stderr: result.stderr })}`); + } + const valueMatch = result.stdout + .split(/\r?\n/) + .map((line) => line.match(/^\s*(\S+)\s+REG_\w+\s+(.*)$/i)) + .find((match) => match?.[1]?.toLowerCase() === name.toLowerCase()); + return valueMatch?.[2]?.trim() ?? null; +} + +function runPowerShell(script, environment = {}) { + const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { + env: { ...process.env, ...environment } + }); + return result.stdout.trim().replace(/^\uFEFF/, ''); +} + +function readShellFolders() { + return JSON.parse(runPowerShell("[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [ordered]@{ currentPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::Programs); commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms); currentDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::DesktopDirectory); commonDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonDesktopDirectory) } | ConvertTo-Json -Compress")); +} + +function readShortcutDetails(shortcutPath) { + const script = "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_INSTALLER_SMOKE_SHORTCUT); [ordered]@{ targetPath = $shortcut.TargetPath; iconLocation = $shortcut.IconLocation } | ConvertTo-Json -Compress"; + return JSON.parse(runPowerShell(script, { TVM_INSTALLER_SMOKE_SHORTCUT: shortcutPath })); +} + +function assertAdministrator() { + runPowerShell("$identity = [Security.Principal.WindowsIdentity]::GetCurrent(); $principal = [Security.Principal.WindowsPrincipal]::new($identity); if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 1 }"); +} + +function assertCleanInstallerSmokeSurface(phases) { + assertInstallerSurfaceClean(phases, { keyExists: registryKeyExists }); +} + +function seedOrphanedRegistrations(phase, smokeRoot) { + for (const hive of ['HKCU', 'HKLM']) { + const keys = registryKeys(hive); + const orphanedLocation = path.win32.join(smokeRoot, 'orphaned', phase.flag.slice(1), hive, 'app'); + const orphanedUninstaller = path.win32.join(orphanedLocation, `Uninstall ${packageJson.build.productName}.exe`); + run('reg.exe', ['add', keys.install, '/v', 'InstallLocation', '/t', 'REG_SZ', '/d', orphanedLocation, '/f']); + run('reg.exe', ['add', keys.uninstall, '/v', 'UninstallString', '/t', 'REG_SZ', '/d', `"${orphanedUninstaller}" ${phase.flag}`, '/f']); + } +} + +function cleanupInstallerSurface(phases) { + const keys = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))]; + for (const key of keys) { + spawnSync('reg.exe', ['delete', key, '/f'], { encoding: 'utf8', timeout: 30000, windowsHide: true }); + } + const shortcuts = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))]; + for (const shortcut of shortcuts) fs.rmSync(shortcut, { force: true }); +} + +function verifyInstalledPhase(phase) { + assertFile(phase.executablePath, 'Installed executable'); + assertFile(phase.iconPath, 'Installed shortcut icon'); + assertFile(phase.startMenuShortcut, 'Start Menu shortcut'); + assertFile(phase.desktopShortcut, 'Desktop shortcut'); + const uninstallerPath = findUninstaller(phase.installationDirectory); + if (!uninstallerPath) throw new Error(`Installed uninstaller is missing: ${phase.installationDirectory}`); + assertInstalledRegistration(phase, { expectedUninstallerPath: uninstallerPath, keyExists: registryKeyExists, readRegistryValue }); + for (const shortcutPath of [phase.startMenuShortcut, phase.desktopShortcut]) { + assertShortcutDetails(readShortcutDetails(shortcutPath), { + expectedIcon: phase.iconPath, + expectedTarget: phase.executablePath + }); + } + return uninstallerPath; } async function waitForPathRemoval(targetPath, timeoutMs = 10000) { @@ -47,42 +224,86 @@ async function waitForPathRemoval(targetPath, timeoutMs = 10000) { return true; } -async function main() { - if (process.platform !== 'win32') throw new Error('Installer smoke requires Windows'); - if (process.env.CI !== 'true' && process.env.TWITCH_VOD_MANAGER_INSTALLER_SMOKE !== '1') { - throw new Error('Installer smoke is restricted to CI or explicit TWITCH_VOD_MANAGER_INSTALLER_SMOKE=1 opt-in'); +function assertHostedWindowsCi(environment = process.env, platform = process.platform) { + const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase(); + const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com'; + const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de'; + if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) { + throw new Error('Real installer smoke is restricted to an approved Windows Actions runner'); } +} +async function main() { + assertHostedWindowsCi(); const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`); - if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`); - assertCleanInstallerSmokeSurface(); + assertFile(installerPath, 'Installer'); + assertAdministrator(); - const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-')); - const installationDirectory = path.join(smokeRoot, 'app'); - const executablePath = path.join(installationDirectory, `${packageJson.build.productName}.exe`); - let uninstallerPath = ''; + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) { + throw new Error(`Hosted runner temp directory is invalid: ${runnerTemp || ''}`); + } + const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-installer-')); + let phases = []; + const results = []; + let ownsSurface = false; try { - run(installerPath, ['/S', '/currentuser', `/D=${installationDirectory}`], { cwd: smokeRoot }); - if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`); - uninstallerPath = findUninstaller(installationDirectory); - if (!uninstallerPath) throw new Error('Installed uninstaller is missing'); - run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], { - cwd: root, - env: { ...process.env, PACKAGED_APP_PATH: executablePath } - }); - run(uninstallerPath, ['/S'], { cwd: smokeRoot }); - if (!await waitForPathRemoval(executablePath)) throw new Error('Silent uninstall left the packaged executable installed'); - console.log(JSON.stringify({ failures: [], installerPath }, null, 2)); - } finally { - if (uninstallerPath && fs.existsSync(uninstallerPath)) { - spawnSync(uninstallerPath, ['/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' }); + assertPathInside(smokeRoot, runnerTemp); + phases = createInstallerPhases(smokeRoot, readShellFolders()); + assertCleanInstallerSmokeSurface(phases); + ownsSurface = true; + for (const phase of phases) { + seedOrphanedRegistrations(phase, smokeRoot); + run(installerPath, phase.installArguments, { cwd: smokeRoot }); + const uninstallerPath = verifyInstalledPhase(phase); + run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], { + cwd: root, + env: { ...process.env, PACKAGED_APP_PATH: phase.executablePath } + }); + run(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot }); + if (!await waitForPathRemoval(phase.installationDirectory, 30000)) { + throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`); + } + assertCleanInstallerSmokeSurface(phases); + results.push({ + flag: phase.flag, + hive: phase.hive, + iconPath: phase.iconPath, + installationDirectory: phase.installationDirectory, + startMenuShortcut: phase.startMenuShortcut + }); } + console.log(JSON.stringify({ failures: [], installerPath, results }, null, 2)); + } finally { + if (ownsSurface) { + for (const phase of [...phases].reverse()) { + const uninstallerPath = findUninstaller(phase.installationDirectory); + if (uninstallerPath) { + spawnSync(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' }); + } + } + cleanupInstallerSurface(phases); + } + assertPathInside(smokeRoot, runnerTemp); await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); } } -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} + +module.exports = { + assertHostedWindowsCi, + assertInstalledRegistration, + assertInstallerSurfaceClean, + assertPathInside, + assertShortcutDetails, + createInstallerPhases, + readShortcutDetails, + registryKeys +}; diff --git a/scripts/smoke-test-installer.test.js b/scripts/smoke-test-installer.test.js new file mode 100644 index 0000000..44c2afa --- /dev/null +++ b/scripts/smoke-test-installer.test.js @@ -0,0 +1,213 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const { spawnSync } = require('child_process'); + +const { + assertHostedWindowsCi, + assertInstalledRegistration, + assertInstallerSurfaceClean, + assertPathInside, + assertShortcutDetails, + createInstallerPhases, + readShortcutDetails, + registryKeys +} = require('./smoke-test-installer'); + +const builderInstallerSource = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'include', 'installer.nsh'), 'utf8'); +const builderMultiUserSource = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'multiUser.nsh'), 'utf8'); +const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); + +test('real installer smoke cannot be enabled on a local workstation', () => { + assert.throws( + () => assertHostedWindowsCi({ TWITCH_VOD_MANAGER_INSTALLER_SMOKE: '1' }, 'win32'), + /approved Windows Actions runner/ + ); +}); + +test('real installer smoke rejects generic local CI identities', () => { + assert.throws(() => assertHostedWindowsCi({ + CI: 'true', + GITHUB_ACTIONS: 'true', + GITHUB_RUN_ID: '123', + GITHUB_SERVER_URL: 'https://ci.example.test', + RUNNER_OS: 'Windows', + RUNNER_TEMP: 'C:\\runner-temp' + }, 'win32'), /approved Windows Actions runner/); +}); + +test('real installer smoke accepts the GitHub Windows Actions identity', () => { + assert.doesNotThrow(() => assertHostedWindowsCi({ + CI: 'true', + GITHUB_ACTIONS: 'true', + GITHUB_RUN_ID: '123', + GITHUB_SERVER_URL: 'https://github.com', + RUNNER_OS: 'Windows', + RUNNER_ENVIRONMENT: 'github-hosted', + RUNNER_TEMP: 'C:\\runner-temp' + }, 'win32')); +}); + +test('real installer smoke accepts the git.24-music.de Gitea Windows Actions identity', () => { + assert.doesNotThrow(() => assertHostedWindowsCi({ + CI: 'true', + GITEA_ACTIONS: 'true', + GITHUB_RUN_ID: '456', + GITHUB_SERVER_URL: 'https://git.24-music.de', + RUNNER_OS: 'Windows', + RUNNER_TEMP: 'C:\\runner-temp' + }, 'win32')); +}); + +test('installer phases cover current user then all users with scope-correct paths', () => { + const phases = createInstallerPhases('C:\\smoke', { + commonDesktop: 'C:\\shared-desktop', + commonPrograms: 'C:\\shared-programs', + currentDesktop: 'C:\\user-desktop', + currentPrograms: 'C:\\user-programs' + }); + + assert.deepStrictEqual(phases.map((phase) => ({ + flag: phase.flag, + hive: phase.hive, + installationDirectory: phase.installationDirectory, + startMenuShortcut: phase.startMenuShortcut + })), [ + { + flag: '/currentuser', + hive: 'HKCU', + installationDirectory: 'C:\\smoke\\currentuser\\app', + startMenuShortcut: 'C:\\user-programs\\Twitch VOD Manager.lnk' + }, + { + flag: '/allusers', + hive: 'HKLM', + installationDirectory: 'C:\\smoke\\allusers\\app', + startMenuShortcut: 'C:\\shared-programs\\Twitch VOD Manager.lnk' + } + ]); + for (const phase of phases) { + assert.deepStrictEqual(phase.installArguments, ['/S', phase.flag, `/D=${phase.installationDirectory}`]); + } +}); + +test('installer phases reject unresolved Windows shell folders', () => { + assert.throws(() => createInstallerPhases('C:\\smoke', { + commonDesktop: '', + commonPrograms: 'C:\\shared-programs', + currentDesktop: 'C:\\user-desktop', + currentPrograms: 'C:\\user-programs' + }), /commonDesktop/); +}); + +test('shortcut contract verifies the real target and versioned installed icon', () => { + const expectedIcon = `C:\\smoke\\currentuser\\app\\resources\\app-icons\\icon-${packageJson.version}.ico`; + const existingPaths = new Set([ + 'c:\\smoke\\currentuser\\app\\twitch vod manager.exe', + expectedIcon.toLowerCase() + ]); + assert.doesNotThrow(() => assertShortcutDetails({ + iconLocation: `${expectedIcon},0`, + targetPath: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe' + }, { + expectedIcon, + expectedTarget: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe', + pathExists: (candidate) => existingPaths.has(candidate.toLowerCase()) + })); + assert.throws(() => assertShortcutDetails({ + iconLocation: 'C:\\Users\\runner\\AppData\\Local\\Twitch VOD Manager\\Shortcut Icons\\icon-1.0.17.ico,0', + targetPath: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe' + }, { + expectedIcon, + expectedTarget: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe', + pathExists: () => true + }), /icon/i); +}); + +test('shortcut inspection reads a real temporary Windows link', { skip: process.platform !== 'win32' }, () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-link-contract-')); + const targetPath = path.join(temporaryRoot, 'Twitch VOD Manager.exe'); + const iconPath = path.join(temporaryRoot, 'icon.ico'); + const shortcutPath = path.join(temporaryRoot, 'Twitch VOD Manager.lnk'); + try { + fs.writeFileSync(targetPath, 'target'); + fs.writeFileSync(iconPath, 'icon'); + const result = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_TEST_SHORTCUT); $shortcut.TargetPath = $env:TVM_TEST_TARGET; $shortcut.IconLocation = $env:TVM_TEST_ICON; $shortcut.Save()"], { + encoding: 'utf8', + env: { ...process.env, TVM_TEST_ICON: iconPath, TVM_TEST_SHORTCUT: shortcutPath, TVM_TEST_TARGET: targetPath }, + windowsHide: true + }); + assert.strictEqual(result.status, 0, result.stderr); + const details = readShortcutDetails(shortcutPath); + assert.strictEqual(details.targetPath.toLowerCase(), targetPath.toLowerCase()); + assert.strictEqual(details.iconLocation.replace(/,\s*0$/, '').toLowerCase(), iconPath.toLowerCase()); + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('registration contract proves the selected hive, install path and uninstall mode', () => { + const [phase] = createInstallerPhases('C:\\smoke', { + commonDesktop: 'C:\\shared-desktop', + commonPrograms: 'C:\\shared-programs', + currentDesktop: 'C:\\user-desktop', + currentPrograms: 'C:\\user-programs' + }); + const selected = registryKeys('HKCU'); + const values = new Map([ + [`${selected.install}|InstallLocation`, phase.installationDirectory], + [`${selected.uninstall}|UninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag}`], + [`${selected.uninstall}|QuietUninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag} /S`] + ]); + assert.doesNotThrow(() => assertInstalledRegistration(phase, { + expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe', + keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)), + readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null + })); + values.set(`${registryKeys('HKLM').install}|InstallLocation`, 'C:\\orphan'); + assert.throws(() => assertInstalledRegistration(phase, { + expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe', + keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)), + readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null + }), /opposite installation scope/i); + values.delete(`${registryKeys('HKLM').install}|InstallLocation`); + values.set(`${selected.uninstall}|UninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag} /unexpected`); + assert.throws(() => assertInstalledRegistration(phase, { + expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe', + keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)), + readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null + }), /uninstall command mismatch/i); +}); + +test('bundled electron-builder writes the selected install mode into both uninstall commands', () => { + assert.match(builderMultiUserSource, /!macro setInstallModePerUser[\s\S]*?SetShellVarContext current/); + assert.match(builderMultiUserSource, /!macro setInstallModePerAllUsers[\s\S]*?SetShellVarContext all/); + assert.match(builderInstallerSource, /\$installMode == "all"[\s\S]*?StrCpy \$0 "\/allusers"[\s\S]*?StrCpy \$0 "\/currentuser"/); + assert.match(builderInstallerSource, /WriteRegStr SHELL_CONTEXT "\$\{UNINSTALL_REGISTRY_KEY\}" UninstallString '"\$2" \$0'/); + assert.match(builderInstallerSource, /WriteRegStr SHELL_CONTEXT "\$\{UNINSTALL_REGISTRY_KEY\}" QuietUninstallString '"\$2" \$0 \/S'/); +}); + +test('clean surface contract includes both registry hives and both shortcut scopes', () => { + const phases = createInstallerPhases('C:\\smoke', { + commonDesktop: 'C:\\shared-desktop', + commonPrograms: 'C:\\shared-programs', + currentDesktop: 'C:\\user-desktop', + currentPrograms: 'C:\\user-programs' + }); + assert.doesNotThrow(() => assertInstallerSurfaceClean(phases, { + keyExists: () => false, + pathExists: () => false + })); + assert.throws(() => assertInstallerSurfaceClean(phases, { + keyExists: (key) => key === registryKeys('HKLM').uninstall, + pathExists: (candidate) => candidate === phases[0].startMenuShortcut + }), /HKLM.*Twitch VOD Manager\.lnk/i); +}); + +test('recursive cleanup is limited to the dedicated runner temp directory', () => { + assert.doesNotThrow(() => assertPathInside('C:\\runner-temp\\tvm-installer-123', 'C:\\runner-temp')); + assert.throws(() => assertPathInside('C:\\runner-temp', 'C:\\runner-temp'), /outside its parent/i); + assert.throws(() => assertPathInside('C:\\other', 'C:\\runner-temp'), /outside its parent/i); +}); diff --git a/scripts/smoke-test-live-integration-contract.js b/scripts/smoke-test-live-integration-contract.js new file mode 100644 index 0000000..cc12e56 --- /dev/null +++ b/scripts/smoke-test-live-integration-contract.js @@ -0,0 +1,333 @@ +const nodeCrypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const LIVE_OPT_IN = 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION'; +const PRODUCTION_RELEASE_DOWNLOAD_BASE = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'; +const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; + +function parseGateMode(argumentsList) { + if (argumentsList.length === 0) return 'all'; + if (argumentsList.length === 1 && ['all', 'twitch', 'updater'].includes(argumentsList[0])) return argumentsList[0]; + throw new Error('Live integration mode must be one of: all, twitch, updater'); +} + +function requiredEnvironment(environment, name) { + const value = environment[name]; + if (typeof value !== 'string' || value.trim() === '') throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +function optionalEnvironment(environment, name) { + const value = environment[name]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +function readLiveConfiguration(mode, environment = process.env) { + if (environment[LIVE_OPT_IN] !== '1') throw new Error(`Refusing live network execution without ${LIVE_OPT_IN}=1`); + const configuration = { mode }; + + if (mode === 'all' || mode === 'twitch') { + const login = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN').trim().toLowerCase(); + const vodId = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID').trim(); + if (!/^[a-z0-9_]{2,25}$/.test(login)) throw new Error('TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN must be a Twitch login, not a URL'); + if (!/^\d{6,20}$/.test(vodId)) throw new Error('TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID must contain only the numeric VOD id'); + configuration.twitch = { + clientId: requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID').trim(), + clientSecret: requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'), + ffprobePath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH'), + login, + streamlinkPath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH'), + vodId + }; + } + + if (mode === 'all' || mode === 'updater') { + const sourceVersion = normalizeVersion(requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION')); + const sourceSha256 = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256').trim().toLowerCase(); + const expectedCommitSha = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA').trim().toLowerCase(); + const expectedVersion = normalizeVersion(requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION')); + const expectedSha512 = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512').trim(); + const workflowCommitSha = requiredEnvironment(environment, 'GITHUB_SHA').trim().toLowerCase(); + if (!sourceVersion) throw new Error('TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION must be a numeric release version'); + if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new Error('TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256 must be a hexadecimal SHA-256 digest'); + if (!/^[a-f0-9]{40}$/.test(expectedCommitSha)) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA must be a 40-character hexadecimal commit SHA'); + if (!expectedVersion) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION must be a numeric release version'); + if (!isSha512Base64(expectedSha512)) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512 must be a base64 SHA-512 digest'); + if (!/^[a-f0-9]{40}$/.test(workflowCommitSha) || workflowCommitSha !== expectedCommitSha) { + throw new Error('Pinned update commit must match the current workflow commit'); + } + if (compareVersions(sourceVersion, expectedVersion) >= 0) throw new Error('Packaged source version must be older than the pinned update version'); + if (expectedVersion !== PACKAGE_VERSION) throw new Error(`Pinned update version must match package version ${PACKAGE_VERSION}`); + const expectedRef = `refs/tags/v${expectedVersion}`; + if (requiredEnvironment(environment, 'GITHUB_REF').trim() !== expectedRef) { + throw new Error(`Post-publish updater gate must run from release tag ${expectedRef}`); + } + configuration.updater = { + expectedCommitSha, + expectedSha512, + expectedVersion, + packagedAppPath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_PACKAGED_APP_PATH'), + sourceSha256, + sourceVersion + }; + } + + return configuration; +} + +function redactDiagnostic(error, sensitiveValues = []) { + let diagnostic = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + const values = sensitiveValues + .filter((value) => typeof value === 'string' && value.length >= 4) + .sort((left, right) => right.length - left.length); + for (const value of values) diagnostic = diagnostic.split(value).join('[REDACTED]'); + return diagnostic + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]') + .replace(/(client_secret=)[^&\s]+/gi, '$1[REDACTED]') + .replace(/(access_token=)[^&\s]+/gi, '$1[REDACTED]'); +} + +function validateTwitchToken(tokenPayload, validationPayload, expectedClientId) { + if (!tokenPayload || typeof tokenPayload !== 'object' || typeof tokenPayload.access_token !== 'string' || tokenPayload.access_token.length < 8) { + throw new Error('Twitch OAuth token response did not contain an access token'); + } + const tokenType = String(tokenPayload.token_type || '').toLowerCase(); + if (tokenType !== 'bearer') throw new Error('Twitch OAuth token response did not declare bearer token type'); + if (!validationPayload || typeof validationPayload !== 'object' || validationPayload.client_id !== expectedClientId) { + throw new Error('Twitch OAuth validation returned a different client id'); + } + const expiresInSeconds = Number(validationPayload.expires_in); + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) throw new Error('Twitch OAuth validation returned an expired token'); + return { expiresInSeconds, tokenType }; +} + +function buildStreamlinkArguments(vodId, outputPath, durationSeconds = 8) { + if (!/^\d{6,20}$/.test(String(vodId))) throw new Error('Streamlink VOD id must be numeric'); + if (!Number.isInteger(durationSeconds) || durationSeconds < 3 || durationSeconds > 60) throw new Error('Streamlink sample duration must be between 3 and 60 seconds'); + if (typeof outputPath !== 'string' || !path.isAbsolute(outputPath)) throw new Error('Streamlink output path must be absolute'); + return [ + '--no-config', + '--no-plugin-cache', + '--no-plugin-sideloading', + '--http-timeout', + '20', + '--stream-timeout', + '30', + '--stream-segment-attempts', + '2', + '--stream-segment-timeout', + '20', + '--stream-segmented-duration', + String(durationSeconds), + '--output', + outputPath, + `https://www.twitch.tv/videos/${vodId}`, + 'worst' + ]; +} + +function validateMediaProbe(probe, actualBytes) { + const streams = Array.isArray(probe?.streams) ? probe.streams : []; + const video = streams.find((stream) => stream?.codec_type === 'video' && typeof stream.codec_name === 'string' && stream.codec_name !== ''); + if (!video) throw new Error('Downloaded sample did not contain a video stream'); + const durationSeconds = Number(probe?.format?.duration); + if (!Number.isFinite(durationSeconds) || durationSeconds < 1 || durationSeconds > 60) throw new Error('Downloaded sample duration was outside the bounded smoke range'); + if (!Number.isInteger(actualBytes) || actualBytes < 16 * 1024) throw new Error('Downloaded sample was too small to be valid media'); + if (actualBytes > 32 * 1024 * 1024) throw new Error('Downloaded sample exceeded the 32 MiB safety limit'); + const reportedBytes = Number(probe?.format?.size); + if (Number.isFinite(reportedBytes) && reportedBytes !== actualBytes) throw new Error('ffprobe size did not match the downloaded file'); + return { bytes: actualBytes, codec: video.codec_name, durationSeconds }; +} + +function parseYamlScalar(value) { + const trimmed = value.trim(); + if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) return trimmed.slice(1, -1); + return trimmed; +} + +function parseLatestYaml(source) { + if (typeof source !== 'string' || source.length === 0 || source.length > 128 * 1024) throw new Error('latest.yml payload was empty or too large'); + const metadata = { files: [] }; + let currentFile; + for (const line of source.split(/\r?\n/)) { + let match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); + if (match) { + const [, key, rawValue] = match; + if (key === 'version' || key === 'path' || key === 'sha512' || key === 'releaseDate') metadata[key] = parseYamlScalar(rawValue); + continue; + } + match = line.match(/^\s{2}-\s+url:\s*(.+)$/); + if (match) { + currentFile = { url: parseYamlScalar(match[1]) }; + metadata.files.push(currentFile); + continue; + } + match = line.match(/^\s{4}(sha512|size):\s*(.+)$/); + if (match && currentFile) currentFile[match[1]] = match[1] === 'size' ? Number(parseYamlScalar(match[2])) : parseYamlScalar(match[2]); + } + return metadata; +} + +function normalizeVersion(value) { + const text = String(value || '').trim(); + return /^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$/.test(text) ? text : ''; +} + +function compareVersions(left, right) { + const normalizedLeft = normalizeVersion(left); + const normalizedRight = normalizeVersion(right); + if (!normalizedLeft || !normalizedRight) throw new Error('Cannot compare invalid update versions'); + const leftParts = normalizedLeft.split('.').map(Number); + const rightParts = normalizedRight.split('.').map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const delta = (leftParts[index] || 0) - (rightParts[index] || 0); + if (delta !== 0) return Math.sign(delta); + } + return 0; +} + +function isSha512Base64(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(value)) return false; + try { + return Buffer.from(value, 'base64').length === 64; + } catch { + return false; + } +} + +function validateProductionRelease(metadata, expected) { + const version = normalizeVersion(metadata?.version); + if (!version || version !== normalizeVersion(expected.expectedVersion)) throw new Error('Production release version did not match the pinned version'); + if (expected.latestTag !== `v${version}`) throw new Error('Production release tag did not match the pinned version'); + const artifactName = String(metadata?.path || ''); + if (!artifactName || artifactName !== path.posix.basename(artifactName) || artifactName !== path.win32.basename(artifactName) || !artifactName.toLowerCase().endsWith('.exe')) { + throw new Error('Production release artifact path was unsafe'); + } + const file = Array.isArray(metadata?.files) ? metadata.files.find((entry) => entry?.url === artifactName) : undefined; + if (!file || !Number.isSafeInteger(file.size) || file.size < 1024 * 1024) throw new Error('Production release artifact metadata was incomplete'); + if (metadata.sha512 !== expected.expectedSha512 || file.sha512 !== expected.expectedSha512 || !isSha512Base64(expected.expectedSha512)) { + throw new Error('Production release SHA-512 did not match the pinned digest'); + } + const feedUrl = `${PRODUCTION_RELEASE_DOWNLOAD_BASE}/${encodeURIComponent(expected.latestTag)}/`; + return { artifactName, artifactSize: file.size, feedUrl, version }; +} + +async function sha512File(filePath) { + return await new Promise((resolve, reject) => { + const hash = nodeCrypto.createHash('sha512'); + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('error', reject); + stream.once('end', () => resolve(hash.digest('base64'))); + }); +} + +async function validateDownloadedReleaseArtifact(artifactPath, expected) { + const resolvedPath = path.resolve(artifactPath); + const stat = fs.lstatSync(resolvedPath); + if (!stat.isFile() || stat.isSymbolicLink() || path.basename(resolvedPath) !== expected.artifactName) { + throw new Error('Downloaded updater artifact was not the expected regular file'); + } + if (stat.size !== expected.artifactSize) throw new Error('Downloaded updater artifact size did not match latest.yml'); + const header = Buffer.alloc(2); + const handle = fs.openSync(resolvedPath, 'r'); + try { + fs.readSync(handle, header, 0, header.length, 0); + } finally { + fs.closeSync(handle); + } + if (header[0] !== 0x4d || header[1] !== 0x5a) throw new Error('Downloaded updater artifact was not a Windows executable'); + const digest = await sha512File(resolvedPath); + if (digest !== expected.expectedSha512) throw new Error('Downloaded updater artifact SHA-512 did not match latest.yml'); + return { bytes: stat.size, sha512Verified: true }; +} + +function validateUpdateCacheRecord(record, expected) { + const fileName = typeof record?.fileName === 'string' ? record.fileName : ''; + if (!fileName || fileName !== path.basename(fileName) || fileName !== path.win32.basename(fileName) || fileName !== expected.artifactName) { + throw new Error('Updater cache file name did not match the pinned release artifact'); + } + if (record.sha512 !== expected.expectedSha512 || !isSha512Base64(record.sha512)) { + throw new Error('Updater cache SHA-512 did not match the pinned release artifact'); + } + return { fileName, sha512Verified: true }; +} + +function assertOwnedPath(targetPath, ownerPath) { + const resolvedTarget = path.resolve(targetPath); + const resolvedOwner = path.resolve(ownerPath); + const relative = path.relative(resolvedOwner, resolvedTarget); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Refusing access outside the owned temporary root: ${resolvedTarget}`); + } + return resolvedTarget; +} + +function sanitizeChildEnvironment(environment, overrides = {}) { + const result = {}; + const allowedNames = new Set([ + 'ALLUSERSPROFILE', + 'APPDATA', + 'COMMONPROGRAMFILES', + 'COMMONPROGRAMFILES(X86)', + 'COMMONPROGRAMW6432', + 'COMSPEC', + 'LANG', + 'LC_ALL', + 'LOCALAPPDATA', + 'NUMBER_OF_PROCESSORS', + 'OS', + 'PATH', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'PROCESSOR_IDENTIFIER', + 'PROCESSOR_LEVEL', + 'PROCESSOR_REVISION', + 'PROGRAMDATA', + 'PROGRAMFILES', + 'PROGRAMFILES(X86)', + 'PROGRAMW6432', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'TMP', + 'TZ', + 'USERDOMAIN', + 'USERDOMAIN_ROAMINGPROFILE', + 'USERNAME', + 'USERPROFILE', + 'WINDIR' + ]); + for (const [name, value] of Object.entries(environment)) { + if (typeof value !== 'string') continue; + const normalizedName = name.toUpperCase(); + if (!allowedNames.has(normalizedName)) continue; + result[normalizedName] = value; + } + for (const [name, value] of Object.entries(overrides)) { + if (typeof value === 'string') result[name.toUpperCase()] = value; + } + return result; +} + +module.exports = { + LIVE_OPT_IN, + PRODUCTION_RELEASE_DOWNLOAD_BASE, + assertOwnedPath, + buildStreamlinkArguments, + compareVersions, + isSha512Base64, + normalizeVersion, + parseGateMode, + parseLatestYaml, + readLiveConfiguration, + redactDiagnostic, + sanitizeChildEnvironment, + validateMediaProbe, + validateDownloadedReleaseArtifact, + validateUpdateCacheRecord, + validateProductionRelease, + validateTwitchToken +}; diff --git a/scripts/smoke-test-live-integration.js b/scripts/smoke-test-live-integration.js new file mode 100644 index 0000000..c3cfa1a --- /dev/null +++ b/scripts/smoke-test-live-integration.js @@ -0,0 +1,743 @@ +const nodeCrypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { Readable, Transform } = require('node:stream'); +const { pipeline } = require('node:stream/promises'); +const { spawn, spawnSync } = require('node:child_process'); +const { _electron: electron } = require('playwright'); + +const { + PRODUCTION_RELEASE_DOWNLOAD_BASE, + assertOwnedPath, + buildStreamlinkArguments, + compareVersions, + parseGateMode, + parseLatestYaml, + readLiveConfiguration, + redactDiagnostic, + sanitizeChildEnvironment, + validateDownloadedReleaseArtifact, + validateMediaProbe, + validateProductionRelease, + validateTwitchToken, + validateUpdateCacheRecord +} = require('./smoke-test-live-integration-contract'); + +const root = path.resolve(__dirname, '..'); +const GITHUB_LATEST_API = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'; +const GITHUB_COMMIT_API_BASE = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/commits'; +const GITHUB_RELEASE_BASE = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'; +const TWITCH_VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate'; +const MAX_SOURCE_INSTALLER_BYTES = 256 * 1024 * 1024; +const ELECTRON_CLOSE_TIMEOUT_MS = 15000; +const PACKAGED_VERSION_TIMEOUT_MS = 30000; +const UPDATE_CHECK_TIMEOUT_MS = 120000; +const UPDATE_DOWNLOAD_TIMEOUT_MS = 8 * 60 * 1000; + +function createOwnedRoot(prefix) { + const base = process.env.RUNNER_TEMP && path.isAbsolute(process.env.RUNNER_TEMP) + ? process.env.RUNNER_TEMP + : os.tmpdir(); + if (!fs.statSync(base).isDirectory()) throw new Error(`Temporary base directory is unavailable: ${base}`); + const ownedRoot = fs.mkdtempSync(path.join(base, prefix)); + assertOwnedPath(ownedRoot, base); + return { base, ownedRoot }; +} + +async function removeOwnedRoot(ownedRoot, base) { + assertOwnedPath(ownedRoot, base); + await fs.promises.rm(ownedRoot, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 }); +} + +async function runWithOwnedRoot(prefix, operation) { + const context = createOwnedRoot(prefix); + try { + return await operation(context); + } finally { + await removeOwnedRoot(context.ownedRoot, context.base); + } +} + +async function runBoundedOperation(label, timeoutMs, operation) { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`${label} timeout must be a positive integer`); + let timer; + try { + return await Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }) + ]); + } finally { + clearTimeout(timer); + } +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) { + return await fetch(url, { ...options, redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) }); +} + +async function readBoundedText(response, maximumBytes = 256 * 1024) { + const length = Number(response.headers.get('content-length')); + if (Number.isFinite(length) && length > maximumBytes) throw new Error(`HTTP response exceeded ${maximumBytes} bytes`); + if (!response.body) return ''; + const chunks = []; + let totalBytes = 0; + for await (const chunk of Readable.fromWeb(response.body)) { + totalBytes += chunk.length; + if (totalBytes > maximumBytes) throw new Error(`HTTP response exceeded ${maximumBytes} bytes`); + chunks.push(chunk); + } + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} + +async function fetchJson(url, options = {}, timeoutMs = 30000) { + const response = await fetchWithTimeout(url, options, timeoutMs); + const text = await readBoundedText(response); + if (!response.ok) throw new Error(`HTTP ${response.status} from ${new URL(url).hostname}`); + try { + return JSON.parse(text); + } catch { + throw new Error(`Invalid JSON from ${new URL(url).hostname}`); + } +} + +function loadBuiltTwitchProduct() { + const product = require(path.join(root, 'dist', 'main', 'twitch')); + const requiredExports = [ + 'TwitchAppTokenService', + 'createTwitchProviderRefreshService', + 'requestPublicTwitchVodsByLogin', + 'requestTwitchAppAccessToken', + 'requestTwitchHelixUsers', + 'requestTwitchHelixVideos' + ]; + if (requiredExports.some((name) => typeof product[name] !== 'function')) { + throw new Error('Built Twitch product paths are unavailable; run npm run build first'); + } + return product; +} + +async function requestProductTwitchToken(credentials, requestJson = fetchJson) { + const product = loadBuiltTwitchProduct(); + let tokenPayload; + const client = { + async post(url, data, config) { + if (data !== null || !config || typeof config.timeout !== 'number') throw new Error('Built Twitch token request contract was invalid'); + const requestUrl = new URL(url); + for (const [name, value] of Object.entries(config.params || {})) requestUrl.searchParams.set(name, String(value)); + tokenPayload = await requestJson(requestUrl, { method: 'POST' }, config.timeout); + return { data: tokenPayload }; + } + }; + const service = new product.TwitchAppTokenService( + (requestCredentials) => product.requestTwitchAppAccessToken(client, requestCredentials, 30000) + ); + const accessToken = await service.ensure(credentials); + if (!accessToken) throw new Error('Built Twitch token product path rejected the provider response'); + return { accessToken, tokenPayload }; +} + +function createProductTwitchHttpClient(requestJson = fetchJson) { + return { + async get(url, config) { + const requestUrl = new URL(url); + for (const [name, value] of Object.entries(config.params || {})) requestUrl.searchParams.set(name, String(value)); + return { data: await requestJson(requestUrl, { headers: config.headers }, config.timeout) }; + }, + async post(url, body, config) { + return { + data: await requestJson(url, { + body: JSON.stringify(body), + headers: config.headers, + method: 'POST' + }, config.timeout) + }; + } + }; +} + +async function verifyProductTwitchProviderFallbacks(configuration, accessToken, client = createProductTwitchHttpClient()) { + const product = loadBuiltTwitchProduct(); + const auth = { accessToken, clientId: configuration.clientId }; + const usersOutcome = await product.requestTwitchHelixUsers(client, configuration.login, auth, 30000); + if (usersOutcome.status !== 'success') throw new Error(`Built Twitch Helix user path returned ${usersOutcome.status}`); + const user = usersOutcome.value.find((entry) => entry.login.toLowerCase() === configuration.login); + if (!user) throw new Error('Built Twitch Helix user path did not bind the requested login'); + + let phase = 'public'; + const service = product.createTwitchProviderRefreshService({ + maxLastGoodEntries: 1, + refreshToken: async () => false, + requestHelix: async () => phase === 'helix' + ? await product.requestTwitchHelixVideos(client, user.id, auth, 30000, 50) + : { status: 'unavailable' }, + requestPublic: async () => phase === 'public' + ? await product.requestPublicTwitchVodsByLogin(client, configuration.login, 100, 30000, 3) + : { status: 'unavailable' } + }); + const key = `vod:${configuration.vodId}`; + const publicRefresh = await service.refresh(key); + if (publicRefresh.source !== 'public') throw new Error(`Built Twitch public GQL path returned ${publicRefresh.source}`); + const publicVod = publicRefresh.value?.find((entry) => entry.id === configuration.vodId); + if (!publicVod || String(publicVod.user_login || '').toLowerCase() !== configuration.login) { + throw new Error('Built Twitch public GQL path did not bind the requested VOD to the broadcaster'); + } + phase = 'helix'; + const helixRefresh = await service.refresh(key); + if (helixRefresh.source !== 'helix') throw new Error(`Built Twitch Helix video path returned ${helixRefresh.source}`); + const helixVod = helixRefresh.value?.find((entry) => entry.id === configuration.vodId); + if (!helixVod || String(helixVod.user_login || '').toLowerCase() !== configuration.login) { + throw new Error('Built Twitch Helix video path did not bind the requested VOD to the broadcaster'); + } + phase = 'offline'; + const lastGoodRefresh = await service.refresh(key); + if (lastGoodRefresh.source !== 'last-good' || !lastGoodRefresh.stale || lastGoodRefresh.value !== helixRefresh.value) { + throw new Error('Built Twitch provider service did not restore last-good data while both providers were unavailable'); + } + return { + helix: { + duration: helixVod.duration, + source: helixRefresh.source, + userId: user.id, + vodId: helixVod.id + }, + lastGood: { + restoredFrom: 'helix', + restoredVodId: helixVod.id, + source: lastGoodRefresh.source, + stale: lastGoodRefresh.stale + }, + public: { + duration: publicVod.duration, + login: publicVod.user_login, + source: publicRefresh.source, + vodId: publicVod.id + } + }; +} + +function assertTrustedDownloadResponse(response) { + const finalUrl = new URL(response.url); + const allowedHost = finalUrl.hostname === 'github.com' + || finalUrl.hostname.endsWith('.githubusercontent.com'); + if (finalUrl.protocol !== 'https:' || !allowedHost) throw new Error(`Release download redirected to an untrusted host: ${finalUrl.hostname}`); +} + +async function downloadFile(url, destinationPath, maximumBytes) { + const response = await fetchWithTimeout(url, { + headers: { + Accept: 'application/octet-stream', + 'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate' + } + }, 240000); + if (!response.ok || !response.body) throw new Error(`Release download failed with HTTP ${response.status}`); + assertTrustedDownloadResponse(response); + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maximumBytes) throw new Error(`Release download exceeded ${maximumBytes} bytes`); + const partialPath = `${destinationPath}.partial`; + let downloadedBytes = 0; + const digest = nodeCrypto.createHash('sha256'); + const limiter = new Transform({ + transform(chunk, encoding, callback) { + downloadedBytes += chunk.length; + if (downloadedBytes > maximumBytes) { + callback(new Error(`Release download exceeded ${maximumBytes} bytes`)); + return; + } + digest.update(chunk); + callback(null, chunk); + } + }); + try { + await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(partialPath, { flags: 'wx' })); + fs.renameSync(partialPath, destinationPath); + return { bytes: downloadedBytes, sha256: digest.digest('hex') }; + } finally { + fs.rmSync(partialPath, { force: true }); + } +} + +function runProcess(command, argumentsList, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, argumentsList, { + cwd: options.cwd, + env: options.env || sanitizeChildEnvironment(process.env), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }); + let stdout = ''; + let stderr = ''; + let finished = false; + const capture = (current, chunk) => `${current}${chunk}`.slice(-65536); + child.stdout?.on('data', (chunk) => { stdout = capture(stdout, chunk); }); + child.stderr?.on('data', (chunk) => { stderr = capture(stderr, chunk); }); + const timer = setTimeout(() => { + if (finished) return; + finished = true; + terminateProcessTree(child); + reject(new Error(`${options.label || path.basename(command)} timed out`)); + }, options.timeoutMs || 120000); + child.once('error', (error) => { + finished = true; + clearTimeout(timer); + reject(error); + }); + child.once('exit', (code, signal) => { + if (finished) return; + finished = true; + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`${options.label || path.basename(command)} failed: ${JSON.stringify({ code, signal, stderr: stderr.trim(), stdout: stdout.trim() })}`)); + return; + } + resolve({ code, stderr: stderr.trim(), stdout: stdout.trim() }); + }); + }); +} + +function terminateProcessTree(child) { + if (!child || child.exitCode !== null || !child.pid) return; + if (process.platform === 'win32') { + spawnSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { + env: sanitizeChildEnvironment(process.env), + stdio: 'ignore', + windowsHide: true + }); + return; + } + child.kill('SIGKILL'); +} + +function findExecutable(explicitPath, commandName) { + if (explicitPath) { + const resolved = path.resolve(explicitPath); + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new Error(`${commandName} executable is not a file: ${resolved}`); + return resolved; + } + const locator = process.platform === 'win32' ? 'where.exe' : 'which'; + const located = spawnSync(locator, [commandName], { + encoding: 'utf8', + env: sanitizeChildEnvironment(process.env), + windowsHide: true + }); + if (located.status !== 0) throw new Error(`${commandName} is unavailable; set its TWITCH_VOD_MANAGER_LIVE_*_PATH variable`); + const candidate = String(located.stdout || '').split(/\r?\n/).map((entry) => entry.trim()).find(Boolean); + if (!candidate || !fs.statSync(candidate).isFile()) throw new Error(`${commandName} could not be resolved to a regular file`); + return candidate; +} + +async function runTwitchGate(configuration) { + return await runWithOwnedRoot('tvm-live-twitch-', async ({ ownedRoot }) => { + let accessToken = ''; + try { + const toolProfile = path.join(ownedRoot, 'profile'); + const toolEnvironment = sanitizeChildEnvironment(process.env, { + APPDATA: path.join(toolProfile, 'appdata'), + LOCALAPPDATA: path.join(toolProfile, 'localappdata'), + TEMP: path.join(toolProfile, 'temp'), + TMP: path.join(toolProfile, 'temp'), + USERPROFILE: toolProfile + }); + for (const directory of [toolEnvironment.APPDATA, toolEnvironment.LOCALAPPDATA, toolEnvironment.TEMP, toolEnvironment.USERPROFILE]) { + fs.mkdirSync(directory, { recursive: true }); + } + const streamlinkPath = findExecutable(configuration.streamlinkPath, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink'); + const ffprobePath = findExecutable(configuration.ffprobePath, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'); + const streamlinkVersion = await runProcess(streamlinkPath, ['--version'], { env: toolEnvironment, label: 'Streamlink version check', timeoutMs: 30000 }); + const ffprobeVersion = await runProcess(ffprobePath, ['-version'], { env: toolEnvironment, label: 'ffprobe version check', timeoutMs: 30000 }); + + const productToken = await requestProductTwitchToken({ + clientId: configuration.clientId, + clientSecret: configuration.clientSecret + }); + accessToken = productToken.accessToken; + const validationPayload = await fetchJson(TWITCH_VALIDATE_URL, { + headers: { Authorization: `OAuth ${accessToken}` } + }); + const token = validateTwitchToken(productToken.tokenPayload, validationPayload, configuration.clientId); + const providers = await verifyProductTwitchProviderFallbacks(configuration, accessToken); + + const samplePath = path.join(ownedRoot, `vod-${configuration.vodId}-sample.ts`); + const streamlinkArguments = buildStreamlinkArguments(configuration.vodId, samplePath, 8); + await runProcess(streamlinkPath, streamlinkArguments, { env: toolEnvironment, label: 'Bounded Streamlink VOD download', timeoutMs: 120000 }); + const stat = fs.statSync(samplePath); + const probe = await runProcess(ffprobePath, [ + '-v', + 'error', + '-show_entries', + 'format=duration,size', + '-show_entries', + 'stream=codec_type,codec_name', + '-of', + 'json', + samplePath + ], { env: toolEnvironment, label: 'ffprobe sample validation', timeoutMs: 30000 }); + const media = validateMediaProbe(JSON.parse(probe.stdout), stat.size); + + return { + helix: providers.helix, + lastGood: providers.lastGood, + oauth: { expiresInSeconds: token.expiresInSeconds, validated: true }, + public: providers.public, + streamlink: { + bytes: media.bytes, + codec: media.codec, + durationSeconds: media.durationSeconds, + ffprobeVersion: ffprobeVersion.stdout.split(/\r?\n/, 1)[0], + streamlinkVersion: streamlinkVersion.stdout.split(/\r?\n/, 1)[0] + } + }; + } catch (error) { + throw new Error(redactDiagnostic(error, [configuration.clientId, configuration.clientSecret, accessToken]), { cause: error }); + } + }); +} + +function findFileRecursive(directory, fileName) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) return entryPath; + if (entry.isDirectory()) { + const nested = findFileRecursive(entryPath, fileName); + if (nested) return nested; + } + } + return ''; +} + +async function prepareSourcePackagedApp(configuration, ownedRoot) { + if (configuration.packagedAppPath) { + const executablePath = path.resolve(configuration.packagedAppPath); + if (!fs.statSync(executablePath).isFile()) throw new Error(`Packaged source executable is not a file: ${executablePath}`); + return { executablePath, sourceInstallerVerified: false }; + } + + const sourceTag = `v${configuration.sourceVersion}`; + const installerName = `Twitch-VOD-Manager-Setup-${configuration.sourceVersion}.exe`; + const installerUrl = `${GITHUB_RELEASE_BASE}/${sourceTag}/${installerName}`; + const installerPath = path.join(ownedRoot, installerName); + const downloaded = await downloadFile(installerUrl, installerPath, MAX_SOURCE_INSTALLER_BYTES); + if (downloaded.sha256 !== configuration.sourceSha256) throw new Error('Public source installer SHA-256 did not match the pinned digest'); + + const sevenZipPath = path.join(root, 'node_modules', 'electron-winstaller', 'vendor', '7z.exe'); + if (!fs.existsSync(sevenZipPath)) throw new Error('Bundled 7z extractor is unavailable; run npm ci first'); + const installerExtraction = path.join(ownedRoot, 'source-installer'); + const appExtraction = path.join(ownedRoot, 'source-app'); + fs.mkdirSync(installerExtraction); + fs.mkdirSync(appExtraction); + await runProcess(sevenZipPath, ['x', '-y', `-o${installerExtraction}`, installerPath, '$PLUGINSDIR\\app-64.7z'], { + label: 'Source installer extraction', + timeoutMs: 120000 + }); + const appArchivePath = findFileRecursive(installerExtraction, 'app-64.7z'); + if (!appArchivePath) throw new Error('Public source installer did not contain app-64.7z'); + assertOwnedPath(appArchivePath, ownedRoot); + await runProcess(sevenZipPath, ['x', '-y', `-o${appExtraction}`, appArchivePath], { + label: 'Packaged source app extraction', + timeoutMs: 120000 + }); + const executablePath = findFileRecursive(appExtraction, 'Twitch VOD Manager.exe'); + if (!executablePath) throw new Error('Extracted public source app did not contain Twitch VOD Manager.exe'); + assertOwnedPath(executablePath, ownedRoot); + return { executablePath, sourceInstallerVerified: true }; +} + +async function requestProductionLatestYaml(url) { + const response = await fetchWithTimeout(url, { + headers: { 'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate' } + }); + if (!response.ok) throw new Error(`Production latest.yml failed with HTTP ${response.status}`); + assertTrustedDownloadResponse(response); + return await readBoundedText(response, 128 * 1024); +} + +async function inspectProductionRelease(configuration, dependencies = {}) { + const requestJson = dependencies.requestJson || fetchJson; + const requestLatestYaml = dependencies.requestLatestYaml || requestProductionLatestYaml; + const latest = await requestJson(GITHUB_LATEST_API, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate' + } + }); + const expectedTag = `v${configuration.expectedVersion}`; + if (latest?.tag_name !== expectedTag || latest?.draft === true || latest?.prerelease === true) { + throw new Error(`GitHub latest release was not the pinned public ${expectedTag}`); + } + const commit = await requestJson(`${GITHUB_COMMIT_API_BASE}/${encodeURIComponent(expectedTag)}`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate' + } + }); + const commitSha = typeof commit?.sha === 'string' ? commit.sha.trim().toLowerCase() : ''; + if (!/^[a-f0-9]{40}$/.test(commitSha) || commitSha !== configuration.expectedCommitSha) { + throw new Error('Public release tag commit did not match the pinned workflow commit'); + } + const feedUrl = `${PRODUCTION_RELEASE_DOWNLOAD_BASE}/${expectedTag}/latest.yml`; + const metadata = parseLatestYaml(await requestLatestYaml(feedUrl)); + const release = validateProductionRelease(metadata, { + expectedSha512: configuration.expectedSha512, + expectedVersion: configuration.expectedVersion, + latestTag: expectedTag + }); + const assets = Array.isArray(latest.assets) ? latest.assets : []; + const installerAsset = assets.find((asset) => asset?.name === release.artifactName); + const feedAsset = assets.find((asset) => asset?.name === 'latest.yml'); + if (!installerAsset || installerAsset.size !== release.artifactSize || !feedAsset) { + throw new Error('GitHub latest release assets did not match latest.yml'); + } + return { ...release, commitSha }; +} + +function createUpdaterEnvironment(ownedRoot) { + const directories = { + appData: path.join(ownedRoot, 'appdata'), + localAppData: path.join(ownedRoot, 'localappdata'), + programData: path.join(ownedRoot, 'programdata'), + temp: path.join(ownedRoot, 'temp'), + userData: path.join(ownedRoot, 'userdata'), + userProfile: path.join(ownedRoot, 'profile') + }; + for (const directory of [...Object.values(directories), path.join(directories.userProfile, 'Desktop')]) { + fs.mkdirSync(directory, { recursive: true }); + } + const environment = sanitizeChildEnvironment(process.env, { + APPDATA: directories.appData, + LOCALAPPDATA: directories.localAppData, + PROGRAMDATA: directories.programData, + TEMP: directories.temp, + TMP: directories.temp, + USERPROFILE: directories.userProfile + }); + return { directories, environment }; +} + +async function waitForHardExit(child, timeoutMs = 15000) { + if (!child || child.exitCode !== null) return; + await new Promise((resolve, reject) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error('Packaged app did not exit after forced termination')); + }, timeoutMs); + child.once('exit', finish); + if (child.exitCode !== null) finish(); + }); +} + +async function closeElectronApp(electronApp, electronProcess, options = {}) { + if (!electronProcess || electronProcess.exitCode !== null) return; + const timeoutMs = options.timeoutMs || ELECTRON_CLOSE_TIMEOUT_MS; + const terminate = options.terminate || terminateProcessTree; + const waitForExit = options.waitForExit || waitForHardExit; + if (electronApp) { + try { + await runBoundedOperation('Packaged app close', timeoutMs, async () => await electronApp.close()); + } catch {} + } + if (electronProcess.exitCode === null) { + terminate(electronProcess); + await waitForExit(electronProcess, timeoutMs); + } +} + +async function runWithElectronAppCleanup(lifecycle, operation, closeOptions = {}) { + try { + return await operation(); + } finally { + await closeElectronApp(lifecycle.electronApp, lifecycle.electronProcess, closeOptions); + } +} + +async function startUpdaterDownload(window) { + await window.locator('#workspaceUpdateButton').hover(); + const downloadButton = window.locator('#updateButton'); + await downloadButton.waitFor({ state: 'visible' }); + await downloadButton.click(); +} + +async function getPackagedVersion(window, timeoutMs = PACKAGED_VERSION_TIMEOUT_MS) { + return await runBoundedOperation( + 'Packaged app version query', + timeoutMs, + async () => await window.evaluate(() => window.api.getVersion()) + ); +} + +async function checkPackagedUpdate(window, timeoutMs = UPDATE_CHECK_TIMEOUT_MS) { + return await runBoundedOperation( + 'Packaged app update check', + timeoutMs, + async () => await window.evaluate(() => window.api.checkUpdate()) + ); +} + +function createUpdaterDownloadReadySummary({ artifact, downloadedVersion, progressEvents, release, source, sourceVersion }) { + return { + artifact: { + bytes: artifact.bytes, + name: release.artifactName, + sha512Verified: artifact.sha512Verified + }, + feed: release.feedUrl, + scope: 'download-ready', + source: { + installerDigestVerified: source.sourceInstallerVerified, + version: sourceVersion + }, + target: { + commitSha: release.commitSha, + downloadReady: true, + progressEvents, + version: downloadedVersion + } + }; +} + +async function runUpdaterGate(configuration) { + if (process.platform !== 'win32') throw new Error('The packaged updater live gate requires Windows'); + return await runWithOwnedRoot('tvm-live-updater-', async ({ ownedRoot }) => { + const lifecycle = { electronApp: null, electronProcess: null }; + return await runWithElectronAppCleanup(lifecycle, async () => { + const release = await inspectProductionRelease(configuration); + const source = await prepareSourcePackagedApp(configuration, ownedRoot); + const { directories, environment } = createUpdaterEnvironment(ownedRoot); + lifecycle.electronApp = await electron.launch({ + executablePath: source.executablePath, + args: [`--user-data-dir=${directories.userData}`], + env: environment, + timeout: 60000 + }); + lifecycle.electronProcess = lifecycle.electronApp.process(); + const window = await lifecycle.electronApp.firstWindow({ timeout: 60000 }); + await window.waitForFunction(() => Boolean(window.api && document.getElementById('updateBanner')), null, { timeout: 60000 }); + await window.evaluate(() => { + window.__tvmLiveUpdaterGate = { + available: null, + downloaded: null, + errors: [], + maximumProgress: 0, + progressEvents: 0 + }; + window.api.onUpdateAvailable((info) => { window.__tvmLiveUpdaterGate.available = info; }); + window.api.onUpdateDownloaded((info) => { window.__tvmLiveUpdaterGate.downloaded = info; }); + window.api.onUpdateDownloadProgress((progress) => { + window.__tvmLiveUpdaterGate.progressEvents += 1; + window.__tvmLiveUpdaterGate.maximumProgress = Math.max(window.__tvmLiveUpdaterGate.maximumProgress, Number(progress.percent) || 0); + }); + window.api.onUpdateError((error) => { window.__tvmLiveUpdaterGate.errors.push(String(error?.message || 'update-error')); }); + }); + const sourceVersion = await getPackagedVersion(window); + if (sourceVersion !== configuration.sourceVersion || compareVersions(sourceVersion, configuration.expectedVersion) >= 0) { + throw new Error(`Packaged source app version ${sourceVersion} was not the pinned older ${configuration.sourceVersion}`); + } + + const checkResult = await checkPackagedUpdate(window); + if (!checkResult || checkResult.error) throw new Error('Packaged app rejected the production update check'); + await window.waitForFunction((version) => { + const state = window.__tvmLiveUpdaterGate; + return state.errors.length > 0 || state.available?.version === version; + }, configuration.expectedVersion, { timeout: 120000 }); + let state = await window.evaluate(() => ({ + events: window.__tvmLiveUpdaterGate, + ui: document.getElementById('updateBanner')?.dataset.updateState + })); + if (state.events.errors.length > 0) throw new Error(`Packaged updater emitted an error before download: ${state.events.errors.join('; ')}`); + if (state.ui !== 'available') throw new Error(`Packaged updater UI did not reach available state: ${state.ui || 'missing'}`); + + await startUpdaterDownload(window); + await window.waitForFunction((version) => { + const state = window.__tvmLiveUpdaterGate; + const uiState = document.getElementById('updateBanner')?.dataset.updateState; + return state.errors.length > 0 || (state.downloaded?.version === version && uiState === 'ready'); + }, configuration.expectedVersion, { timeout: UPDATE_DOWNLOAD_TIMEOUT_MS }); + state = await window.evaluate(() => ({ + events: window.__tvmLiveUpdaterGate, + installButtonDisabled: document.getElementById('updateButton')?.disabled, + progressValue: document.getElementById('updateProgressGauge')?.getAttribute('aria-valuenow'), + ui: document.getElementById('updateBanner')?.dataset.updateState + })); + if (state.events.errors.length > 0) throw new Error(`Packaged updater emitted an error during download: ${state.events.errors.join('; ')}`); + if (state.ui !== 'ready' || state.progressValue !== '100' || state.installButtonDisabled !== false) { + throw new Error(`Packaged updater UI did not reach install-ready state: ${JSON.stringify({ disabled: state.installButtonDisabled, progress: state.progressValue, ui: state.ui })}`); + } + if (state.events.progressEvents < 1 || state.events.maximumProgress <= 0) { + throw new Error('Packaged updater did not emit real download progress from the isolated cache'); + } + + const pendingDirectory = path.join(directories.localAppData, 'twitch-vod-manager-updater', 'pending'); + assertOwnedPath(pendingDirectory, ownedRoot); + const updateInfoPath = path.join(pendingDirectory, 'update-info.json'); + const updateInfo = JSON.parse(fs.readFileSync(updateInfoPath, 'utf8')); + const cacheRecord = validateUpdateCacheRecord(updateInfo, { + artifactName: release.artifactName, + expectedSha512: configuration.expectedSha512 + }); + const artifactPath = path.join(pendingDirectory, cacheRecord.fileName); + assertOwnedPath(artifactPath, ownedRoot); + const artifact = await validateDownloadedReleaseArtifact(artifactPath, { + artifactName: release.artifactName, + artifactSize: release.artifactSize, + expectedSha512: configuration.expectedSha512 + }); + + return createUpdaterDownloadReadySummary({ + artifact, + downloadedVersion: state.events.downloaded.version, + progressEvents: state.events.progressEvents, + release, + source, + sourceVersion + }); + }); + }); +} + +async function main() { + const mode = parseGateMode(process.argv.slice(2)); + const configuration = readLiveConfiguration(mode); + const summary = { mode, updater: null, twitch: null }; + if (mode === 'all' || mode === 'twitch') summary.twitch = await runTwitchGate(configuration.twitch); + if (mode === 'all' || mode === 'updater') summary.updater = await runUpdaterGate(configuration.updater); + console.log(JSON.stringify({ failures: [], summary }, null, 2)); +} + +if (require.main === module) { + main().catch((error) => { + const secrets = Object.entries(process.env) + .filter(([name]) => name.startsWith('TWITCH_VOD_MANAGER_LIVE_') && /CLIENT_ID|SECRET|TOKEN/i.test(name)) + .map(([, value]) => value); + console.error(redactDiagnostic(error, secrets)); + process.exitCode = 1; + }); +} + +module.exports = { + checkPackagedUpdate, + closeElectronApp, + createUpdaterEnvironment, + createUpdaterDownloadReadySummary, + downloadFile, + findExecutable, + getPackagedVersion, + inspectProductionRelease, + prepareSourcePackagedApp, + requestProductTwitchToken, + runWithElectronAppCleanup, + runWithOwnedRoot, + runTwitchGate, + runUpdaterGate, + startUpdaterDownload, + verifyProductTwitchProviderFallbacks +}; diff --git a/scripts/smoke-test-live-integration.test.js b/scripts/smoke-test-live-integration.test.js new file mode 100644 index 0000000..33311b1 --- /dev/null +++ b/scripts/smoke-test-live-integration.test.js @@ -0,0 +1,704 @@ +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + assertOwnedPath, + buildStreamlinkArguments, + compareVersions, + parseGateMode, + parseLatestYaml, + readLiveConfiguration, + redactDiagnostic, + sanitizeChildEnvironment, + validateMediaProbe, + validateDownloadedReleaseArtifact, + validateProductionRelease, + validateUpdateCacheRecord, + validateTwitchToken +} = require('./smoke-test-live-integration-contract'); +const { + checkPackagedUpdate, + closeElectronApp, + createUpdaterDownloadReadySummary, + getPackagedVersion, + inspectProductionRelease, + requestProductTwitchToken, + runWithElectronAppCleanup, + runWithOwnedRoot, + startUpdaterDownload, + verifyProductTwitchProviderFallbacks +} = require('./smoke-test-live-integration'); +const PACKAGE_VERSION = require('../package.json').version; + +const TWITCH_ENVIRONMENT = { + TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1', + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: 'client-id-value', + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: 'client-secret-value', + TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: 'example_channel', + TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: '1234567890' +}; + +const UPDATE_SHA512 = Buffer.alloc(64, 7).toString('base64'); +const UPDATE_COMMIT_SHA = 'b'.repeat(40); + +function releaseYaml(version = PACKAGE_VERSION) { + return [ + `version: ${version}`, + 'files:', + ` - url: Twitch-VOD-Manager-Setup-${version}.exe`, + ` sha512: ${UPDATE_SHA512}`, + ' size: 120000000', + `path: Twitch-VOD-Manager-Setup-${version}.exe`, + `sha512: ${UPDATE_SHA512}`, + "releaseDate: '2026-08-13T10:00:00.000Z'" + ].join('\n'); +} + +function releaseInspectionDependencies(commitSha = UPDATE_COMMIT_SHA) { + const calls = []; + return { + calls, + requestJson: async (url) => { + calls.push(String(url)); + if (String(url).endsWith('/releases/latest')) { + return { + assets: [ + { name: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`, size: 120000000 }, + { name: 'latest.yml', size: 1024 } + ], + draft: false, + prerelease: false, + tag_name: `v${PACKAGE_VERSION}` + }; + } + if (String(url).endsWith(`/commits/v${PACKAGE_VERSION}`)) return { sha: commitSha }; + throw new Error(`Unexpected JSON request: ${url}`); + }, + requestLatestYaml: async (url) => { + calls.push(String(url)); + return releaseYaml(); + } + }; +} + +function updaterEnvironment(overrides = {}) { + return { + GITHUB_REF: `refs/tags/v${PACKAGE_VERSION}`, + GITHUB_SHA: UPDATE_COMMIT_SHA, + TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1', + TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: '0.0.1', + TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: 'a'.repeat(64), + TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: UPDATE_COMMIT_SHA, + TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: PACKAGE_VERSION, + TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: UPDATE_SHA512, + ...overrides + }; +} + +test('refuses every live mode until the explicit opt-in is set', () => { + assert.throws( + () => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_INTEGRATION: undefined }), + /TWITCH_VOD_MANAGER_LIVE_INTEGRATION=1/ + ); + assert.throws( + () => readLiveConfiguration('updater', {}), + /TWITCH_VOD_MANAGER_LIVE_INTEGRATION=1/ + ); +}); + +test('keeps Twitch credentials scoped to the Twitch gate while updater requires explicit release pins', () => { + const twitch = readLiveConfiguration('twitch', TWITCH_ENVIRONMENT); + assert.equal(twitch.twitch.clientId, 'client-id-value'); + assert.equal(twitch.twitch.clientSecret, 'client-secret-value'); + assert.equal(twitch.twitch.login, 'example_channel'); + assert.equal(twitch.twitch.vodId, '1234567890'); + + assert.throws( + () => readLiveConfiguration('updater', { TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' }), + /TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION/ + ); + + const updater = readLiveConfiguration('updater', updaterEnvironment()); + assert.equal(updater.twitch, undefined); + assert.equal(updater.updater.sourceVersion, '0.0.1'); + assert.equal(updater.updater.sourceSha256, 'a'.repeat(64)); + assert.equal(updater.updater.expectedVersion, PACKAGE_VERSION); + assert.equal(updater.updater.expectedSha512, UPDATE_SHA512); + assert.equal(updater.updater.expectedCommitSha, UPDATE_COMMIT_SHA); + assert.equal(updater.updater.packagedAppPath, undefined); + + const override = readLiveConfiguration('updater', updaterEnvironment({ + TWITCH_VOD_MANAGER_LIVE_PACKAGED_APP_PATH: 'C:\\fixtures\\Twitch VOD Manager.exe', + })); + assert.equal(override.updater.packagedAppPath, 'C:\\fixtures\\Twitch VOD Manager.exe'); + assert.equal(override.updater.sourceVersion, '0.0.1'); + assert.equal(override.updater.expectedVersion, PACKAGE_VERSION); + assert.equal(override.updater.expectedSha512, UPDATE_SHA512); +}); + +test('rejects every missing updater pin and binds the target to package version and release tag', () => { + for (const name of [ + 'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION', + 'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION', + 'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512' + ]) { + assert.throws( + () => readLiveConfiguration('updater', updaterEnvironment({ [name]: undefined })), + (error) => error instanceof Error && error.message.includes(name) + ); + } + assert.throws( + () => readLiveConfiguration('updater', updaterEnvironment({ TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: '99.0.0', GITHUB_REF: 'refs/tags/v99.0.0' })), + /package version/ + ); + assert.throws( + () => readLiveConfiguration('updater', updaterEnvironment({ GITHUB_REF: 'refs/heads/main' })), + /release tag/ + ); + assert.throws( + () => readLiveConfiguration('updater', updaterEnvironment({ TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: 'abc' })), + /UPDATE_COMMIT_SHA/ + ); + assert.throws( + () => readLiveConfiguration('updater', updaterEnvironment({ GITHUB_SHA: 'c'.repeat(40) })), + /current workflow commit/ + ); + for (const version of ['1.0.18-alpha', '1.0.18+build', '01.0.18', '1.00.18', '1.0.18.0', '1234567890.0.0']) { + assert.throws( + () => readLiveConfiguration('updater', updaterEnvironment({ + GITHUB_REF: `refs/tags/v${version}`, + TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: version + })), + /numeric release version/ + ); + } +}); + +test('obtains the live credential through the built Twitch token product path', async () => { + const product = require('../dist/main/twitch'); + assert.equal(requestProductTwitchToken.length, 1); + assert.equal(typeof product.TwitchAppTokenService, 'function'); + assert.equal(typeof product.requestTwitchAppAccessToken, 'function'); + let requests = 0; + const request = async (url, options, timeoutMs) => { + requests += 1; + assert.equal(url.origin + url.pathname, 'https://id.twitch.tv/oauth2/token'); + assert.deepEqual(Object.fromEntries(url.searchParams), { + client_id: 'client-id-value', + client_secret: 'client-secret-value', + grant_type: 'client_credentials' + }); + assert.deepEqual(options, { method: 'POST' }); + assert.equal(timeoutMs, 30000); + return { access_token: 'live-product-token', expires_in: 3600, token_type: 'bearer' }; + }; + const token = await requestProductTwitchToken( + { clientId: 'client-id-value', clientSecret: 'client-secret-value' }, + request + ); + assert.deepEqual(token, { + accessToken: 'live-product-token', + tokenPayload: { access_token: 'live-product-token', expires_in: 3600, token_type: 'bearer' } + }); + assert.equal(requests, 1); +}); + +test('runs Helix, public GQL, and offline last-good through the built Twitch provider product paths', async () => { + const calls = []; + const client = { + async get(url, config) { + calls.push({ config, method: 'GET', url }); + assert.deepEqual(config.headers, { + 'Client-ID': 'client-id-value', + Authorization: 'Bearer live-product-token' + }); + assert.equal(config.timeout, 30000); + if (url === 'https://api.twitch.tv/helix/users') { + assert.deepEqual(config.params, { login: 'example_channel' }); + return { + data: { + data: [{ + broadcaster_type: 'partner', + description: 'Example broadcaster', + display_name: 'Example Channel', + id: '42', + login: 'example_channel', + profile_image_url: 'https://static-cdn.example.test/profile.png' + }] + } + }; + } + assert.equal(url, 'https://api.twitch.tv/helix/videos'); + assert.deepEqual(config.params, { first: 100, type: 'archive', user_id: '42' }); + return { + data: { + data: [{ + created_at: '2026-08-13T10:00:00Z', + duration: '2h3m4s', + id: '1234567890', + stream_id: 'stream-1', + thumbnail_url: 'https://static-cdn.example.test/vod.jpg', + title: 'A VOD', + url: 'https://www.twitch.tv/videos/1234567890', + user_login: 'example_channel', + view_count: 123 + }], + pagination: {} + } + }; + }, + async post(url, body, config) { + calls.push({ body, config, method: 'POST', url }); + if (config.timeout !== 30000) { + const error = new Error('Public product wrapper did not own its request signature'); + error.response = { status: 400 }; + throw error; + } + assert.equal(url, 'https://gql.twitch.tv/gql'); + assert.equal(typeof body.query, 'string'); + assert.deepEqual(body.variables, { first: 100, login: 'example_channel' }); + assert.equal(config.headers['Content-Type'], 'application/json'); + assert.equal(typeof config.headers['Client-ID'], 'string'); + assert.notEqual(config.headers['Client-ID'], 'client-id-value'); + return { + data: { + data: { + user: { + videos: { + edges: [{ + node: { + id: '1234567890', + lengthSeconds: 7384, + previewThumbnailURL: 'https://static-cdn.example.test/vod.jpg', + publishedAt: '2026-08-13T10:00:00Z', + title: 'A VOD', + viewCount: 123 + } + }] + } + } + } + } + }; + } + }; + const originalFetch = global.fetch; + global.fetch = async () => { throw new Error('provider contract bypassed its injected transport'); }; + try { + const result = await verifyProductTwitchProviderFallbacks({ + clientId: 'client-id-value', + login: 'example_channel', + vodId: '1234567890' + }, 'live-product-token', client); + assert.deepEqual(result, { + helix: { duration: '2h3m4s', source: 'helix', userId: '42', vodId: '1234567890' }, + lastGood: { restoredFrom: 'helix', restoredVodId: '1234567890', source: 'last-good', stale: true }, + public: { duration: '2h3m4s', login: 'example_channel', source: 'public', vodId: '1234567890' } + }); + assert.deepEqual(calls.map((call) => `${call.method} ${call.url}`), [ + 'GET https://api.twitch.tv/helix/users', + 'POST https://gql.twitch.tv/gql', + 'GET https://api.twitch.tv/helix/videos' + ]); + } finally { + global.fetch = originalFetch; + } +}); + +test('bounds the packaged version product call', async () => { + const window = { evaluate: async () => await new Promise(() => {}) }; + await assert.rejects(getPackagedVersion(window, 10), /version query timed out/i); +}); + +test('bounds the packaged update-check product call independently', async () => { + const window = { evaluate: async () => await new Promise(() => {}) }; + await assert.rejects(checkPackagedUpdate(window, 10), /update check timed out/i); +}); + +test('closes the packaged app gracefully without a hard kill', async () => { + const calls = []; + const process = { exitCode: null }; + const app = { + close: async () => { + calls.push('close'); + process.exitCode = 0; + } + }; + await closeElectronApp(app, process, { + terminate: () => calls.push('terminate'), + timeoutMs: 10, + waitForExit: async () => calls.push('wait') + }); + assert.deepEqual(calls, ['close']); +}); + +test('hard-kills the packaged app after graceful close times out', async () => { + const calls = []; + const process = { exitCode: null }; + const app = { close: async () => { calls.push('close'); return await new Promise(() => {}); } }; + await closeElectronApp(app, process, { + terminate: () => { calls.push('terminate'); process.exitCode = 1; }, + timeoutMs: 10, + waitForExit: async () => calls.push('wait') + }); + assert.deepEqual(calls, ['close', 'terminate', 'wait']); +}); + +test('hard-kills the packaged app after graceful close rejects', async () => { + const calls = []; + const process = { exitCode: null }; + const app = { close: async () => { calls.push('close'); throw new Error('close rejected'); } }; + await closeElectronApp(app, process, { + terminate: () => { calls.push('terminate'); process.exitCode = 1; }, + timeoutMs: 10, + waitForExit: async () => calls.push('wait') + }); + assert.deepEqual(calls, ['close', 'terminate', 'wait']); +}); + +test('closes the packaged app when the updater body fails', async () => { + const calls = []; + const process = { exitCode: null }; + const lifecycle = { + electronApp: { close: async () => { calls.push('close'); process.exitCode = 0; } }, + electronProcess: process + }; + await assert.rejects( + runWithElectronAppCleanup(lifecycle, async () => { throw new Error('updater body failed'); }, { timeoutMs: 10 }), + /updater body failed/ + ); + assert.deepEqual(calls, ['close']); +}); + +test('opens the update popover before clicking its download action', async () => { + const calls = []; + const locators = { + '#workspaceUpdateButton': { hover: async () => { calls.push('hover'); } }, + '#updateButton': { + waitFor: async (options) => { calls.push(`wait:${options.state}`); }, + click: async () => { calls.push('click'); } + } + }; + await startUpdaterDownload({ locator: (selector) => locators[selector] }); + assert.deepEqual(calls, ['hover', 'wait:visible', 'click']); +}); + +test('rejects malformed public fixture identities before making network requests', () => { + assert.throws( + () => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: 'https://twitch.tv/name' }), + /LIVE_TWITCH_LOGIN/ + ); + assert.throws( + () => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: '../123' }), + /LIVE_TWITCH_VOD_ID/ + ); +}); + +test('redacts credentials and access tokens from nested external errors', () => { + const diagnostic = redactDiagnostic( + new Error('request failed for client-id-value client-secret-value bearer-token-value'), + ['client-id-value', 'client-secret-value', 'bearer-token-value'] + ); + assert.equal(diagnostic.includes('client-id-value'), false); + assert.equal(diagnostic.includes('client-secret-value'), false); + assert.equal(diagnostic.includes('bearer-token-value'), false); + assert.match(diagnostic, /\[REDACTED\]/); +}); + +test('validates a real client-credentials token contract without exposing the token', () => { + const result = validateTwitchToken( + { access_token: 'bearer-token-value', expires_in: 3600, token_type: 'bearer' }, + { client_id: 'client-id-value', expires_in: 3590 }, + 'client-id-value' + ); + assert.deepEqual(result, { expiresInSeconds: 3590, tokenType: 'bearer' }); + assert.throws( + () => validateTwitchToken( + { access_token: 'bearer-token-value', expires_in: 3600, token_type: 'bearer' }, + { client_id: 'other-client', expires_in: 3590 }, + 'client-id-value' + ), + /client id/ + ); +}); + +test('builds a bounded lowest-quality Streamlink download command', () => { + const output = path.join('C:\\runner\\temp', 'sample.ts'); + assert.deepEqual(buildStreamlinkArguments('1234567890', output, 8), [ + '--no-config', + '--no-plugin-cache', + '--no-plugin-sideloading', + '--http-timeout', + '20', + '--stream-timeout', + '30', + '--stream-segment-attempts', + '2', + '--stream-segment-timeout', + '20', + '--stream-segmented-duration', + '8', + '--output', + output, + 'https://www.twitch.tv/videos/1234567890', + 'worst' + ]); + assert.throws(() => buildStreamlinkArguments('abc', output, 8), /VOD id/); + assert.throws(() => buildStreamlinkArguments('1234567890', output, 61), /duration/); +}); + +test('accepts only a bounded ffprobe-confirmed video artifact', () => { + const result = validateMediaProbe({ + format: { duration: '8.25', size: '1048576' }, + streams: [{ codec_type: 'video', codec_name: 'h264' }, { codec_type: 'audio', codec_name: 'aac' }] + }, 1048576); + assert.deepEqual(result, { bytes: 1048576, codec: 'h264', durationSeconds: 8.25 }); + assert.throws( + () => validateMediaProbe({ format: { duration: '0.4', size: '40' }, streams: [{ codec_type: 'audio', codec_name: 'aac' }] }, 40), + /video stream/ + ); + assert.throws( + () => validateMediaProbe({ format: { duration: '8', size: String(40 * 1024 * 1024) }, streams: [{ codec_type: 'video', codec_name: 'h264' }] }, 40 * 1024 * 1024), + /32 MiB/ + ); +}); + +test('parses and pins the production GitHub release feed metadata', () => { + const yaml = [ + 'version: 1.0.18', + 'files:', + ' - url: Twitch-VOD-Manager-Setup-1.0.18.exe', + ` sha512: ${UPDATE_SHA512}`, + ' size: 120000000', + 'path: Twitch-VOD-Manager-Setup-1.0.18.exe', + `sha512: ${UPDATE_SHA512}`, + "releaseDate: '2026-08-13T10:00:00.000Z'" + ].join('\n'); + const metadata = parseLatestYaml(yaml); + const result = validateProductionRelease(metadata, { + expectedVersion: '1.0.18', + expectedSha512: UPDATE_SHA512, + latestTag: 'v1.0.18' + }); + assert.deepEqual(result, { + artifactName: 'Twitch-VOD-Manager-Setup-1.0.18.exe', + artifactSize: 120000000, + feedUrl: 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v1.0.18/', + version: '1.0.18' + }); + assert.throws( + () => validateProductionRelease({ ...metadata, path: '../outside.exe' }, { + expectedVersion: '1.0.18', + expectedSha512: UPDATE_SHA512, + latestTag: 'v1.0.18' + }), + /artifact path/ + ); + assert.throws( + () => validateProductionRelease(metadata, { + expectedVersion: '1.0.19', + expectedSha512: UPDATE_SHA512, + latestTag: 'v1.0.18' + }), + /version/ + ); +}); + +test('resolves the public release tag to the pinned workflow commit', async () => { + const dependencies = releaseInspectionDependencies(); + const originalFetch = global.fetch; + global.fetch = async () => { throw new Error('inspectProductionRelease bypassed its injected transport'); }; + try { + const result = await inspectProductionRelease({ + expectedCommitSha: UPDATE_COMMIT_SHA, + expectedSha512: UPDATE_SHA512, + expectedVersion: PACKAGE_VERSION + }, dependencies); + assert.equal(result.commitSha, UPDATE_COMMIT_SHA); + assert.deepEqual(dependencies.calls, [ + 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest', + `https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/commits/v${PACKAGE_VERSION}`, + `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/latest.yml` + ]); + } finally { + global.fetch = originalFetch; + } +}); + +test('rejects a public release tag that resolves to another commit', async () => { + const dependencies = releaseInspectionDependencies('c'.repeat(40)); + const originalFetch = global.fetch; + global.fetch = async () => { throw new Error('inspectProductionRelease bypassed its injected transport'); }; + try { + await assert.rejects( + inspectProductionRelease({ + expectedCommitSha: UPDATE_COMMIT_SHA, + expectedSha512: UPDATE_SHA512, + expectedVersion: PACKAGE_VERSION + }, dependencies), + /release tag commit/i + ); + } finally { + global.fetch = originalFetch; + } +}); + +test('describes updater success as download-ready without claiming installation', () => { + const result = createUpdaterDownloadReadySummary({ + artifact: { bytes: 120000000, sha512Verified: true }, + downloadedVersion: PACKAGE_VERSION, + progressEvents: 4, + release: { + artifactName: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`, + commitSha: UPDATE_COMMIT_SHA, + feedUrl: `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/` + }, + source: { sourceInstallerVerified: true }, + sourceVersion: '0.0.1' + }); + assert.deepEqual(result, { + artifact: { + bytes: 120000000, + name: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`, + sha512Verified: true + }, + feed: `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/`, + scope: 'download-ready', + source: { installerDigestVerified: true, version: '0.0.1' }, + target: { + commitSha: UPDATE_COMMIT_SHA, + downloadReady: true, + progressEvents: 4, + version: PACKAGE_VERSION + } + }); +}); + +test('independently hashes the downloaded PE artifact from the isolated updater cache', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-live-update-artifact-')); + try { + const artifact = path.join(root, 'Twitch-VOD-Manager-Setup-1.0.18.exe'); + const contents = Buffer.alloc(1024 * 1024, 9); + contents[0] = 0x4d; + contents[1] = 0x5a; + fs.writeFileSync(artifact, contents); + const expectedSha512 = nodeCrypto.createHash('sha512').update(contents).digest('base64'); + const result = await validateDownloadedReleaseArtifact(artifact, { + artifactName: path.basename(artifact), + artifactSize: contents.length, + expectedSha512 + }); + assert.deepEqual(result, { bytes: contents.length, sha512Verified: true }); + + contents[0] = 0; + fs.writeFileSync(artifact, contents); + await assert.rejects( + validateDownloadedReleaseArtifact(artifact, { + artifactName: path.basename(artifact), + artifactSize: contents.length, + expectedSha512: nodeCrypto.createHash('sha512').update(contents).digest('base64') + }), + /Windows executable/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('requires the packaged source to be older than the pinned release', () => { + assert.equal(compareVersions('1.0.17', '1.0.18'), -1); + assert.equal(compareVersions('1.0.18', '1.0.18'), 0); + assert.equal(compareVersions('1.0.19', '1.0.18'), 1); + for (const invalid of ['nightly', 'v1.0.18', '1.0.18-alpha', '1.0.18+build', '01.0.18', '1.00.18', '1.0.18.0', '1234567890.0.0']) { + assert.throws(() => compareVersions(invalid, '1.0.18'), /invalid/); + } +}); + +test('binds the updater cache record to the pinned artifact and digest', () => { + assert.deepEqual(validateUpdateCacheRecord({ + fileName: 'Twitch-VOD-Manager-Setup-1.0.17.exe', + sha512: 'MkTghoBxIOnhP77tHV8szr8S1dbhItJId0atllZjWVrPwNLcvwnCyYjoUVEWV1czTqb5I+CUvqLiIjaamwglgw==', + isAdminRightsRequired: false + }, { + artifactName: 'Twitch-VOD-Manager-Setup-1.0.17.exe', + expectedSha512: 'MkTghoBxIOnhP77tHV8szr8S1dbhItJId0atllZjWVrPwNLcvwnCyYjoUVEWV1czTqb5I+CUvqLiIjaamwglgw==' + }), { + fileName: 'Twitch-VOD-Manager-Setup-1.0.17.exe', + sha512Verified: true + }); + assert.throws(() => validateUpdateCacheRecord({ + fileName: '..\\outside.exe', + sha512: UPDATE_SHA512 + }, { + artifactName: 'Twitch-VOD-Manager-Setup-1.0.17.exe', + expectedSha512: UPDATE_SHA512 + }), /file name/); +}); + +test('refuses cleanup at or outside the owned temporary root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-live-contract-')); + try { + const child = path.join(root, 'owned'); + fs.mkdirSync(child); + assert.equal(assertOwnedPath(child, root), path.resolve(child)); + assert.throws(() => assertOwnedPath(root, root), /outside/); + assert.throws(() => assertOwnedPath(path.dirname(root), root), /outside/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('removes the owned runner root after a successful operation', async () => { + let ownedRoot = ''; + const result = await runWithOwnedRoot('tvm-live-success-', async (context) => { + ownedRoot = context.ownedRoot; + assert.equal(fs.existsSync(ownedRoot), true); + fs.writeFileSync(path.join(ownedRoot, 'artifact.tmp'), 'owned'); + return 'completed'; + }); + assert.equal(result, 'completed'); + assert.equal(fs.existsSync(ownedRoot), false); +}); + +test('removes the owned runner root when the operation fails', async () => { + let ownedRoot = ''; + await assert.rejects( + runWithOwnedRoot('tvm-live-failure-', async (context) => { + ownedRoot = context.ownedRoot; + fs.writeFileSync(path.join(ownedRoot, 'artifact.tmp'), 'owned'); + throw new Error('runner failed'); + }), + /runner failed/ + ); + assert.equal(fs.existsSync(ownedRoot), false); +}); + +test('accepts only the three explicit execution modes', () => { + assert.equal(parseGateMode([]), 'all'); + assert.equal(parseGateMode(['twitch']), 'twitch'); + assert.equal(parseGateMode(['updater']), 'updater'); + assert.throws(() => parseGateMode(['local-feed']), /mode/); +}); + +test('removes live credentials and injection variables from the packaged app environment', () => { + const result = sanitizeChildEnvironment({ + PATH: 'C:\\Windows', + SystemRoot: 'C:\\Windows', + GITHUB_TOKEN: 'github-secret', + HTTP_PROXY: ['http://user', ':', 'password', '@', 'proxy.example.test'].join(''), + NODE_OPTIONS: '--require malicious.js', + TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: 'twitch-secret', + SAFE_SETTING: 'kept' + }, { LOCALAPPDATA: 'C:\\isolated' }); + assert.deepEqual(result, { + LOCALAPPDATA: 'C:\\isolated', + PATH: 'C:\\Windows', + SYSTEMROOT: 'C:\\Windows' + }); +}); diff --git a/scripts/smoke-test-managed-tools-live.js b/scripts/smoke-test-managed-tools-live.js new file mode 100644 index 0000000..1ed4b2a --- /dev/null +++ b/scripts/smoke-test-managed-tools-live.js @@ -0,0 +1,168 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); + +function assertActionsWindowsCi(environment = process.env, platform = process.platform) { + const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase(); + const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com'; + const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de'; + if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) { + throw new Error('Live managed-tool smoke is restricted to an approved Windows Actions runner'); + } +} + +function assertPathInside(targetPath, parentPath) { + const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath)); + if (!relative || relative === '..' || relative.startsWith('..\\') || path.win32.isAbsolute(relative)) { + throw new Error(`Refusing cleanup outside the owned runner directory: ${targetPath}`); + } +} + +function findFileRecursive(directory, fileName) { + if (!fs.existsSync(directory)) return ''; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) return entryPath; + if (entry.isDirectory()) { + const nested = findFileRecursive(entryPath, fileName); + if (nested) return nested; + } + } + return ''; +} + +function corruptInstalledTools(streamlinkDirectory, ffmpegDirectory) { + const streamlinkPath = findFileRecursive(streamlinkDirectory, 'streamlink.exe'); + const ffmpegPath = findFileRecursive(ffmpegDirectory, 'ffmpeg.exe'); + if (!streamlinkPath || !ffmpegPath) throw new Error('Managed executables are missing before corruption check'); + fs.rmSync(streamlinkPath); + fs.appendFileSync(ffmpegPath, 'corrupt'); + return { ffmpegPath, streamlinkPath }; +} + +function runVersionCheck(executablePath, args, label) { + const result = spawnSync(executablePath, args, { + encoding: 'utf8', + timeout: 60000, + windowsHide: true, + maxBuffer: 4 * 1024 * 1024 + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${label} version check failed: ${JSON.stringify({ status: result.status, stderr: result.stderr })}`); + } + const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim(); + if (!output) throw new Error(`${label} version check produced no output`); + return output.split(/\r?\n/, 1)[0]; +} + +function assertPinnedVersion(output, version, label) { + if (!output.toLowerCase().includes(version.toLowerCase())) { + throw new Error(`${label} version output does not match the pinned ${version}: ${output}`); + } +} + +function assertVerified(statuses, manifest, phase) { + for (const id of ['streamlink', 'ffmpeg']) { + if (!statuses[id]?.verified || statuses[id]?.state !== 'verified' || statuses[id]?.version !== manifest[id].version) { + throw new Error(`${id} is not verified after ${phase}: ${JSON.stringify(statuses[id])}`); + } + } +} + +async function main() { + assertActionsWindowsCi(); + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) { + throw new Error(`Actions runner temp directory is invalid: ${runnerTemp || ''}`); + } + const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-managed-tools-')); + assertPathInside(smokeRoot, runnerTemp); + + try { + const streamlinkDirectory = path.join(smokeRoot, 'tools', 'streamlink'); + const ffmpegDirectory = path.join(smokeRoot, 'tools', 'ffmpeg'); + const temporaryDirectory = path.join(smokeRoot, 'temporary'); + fs.mkdirSync(temporaryDirectory, { recursive: true }); + const toolsPath = path.join(root, 'dist', 'tools.js'); + const manifestPath = path.join(root, 'dist', 'main', 'domain', 'tool-manifest.js'); + if (!fs.existsSync(toolsPath)) throw new Error('Build output is missing; run npm run build first'); + if (!fs.existsSync(manifestPath)) throw new Error('Built tool manifest is missing; run npm run build first'); + const tools = require(toolsPath); + const { APPLICATION_TOOL_MANIFEST: manifest } = require(manifestPath); + tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory); + + const initial = await tools.getManagedToolStatuses(); + if (initial.streamlink.state !== 'missing' || initial.ffmpeg.state !== 'missing') { + throw new Error(`Clean managed-tool surface is not empty: ${JSON.stringify(initial)}`); + } + + const firstRepair = await tools.repairManagedTools(); + if (!firstRepair.success) throw new Error(`Initial managed-tool provisioning failed: ${JSON.stringify(firstRepair.statuses)}`); + assertVerified(firstRepair.statuses, manifest, 'initial provisioning'); + const initialPaths = { + streamlink: fs.realpathSync.native(tools.getStreamlinkPath()), + ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()), + ffprobe: fs.realpathSync.native(tools.getFFprobePath()) + }; + assertPathInside(initialPaths.streamlink, streamlinkDirectory); + assertPathInside(initialPaths.ffmpeg, ffmpegDirectory); + assertPathInside(initialPaths.ffprobe, ffmpegDirectory); + const initialVersions = { + streamlink: runVersionCheck(initialPaths.streamlink, ['--version'], 'Streamlink'), + ffmpeg: runVersionCheck(initialPaths.ffmpeg, ['-version'], 'FFmpeg'), + ffprobe: runVersionCheck(initialPaths.ffprobe, ['-version'], 'FFprobe') + }; + assertPinnedVersion(initialVersions.streamlink, manifest.streamlink.version, 'Streamlink'); + assertPinnedVersion(initialVersions.ffmpeg, manifest.ffmpeg.version, 'FFmpeg'); + assertPinnedVersion(initialVersions.ffprobe, manifest.ffmpeg.version, 'FFprobe'); + + corruptInstalledTools(streamlinkDirectory, ffmpegDirectory); + tools.invalidateVerifiedToolCaches(); + const damaged = await tools.getManagedToolStatuses(); + if (damaged.streamlink.state !== 'corrupt' || damaged.ffmpeg.state !== 'corrupt') { + throw new Error(`Damaged managed tools were not detected: ${JSON.stringify(damaged)}`); + } + + const secondRepair = await tools.repairManagedTools(); + if (!secondRepair.success) throw new Error(`Managed-tool repair failed: ${JSON.stringify(secondRepair.statuses)}`); + assertVerified(secondRepair.statuses, manifest, 'corruption repair'); + const repairedPaths = { + streamlink: fs.realpathSync.native(tools.getStreamlinkPath()), + ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()), + ffprobe: fs.realpathSync.native(tools.getFFprobePath()) + }; + assertPathInside(repairedPaths.streamlink, streamlinkDirectory); + assertPathInside(repairedPaths.ffmpeg, ffmpegDirectory); + assertPathInside(repairedPaths.ffprobe, ffmpegDirectory); + const repairedVersions = { + streamlink: runVersionCheck(repairedPaths.streamlink, ['--version'], 'Repaired Streamlink'), + ffmpeg: runVersionCheck(repairedPaths.ffmpeg, ['-version'], 'Repaired FFmpeg'), + ffprobe: runVersionCheck(repairedPaths.ffprobe, ['-version'], 'Repaired FFprobe') + }; + assertPinnedVersion(repairedVersions.streamlink, manifest.streamlink.version, 'Repaired Streamlink'); + assertPinnedVersion(repairedVersions.ffmpeg, manifest.ffmpeg.version, 'Repaired FFmpeg'); + assertPinnedVersion(repairedVersions.ffprobe, manifest.ffmpeg.version, 'Repaired FFprobe'); + + console.log(JSON.stringify({ failures: [], initialVersions, repairedVersions }, null, 2)); + } finally { + assertPathInside(smokeRoot, runnerTemp); + await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} + +module.exports = { + assertActionsWindowsCi, + assertPathInside, + corruptInstalledTools, + findFileRecursive +}; diff --git a/scripts/smoke-test-managed-tools-live.test.js b/scripts/smoke-test-managed-tools-live.test.js new file mode 100644 index 0000000..71931f6 --- /dev/null +++ b/scripts/smoke-test-managed-tools-live.test.js @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + assertActionsWindowsCi, + assertPathInside, + corruptInstalledTools, + findFileRecursive +} = require('./smoke-test-managed-tools-live'); + +test('accepts GitHub and Gitea Windows Actions while rejecting local opt-in', () => { + assert.doesNotThrow(() => assertActionsWindowsCi({ CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://github.com', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '123' }, 'win32')); + assert.doesNotThrow(() => assertActionsWindowsCi({ CI: 'true', GITEA_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://git.24-music.de', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32')); + assert.throws(() => assertActionsWindowsCi({ CI: 'true', RUNNER_OS: 'Windows' }, 'win32'), /Windows Actions runner/); + assert.throws(() => assertActionsWindowsCi({ TWITCH_VOD_MANAGER_MANAGED_TOOLS_LIVE: '1' }, 'win32'), /Windows Actions runner/); + assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://ci.example.test', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '123' }, 'win32'), /Windows Actions runner/); + assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITEA_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://other.example.test', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'), /Windows Actions runner/); + assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITHUB_SERVER_URL: 'https://git.24-music.de', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'), /Windows Actions runner/); +}); + +test('rejects cleanup targets outside the owned runner directory', () => { + assert.doesNotThrow(() => assertPathInside('C:\\runner\\temp\\managed-tools-1', 'C:\\runner\\temp')); + assert.throws(() => assertPathInside('C:\\runner\\other', 'C:\\runner\\temp'), /outside/); + assert.throws(() => assertPathInside('C:\\runner\\temp', 'C:\\runner\\temp'), /outside/); +}); + +test('damages both managed installations without touching unrelated files', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-managed-tools-contract-')); + try { + const streamlinkDirectory = path.join(root, 'streamlink', 'bin'); + const ffmpegDirectory = path.join(root, 'ffmpeg', 'bin'); + fs.mkdirSync(streamlinkDirectory, { recursive: true }); + fs.mkdirSync(ffmpegDirectory, { recursive: true }); + const streamlinkPath = path.join(streamlinkDirectory, 'streamlink.exe'); + const ffmpegPath = path.join(ffmpegDirectory, 'ffmpeg.exe'); + const ffprobePath = path.join(ffmpegDirectory, 'ffprobe.exe'); + fs.writeFileSync(streamlinkPath, 'streamlink'); + fs.writeFileSync(ffmpegPath, 'ffmpeg'); + fs.writeFileSync(ffprobePath, 'ffprobe'); + + const damaged = corruptInstalledTools(path.join(root, 'streamlink'), path.join(root, 'ffmpeg')); + + assert.equal(fs.existsSync(streamlinkPath), false); + assert.equal(fs.readFileSync(ffmpegPath, 'utf8'), 'ffmpegcorrupt'); + assert.equal(fs.readFileSync(ffprobePath, 'utf8'), 'ffprobe'); + assert.deepEqual(damaged, { ffmpegPath, streamlinkPath }); + assert.equal(findFileRecursive(path.join(root, 'ffmpeg'), 'ffprobe.exe'), ffprobePath); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/smoke-test-public-release-config.js b/scripts/smoke-test-public-release-config.js index b5c75c4..291830c 100644 --- a/scripts/smoke-test-public-release-config.js +++ b/scripts/smoke-test-public-release-config.js @@ -1,5 +1,8 @@ const fs = require('fs'); const path = require('path'); +const { createRequire } = require('module'); + +const { Minimatch } = createRequire(require.resolve('app-builder-lib/package.json'))('minimatch'); const root = process.cwd(); const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); @@ -10,43 +13,77 @@ const installerSource = fs.readFileSync(path.join(root, 'build', 'installer.nsh' const installerSmokeSource = fs.readFileSync(path.join(root, 'scripts', 'smoke-test-installer.js'), 'utf8'); const manifestPath = path.join(root, 'scripts', 'public-release-files.json'); const failures = []; +const expectedVersion = '1.0.18'; function check(condition, message) { if (!condition) failures.push(message); } -check(packageJson.version === '1.0.17', `package version is ${packageJson.version}`); -check(packageLock.version === '1.0.17', `lockfile version is ${packageLock.version}`); -check(packageLock.packages?.['']?.version === '1.0.17', `lockfile root package version is ${packageLock.packages?.['']?.version}`); +check(packageJson.version === expectedVersion, `package version is ${packageJson.version}`); +check(packageLock.version === expectedVersion, `lockfile version is ${packageLock.version}`); +check(packageLock.packages?.['']?.version === expectedVersion, `lockfile root package version is ${packageLock.packages?.['']?.version}`); check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`); check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`); check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`); -for (const pattern of ['!dist/**/*.test.js', '!node_modules/better-sqlite3/build/**', '!node_modules/better-sqlite3/deps/**', '!node_modules/better-sqlite3/src/**']) { +const packagedDistExclusions = [ + '!dist/**/*.test.js', + '!dist/main/dev-executable.js', + '!dist/main/index.js', + '!dist/types.js' +]; +const packagedDependencyExclusions = [ + '!node_modules/better-sqlite3/build/**', + '!node_modules/better-sqlite3/deps/**', + '!node_modules/better-sqlite3/src/**', + '!node_modules/{agent-base,axios,builder-util-runtime,electron-updater,https-proxy-agent,js-yaml,lazy-val}/**/*.map', + '!node_modules/agent-base/src/{index,promisify}.ts', + '!node_modules/{call-bind-apply-helpers,dunder-proto,es-define-property,es-set-tostringtag,function-bind,get-intrinsic,get-proto,has-symbols,has-tostringtag,hasown}/.nycrc', + '!node_modules/delayed-stream/Makefile', + '!node_modules/node-addon-api/{common,except,noexcept}.gypi', + '!node_modules/node-addon-api/{node_addon_api,node_api}.gyp', + '!node_modules/node-addon-api/nothing.c', + '!node_modules/node-addon-api/{napi-inl.deprecated,napi-inl,napi}.h', + '!node_modules/better-sqlite3/prebuilds/{darwin-arm64,darwin-x64,linux-arm64,linux-x64,linuxmusl-arm64,linuxmusl-x64,win32-arm64}.node' +]; +const packagedFileExclusions = [...packagedDistExclusions, ...packagedDependencyExclusions]; +for (const pattern of packagedFileExclusions) { check(packageJson.build?.files?.includes(pattern), `missing packaged file exclusion: ${pattern}`); } -check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', '!dist/**/*.test.js', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'build/icon.png', 'package.json', '!node_modules/better-sqlite3/build/**', '!node_modules/better-sqlite3/deps/**', '!node_modules/better-sqlite3/src/**']), 'packaged file list is not restricted'); +const productionStyles = ['styles.css', 'styles-workflows.css', 'styles-overlays.css', 'workspace.css', 'workspace-refinements.css']; +check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', ...packagedDistExclusions, 'src/index.html', ...productionStyles.map((fileName) => `src/${fileName}`), 'build/icon.png', 'package.json', ...packagedDependencyExclusions]), 'packaged file list is not restricted'); +const requiredWindowsSqlitePrebuild = 'node_modules/better-sqlite3/prebuilds/win32-x64.node'; +const matchingWindowsSqliteExclusions = packageJson.build?.files?.filter((pattern) => typeof pattern === 'string' && pattern.startsWith('!node_modules/') && new Minimatch(pattern.slice(1), { dot: true }).match(requiredWindowsSqlitePrebuild)) || []; +check(matchingWindowsSqliteExclusions.length === 0, `required win32-x64 better-sqlite3 prebuild is excluded by: ${matchingWindowsSqliteExclusions.join(', ')}`); +const linkedStyles = Array.from(indexSource.matchAll(/ match[1]); +check(JSON.stringify(linkedStyles) === JSON.stringify(productionStyles), `production stylesheet order is ${linkedStyles.join(', ')}`); check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`); check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`); check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`); check(packageJson.build?.nsis?.shortcutName === 'Twitch VOD Manager', `Windows Start Menu shortcut is not stable: ${packageJson.build?.nsis?.shortcutName}`); check(installerSource.includes('!macro preInit'), 'installer does not recover from orphaned Windows registration before upgrade detection'); -check(installerSource.includes('ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read the existing per-user install location before upgrade detection'); -check(installerSource.includes('${ifNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect a missing executable in an existing per-user registration'); -check(installerSource.includes('DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned per-user registration before upgrade detection'); +check(installerSource.includes('!macro removeOrphanedRegistration ROOT'), 'installer does not centralize orphaned registration cleanup'); +check(installerSource.includes('ReadRegStr $0 ${ROOT} "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read an existing install location before upgrade detection'); +check(installerSource.includes('${if} $0 == ""') && installerSource.includes('${orIfNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect incomplete or missing orphaned installations'); +check(installerSource.includes('DeleteRegKey ${ROOT} "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey ${ROOT} "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned install and uninstall registration together'); +check(installerSource.includes('!insertmacro removeOrphanedRegistration HKCU') && installerSource.includes('!insertmacro removeOrphanedRegistration HKLM'), 'installer does not clear orphaned registration in both Windows installation scopes'); +check(installerSource.includes('!ifndef BUILD_UNINSTALLER') && installerSource.includes('!insertmacro check64BitAndSetRegView'), 'orphan cleanup can run during uninstaller generation or against the wrong registry view'); const shortcutIconResource = packageJson.build?.extraResources?.find((entry) => entry?.from === 'build/icon.ico'); check(shortcutIconResource?.to === 'app-icons/icon-${version}.ico', `versioned shortcut icon resource is ${shortcutIconResource?.to}`); -check(installerSource.includes('"$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the persistent versioned icon resource'); -check(installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is not copied to persistent storage'); +check(installerSource.includes('StrCpy $0 "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the versioned icon in the selected installation scope'); +check(!installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is copied into a user-private location'); +check(!installerSource.includes('CreateDirectory "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'installer creates user-private shortcut resources that break all-users installations'); check(installerSource.includes('CreateShortCut "$newDesktopLink"'), 'desktop shortcut is not refreshed with the versioned icon resource'); check(installerSource.includes('CreateShortCut "$newStartMenuLink"'), 'start menu shortcut is not refreshed with the versioned icon resource'); check(installerSource.includes('Delete "$SMPROGRAMS\\Twitch VOD Manager v*.lnk"'), 'legacy versioned Start Menu shortcuts are not removed during upgrade'); const stableStartShortcutBlock = installerSource.match(/Delete "\$SMPROGRAMS\\Twitch VOD Manager v\*\.lnk"([\s\S]*?)System::Call 'shell32::SHChangeNotify\(i 0x00001000/); check(Boolean(stableStartShortcutBlock) && !stableStartShortcutBlock[1].includes('${if} ${FileExists} "$newStartMenuLink"'), 'stable Start Menu shortcut is not recreated when an older installer did not register it'); check(installerSource.includes('SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS"'), 'Windows Start Menu is not notified after shortcut refresh'); -check(installerSmokeSource.includes("'/currentuser'"), 'installer smoke does not force a per-user test installation'); +check(installerSmokeSource.includes("flag: '/currentuser'") && installerSmokeSource.includes("flag: '/allusers'"), 'installer smoke does not cover current-user and all-users installations sequentially'); check(installerSmokeSource.includes('assertCleanInstallerSmokeSurface'), 'installer smoke can run against an existing workstation installation'); +check(installerSmokeSource.includes('GITHUB_ACTIONS') && installerSmokeSource.includes('GITEA_ACTIONS') && installerSmokeSource.includes('https://github.com') && installerSmokeSource.includes('https://git.24-music.de') && !installerSmokeSource.includes('TWITCH_VOD_MANAGER_INSTALLER_SMOKE'), 'real installer smoke is not restricted to approved GitHub and Gitea Windows runners'); +check(installerSmokeSource.includes('assertInstalledRegistration(phase') && installerSmokeSource.includes('assertShortcutDetails('), 'installer smoke does not verify registry mode and shortcut target/icon'); check(installerSource.includes('SHChangeNotify(i 0x08000000, i 0x1000'), 'Windows shell icon cache is not flushed after shortcut refresh'); -check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'persistent shortcut icons are not cleaned up on a real uninstall'); +check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'legacy user-private shortcut icons are not cleaned up on a real uninstall'); check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled'); check(packageJson.build?.win?.signExecutable !== false, 'Windows executable signing is disabled'); check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon is missing'); @@ -65,7 +102,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing'); check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing'); check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source'); -check(indexSource.includes('Version: v1.0.17'), 'initial version label is not 1.0.17'); +check(indexSource.includes(`Version: v${expectedVersion}`), `initial version label is not ${expectedVersion}`); check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present'); check(fs.existsSync(manifestPath), 'public release manifest is missing'); @@ -73,7 +110,10 @@ if (fs.existsSync(manifestPath)) { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const entries = Array.isArray(manifest.files) ? manifest.files : []; const normalizedEntries = entries.map((entry) => entry.replace(/\\/g, '/').replace(/\/$/, '')); - const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl)$/i; + const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl|patch)$/i; + for (const candidate of ['docs/superpowers/internal.md', 'task6-main-selective.patch']) { + check(forbiddenReleasePath.test(candidate), `forbidden path guard misses ${candidate}`); + } for (const entry of entries) { const absolutePath = path.join(root, entry); check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`); diff --git a/scripts/smoke-test-workspace-ui.js b/scripts/smoke-test-workspace-ui.js index c2e01aa..0ad565a 100644 --- a/scripts/smoke-test-workspace-ui.js +++ b/scripts/smoke-test-workspace-ui.js @@ -477,6 +477,11 @@ async function run() { check(cutterDropUi.filePath === path.basename(cutterDropFixturePath), `Cutter drop displayed "${cutterDropUi.filePath}" instead of the safe file name`); check(typeof cutterDropPaths.mediaCapability === 'string' && cutterDropPaths.mediaCapability.length >= 32 && cutterDropPaths.mediaCapability !== cutterDropFixturePath, `Cutter drop did not send an opaque capability to media preparation: ${JSON.stringify(cutterDropPaths)}`); check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls'); + checks.cutterDrop.fixtureInputRemoved = await win.evaluate(() => { + document.getElementById('workspaceCutterDropInput')?.remove(); + return document.getElementById('workspaceCutterDropInput') === null; + }); + check(checks.cutterDrop.fixtureInputRemoved, 'Cutter drop fixture input remains visible in later workspace states'); const queueEmptyActions = await win.evaluate(() => ({ count: document.getElementById('queueCount')?.textContent?.trim() || '', @@ -644,6 +649,7 @@ async function run() { const changelogClosed = await captureUpdateChangelog(); await win.evaluate(() => dismissUpdateModal()); await win.emulateMedia({ reducedMotion: 'no-preference' }); + await win.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); checks.updateChangelogMotion = { collapsed: changelogCollapsed, opening: changelogOpening, @@ -1453,7 +1459,7 @@ async function run() { document.body.textContent || '', ...[...document.querySelectorAll('[title], [placeholder], [aria-label]')].flatMap((element) => [element.getAttribute('title') || '', element.getAttribute('placeholder') || '', element.getAttribute('aria-label') || '']) ].join('\n').toLocaleLowerCase('de-DE'); - const forbidden = ['verfugbar', 'uberspringen', 'fur ', 'hinzufugen', 'hinzufuegen', 'schliessen', 'auswahlen', 'auswaehlen', 'auflosung', 'zusammenfugen', 'wahle ', 'uebersicht', 'groesse', 'groessen', 'aelteste', 'qualitaet', 'waehrend', 'loeschen', 'nuetzlich', 'geprueft', 'geraet', 'zurueck', 'ausfuehren', 'wuerde', 'aelter', 'eintraege', 'oeffnen', 'ungueltig', 'kuerzere', 'gleichmaessig', 'einfuegereihenfolge', 'noetig', 'behaelt', 'faellt', 'laeuft', 'gekuerzt', 'ausserhalb', 'fliessen', 'grosser', 'aktivitaet', 'gruene', 'laengste', 'kuerzeste', 'zugehoerige']; + const forbidden = ['verfugbar', 'uberspringen', 'fur ', 'hinzufugen', 'hinzufuegen', 'schliessen', 'auswahlen', 'auswaehlen', 'ausgewahlt', 'auflosung', 'zusammenfugen', 'zusammengefugt', 'wahle ', 'uebersicht', 'groesse', 'groessen', 'aelteste', 'qualitaet', 'waehrend', 'loeschen', 'nuetzlich', 'geprueft', 'geraet', 'zurueck', 'ausfuehren', 'wuerde', 'aelter', 'eintraege', 'offnen', 'oeffnen', 'ungultig', 'ungueltig', 'kuerzere', 'gleichmaessig', 'einfuegereihenfolge', 'noetig', 'behaelt', 'faellt', 'lauft', 'laeuft', 'gekuerzt', 'ausserhalb', 'fliessen', 'grosser', 'aktivitaet', 'gruene', 'laengste', 'kuerzeste', 'zugehoerige', 'aenderungen', 'oeffnet', 'unterstutzte', 'teil-lange', 'aufraumen', 'prufung', 'stabilitat', 'integritat']; return { localeMatches: forbidden.filter((token) => localeText.includes(token)), domMatches: forbidden.filter((token) => domText.includes(token)) diff --git a/src/cutter-workspace-actions.production-path.test.ts b/src/cutter-workspace-actions.production-path.test.ts new file mode 100644 index 0000000..9650e81 --- /dev/null +++ b/src/cutter-workspace-actions.production-path.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, test } from 'vitest'; + +function fragment(source: string, start: string, end: string): string { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + if (from < 0 || to < 0) throw new Error(`Missing production fragment: ${start}`); + return source.slice(from, to); +} + +describe('cutter workspace actions production path', () => { + const html = readFileSync(join(__dirname, 'index.html'), 'utf8'); + + test('keeps project open and save actions in the persistent loaded toolbar', () => { + const toolbar = fragment(html, '