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 bd377bd..aadad03 100644 Binary files a/docs/images/twitch-vod-manager-overview.png and b/docs/images/twitch-vod-manager-overview.png differ diff --git a/package-lock.json b/package-lock.json index b97ad87..46697b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "1.0.17", + "version": "1.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "1.0.17", + "version": "1.0.18", "license": "MIT", "dependencies": { "axios": "^1.16.1", @@ -4294,9 +4294,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 70fab98..0fb06c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "1.0.17", + "version": "1.0.18", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "Sucukdeluxe", @@ -23,6 +23,7 @@ "test:e2e:full": "node scripts/smoke-test-full.js", "test:e2e:workspace-ui": "npm run build && node scripts/smoke-test-workspace-ui.js", "test:e2e:cutter": "npm run build && node scripts/smoke-test-cutter.js", + "test:e2e:cutter-matrix": "npm run build && node scripts/smoke-test-cutter-media-matrix.js", "test:e2e:isolation": "node scripts/smoke-test-e2e-isolation-contract.js", "test:capability-contract": "node scripts/smoke-test-file-capability-contract.js", "test:e2e:settings-autosave": "node scripts/smoke-test-settings-autosave.js", @@ -30,9 +31,16 @@ "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:packaged-launch": "node scripts/smoke-test-packaged-launch.js", "test:installer": "node scripts/smoke-test-installer.js", - "test:e2e:release": "npm run build && npm run test:unit && npm run test:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", + "test:e2e:release": "npm run build && npm run test:unit && 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:capability-contract && npm run test:e2e:update-logic && npm run test:merge-split && npm run test:e2e:public-release && npm run test:e2e:workspace-ui && node scripts/smoke-test-cutter.js && node scripts/smoke-test-cutter-media-matrix.js && npm run test:e2e:isolation && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full && npm run test:e2e:settings-autosave", "test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release", "pack": "npm run build && electron-builder --dir", "dist": "npm run build && electron-builder", @@ -65,14 +73,29 @@ "files": [ "dist/**/*", "!dist/**/*.test.js", + "!dist/main/dev-executable.js", + "!dist/main/index.js", + "!dist/types.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/**" + "src/styles-workflows.css", + "src/styles-overlays.css", + "src/workspace.css", + "src/workspace-refinements.css", + "build/icon.png", + "package.json", + "!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" ], "extraResources": [ { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 207b2ec..c6a72dd 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,11 +1,15 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { pathToFileURL } from 'node:url'; -import { watch } from 'node:fs'; +import { readFileSync, watch } from 'node:fs'; import { dirname, resolve } from 'node:path'; const scriptPath = fileURLToPath(import.meta.url); const rootDirectory = resolve(dirname(scriptPath), '..'); +const developmentAppVersion = JSON.parse(readFileSync(resolve(rootDirectory, 'package.json'), 'utf8')).version; +if (typeof developmentAppVersion !== 'string' || developmentAppVersion.trim().length === 0) { + throw new Error('package.json version must be a non-empty string'); +} const typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc'); const electronSourceExecutable = process.platform === 'win32' ? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe') @@ -93,7 +97,7 @@ if (process.platform === 'win32') { sourcePath: electronSourceExecutable, destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'), iconPath: resolve(rootDirectory, 'build', 'icon.ico'), - version: '1.0.17', + version: developmentAppVersion, }); } diff --git a/scripts/public-release-files.json b/scripts/public-release-files.json index 78a4ead..1fd8bba 100644 --- a/scripts/public-release-files.json +++ b/scripts/public-release-files.json @@ -154,6 +154,65 @@ "src/tools.ts", "src/types.ts", "src/workspace.css", + "scripts/smoke-test-cutter-media-matrix.js", + "scripts/smoke-test-cutter-media-matrix.test.js", + "scripts/smoke-test-installer.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-managed-tools-live.js", + "scripts/smoke-test-managed-tools-live.test.js", + "src/cutter-workspace-actions.production-path.test.ts", + "src/german-source-text.production-path.test.ts", + "src/main-runtime.production-path.test.ts", + "src/main-shutdown.production-path.test.ts", + "src/main/cutter/index.ts", + "src/main/domain/config-import.production-path.test.ts", + "src/main/domain/config-input.test.ts", + "src/main/domain/config-input.ts", + "src/main/domain/cutter-vfr.production-path.test.ts", + "src/main/domain/external-error.test.ts", + "src/main/domain/external-error.ts", + "src/main/domain/last-good-cache.test.ts", + "src/main/domain/last-good-cache.ts", + "src/main/domain/merge-recovery.test.ts", + "src/main/domain/merge-recovery.ts", + "src/main/domain/merge-split.production-path.test.ts", + "src/main/domain/phase-boundary-process.test.ts", + "src/main/domain/phase-boundary-process.ts", + "src/main/domain/phase-boundary.production-path.test.ts", + "src/main/domain/provider-payload.test.ts", + "src/main/domain/provider-payload.ts", + "src/main/domain/queue-addition.production-path.test.ts", + "src/main/domain/queue-addition.test.ts", + "src/main/domain/queue-addition.ts", + "src/main/domain/queue-runtime.test.ts", + "src/main/domain/queue-runtime.ts", + "src/main/domain/refresh-result.test.ts", + "src/main/domain/refresh-result.ts", + "src/main/domain/runtime-safety.test.ts", + "src/main/domain/runtime-safety.ts", + "src/main/domain/twitch-refresh.production-path.test.ts", + "src/main/queue/index.ts", + "src/main/storage/index.ts", + "src/main/twitch/app-token.test.ts", + "src/main/twitch/app-token.ts", + "src/main/twitch/index.ts", + "src/main/twitch/provider-refresh.test.ts", + "src/main/twitch/provider-refresh.ts", + "src/main/updates/index.ts", + "src/main/updates/update-lifecycle.production-path.test.ts", + "src/main/updates/update-lifecycle.test.ts", + "src/main/updates/update-lifecycle.ts", + "src/renderer-profile.production-path.test.ts", + "src/renderer-queue.production-path.test.ts", + "src/renderer-settings.production-path.test.ts", + "src/renderer-streamers.state-regressions.test.ts", + "src/renderer-vod-hover.lifecycle.test.ts", + "src/style-modules.production-path.test.ts", + "src/styles-overlays.css", + "src/styles-workflows.css", + "src/workspace-refinements.css", "tsconfig.json", "vitest.config.ts" ] diff --git a/scripts/smoke-test-ci-contract.js b/scripts/smoke-test-ci-contract.js index 31d40f4..8092bc3 100644 --- a/scripts/smoke-test-ci-contract.js +++ b/scripts/smoke-test-ci-contract.js @@ -9,12 +9,401 @@ function check(condition, message) { if (!condition) failures.push(message); } +function addField(fields, key, value) { + if (!fields.has(key)) fields.set(key, []); + fields.get(key).push(value); +} + +function singleField(fields, key) { + const values = fields.get(key) || []; + return values.length === 1 ? values[0] : undefined; +} + +function recordDuplicateFields(duplicates, fields, prefix) { + for (const [key, values] of fields) { + if (values.length > 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, '
', '
{ + 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); - } -}