From ffd827416e0d6b509456ec856c239ac5cc12da91 Mon Sep 17 00:00:00 2001 From: Sloth Date: Tue, 8 Sep 2026 20:44:54 -0700 Subject: [PATCH 01/12] fix(app): improve isolation of yt-dlp node process by stripping env data on the spawned process --- src/electron/main.mjs | 18 ++++++++---- src/electron/main.test.mjs | 57 +++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 5b91e9a..0678b52 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -98,12 +98,20 @@ export function jsRuntimeArgs() { return ['--js-runtimes', `node:${process.execPath}`]; } -// Every yt-dlp child process needs this env so that the Electron-binary-as- -// node trick above actually works -- yt-dlp spawns process.execPath itself -// as a nested child, which inherits whatever env yt-dlp was spawned with. -// Harmless for yt-dlp's own (Python) process, which never checks this var. +// Curated, not a full process.env copy -- the nsig-solving runtime +// (jsRuntimeArgs above) inherits this too, and Node's --permission sandbox +// doesn't gate env access, so a compromised solver script could otherwise +// read any secret a user's shell happens to have exported. HOME/ +// USERPROFILE/APPDATA/LOCALAPPDATA are kept because --cookies-from-browser +// (cookies.mjs) needs them to locate a browser's profile directory. +const YTDLP_ENV_ALLOWLIST = ['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PATH', 'TEMP', 'TMP', 'TMPDIR']; + export function ytdlpSpawnEnv() { - return { ...process.env, ELECTRON_RUN_AS_NODE: '1' }; + const env = { ELECTRON_RUN_AS_NODE: '1' }; + for (const key of YTDLP_ENV_ALLOWLIST) { + if (process.env[key] !== undefined) env[key] = process.env[key]; + } + return env; } // Defense-in-depth for every IPC handler that hands a caller-supplied URL to diff --git a/src/electron/main.test.mjs b/src/electron/main.test.mjs index 6a1455d..bd8b4f8 100644 --- a/src/electron/main.test.mjs +++ b/src/electron/main.test.mjs @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -66,6 +66,8 @@ import { makeCookiesArgs, reapStaleCookieCopies, getCookiesPersistAcrossSessions, + jsRuntimeArgs, + ytdlpSpawnEnv, } from './main.mjs'; fs.mkdirSync(electronMocks.mockUserDataDir, { recursive: true }); @@ -338,6 +340,59 @@ describe('assertValidHttpUrl', () => { }); }); +describe('jsRuntimeArgs', () => { + it('points yt-dlp at the running Electron binary via the node provider', () => { + expect(jsRuntimeArgs()).toEqual(['--js-runtimes', `node:${process.execPath}`]); + }); +}); + +describe('ytdlpSpawnEnv', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('always sets ELECTRON_RUN_AS_NODE, regardless of what is in process.env', () => { + expect(ytdlpSpawnEnv().ELECTRON_RUN_AS_NODE).toBe('1'); + }); + + it('does not leak arbitrary secrets from process.env through to the spawned env', () => { + vi.stubEnv('SOME_API_TOKEN', 'super-secret-value'); + expect(ytdlpSpawnEnv().SOME_API_TOKEN).toBeUndefined(); + }); + + it('passes through HOME -- cookies-from-browser resolves a browser profile dir via it', () => { + vi.stubEnv('HOME', '/Users/someone'); + expect(ytdlpSpawnEnv().HOME).toBe('/Users/someone'); + }); + + it('passes through the Windows profile-dir variables cookies-from-browser also relies on', () => { + vi.stubEnv('USERPROFILE', 'C:\\Users\\someone'); + vi.stubEnv('APPDATA', 'C:\\Users\\someone\\AppData\\Roaming'); + vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\someone\\AppData\\Local'); + const env = ytdlpSpawnEnv(); + expect(env.USERPROFILE).toBe('C:\\Users\\someone'); + expect(env.APPDATA).toBe('C:\\Users\\someone\\AppData\\Roaming'); + expect(env.LOCALAPPDATA).toBe('C:\\Users\\someone\\AppData\\Local'); + }); + + it('passes through PATH and the temp-dir variables', () => { + vi.stubEnv('PATH', '/usr/bin:/bin'); + vi.stubEnv('TEMP', '/tmp/a'); + vi.stubEnv('TMP', '/tmp/b'); + vi.stubEnv('TMPDIR', '/tmp/c'); + const env = ytdlpSpawnEnv(); + expect(env.PATH).toBe('/usr/bin:/bin'); + expect(env.TEMP).toBe('/tmp/a'); + expect(env.TMP).toBe('/tmp/b'); + expect(env.TMPDIR).toBe('/tmp/c'); + }); + + it('omits an allowlisted key entirely when unset, rather than passing through an undefined value', () => { + vi.stubEnv('HOME', undefined); + expect('HOME' in ytdlpSpawnEnv()).toBe(false); + }); +}); + describe('isValidClipTimestamp', () => { it('accepts every shape the renderer\'s own timestamp formatters can produce', () => { expect(isValidClipTimestamp('5')).toBe(true); From 8a70230719d659064d2c2e90a28b6f786afcc024 Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 9 Sep 2026 19:07:21 -0700 Subject: [PATCH 02/12] feat(app): re-implement deno runtime to have a fully sandboxed and isolated runtime for the yt-dlp js challenged --- package.json | 10 +- scripts/fetch-deno-bin.mjs | 67 +++++++ scripts/fetch-ytdlp-bin.mjs | 25 ++- src/electron/denoRelease.mjs | 205 +++++++++++++++++++++ src/electron/denoRelease.test.mjs | 287 ++++++++++++++++++++++++++++++ src/electron/main.mjs | 36 ++-- src/electron/main.test.mjs | 11 +- 7 files changed, 621 insertions(+), 20 deletions(-) create mode 100644 scripts/fetch-deno-bin.mjs create mode 100644 src/electron/denoRelease.mjs create mode 100644 src/electron/denoRelease.test.mjs diff --git a/package.json b/package.json index f45697e..a329154 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,13 @@ "filter": [ "**/*" ] + }, + { + "from": "dist/deno/", + "to": "deno", + "filter": [ + "**/*" + ] } ], "directories": { @@ -66,7 +73,8 @@ "build:copy:electron": "node scripts/copy-electron.mjs", "build:ytdlp:bin": "node scripts/fetch-ytdlp-bin.mjs", "build:copy:ffmpeg": "node scripts/copy-ffmpeg.mjs", - "build:electron": "npm run build:copy:electron && npm run build:ytdlp:bin && npm run build:copy:ffmpeg", + "build:deno:bin": "node scripts/fetch-deno-bin.mjs", + "build:electron": "npm run build:copy:electron && npm run build:ytdlp:bin && npm run build:copy:ffmpeg && npm run build:deno:bin", "build:all": "npm run build:renderer && npm run build:electron", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/fetch-deno-bin.mjs b/scripts/fetch-deno-bin.mjs new file mode 100644 index 0000000..4baca89 --- /dev/null +++ b/scripts/fetch-deno-bin.mjs @@ -0,0 +1,67 @@ +import fs from 'fs'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { resolveLatestRelease, mapPlatformToAssetName, fetchAndVerifyDenoRelease } from '../src/electron/denoRelease.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const workDir = path.join(rootDir, 'build', 'deno-fetch'); +const finalDist = path.join(rootDir, 'dist', 'deno'); +const binName = process.platform === 'win32' ? 'deno.exe' : 'deno'; + +// Pinned so every SlothArchiver release bundles a known, reproducible Deno +// version rather than "whatever GitHub's latest release happened to be at +// build time" -- same reasoning as fetch-ytdlp-bin.mjs's own +// PINNED_YTDLP_TAG. This exact version (v2.9.5) was validated end-to-end +// this session against a real yt-dlp nsig solve. Bump by hand in the same +// commit as any other dependency-version bump. +const PINNED_DENO_TAG = 'v2.9.5'; + +// Same repeat-local-build shortcut as fetch-ytdlp-bin.mjs -- skips the +// download+verify+extract round trip when the already-installed binary +// already reports the pinned version. `deno --version`'s first line is +// `deno ` with no "v" prefix (unlike the release tag, which has +// one) -- add it back before comparing. Falls through to a full re-fetch on +// any uncertainty (missing binary, wrong version, unparseable output, or a +// binary that fails to even run). +const existingBinaryPath = path.join(finalDist, binName); +if (fs.existsSync(existingBinaryPath)) { + try { + const versionOutput = execFileSync(existingBinaryPath, ['--version'], { encoding: 'utf-8' }); + const match = /^deno (\S+)/.exec(versionOutput); + const currentVersion = match ? `v${match[1]}` : null; + if (currentVersion === PINNED_DENO_TAG) { + console.log(`Deno ${PINNED_DENO_TAG} already installed at ${existingBinaryPath} -- skipping fetch.`); + process.exit(0); + } + console.log(`Installed Deno is ${currentVersion ?? '(unparseable)'}, pinned is ${PINNED_DENO_TAG} -- re-fetching.`); + } catch { + console.log(`Existing Deno binary at ${existingBinaryPath} could not report its version -- re-fetching.`); + } +} + +fs.rmSync(workDir, { recursive: true, force: true }); +fs.rmSync(finalDist, { recursive: true, force: true }); + +const release = await resolveLatestRelease({ pin: PINNED_DENO_TAG }); +const assetName = mapPlatformToAssetName(); + +console.log(`Fetching Deno ${release.tag} (${assetName})...`); +const extractedDir = await fetchAndVerifyDenoRelease({ + release, + assetName, + workDir, + onProgress: (stage) => console.log(`[fetch-deno-bin] ${stage}`), + onLog: (msg) => console.log(msg), +}); + +// fetchAndVerifyDenoRelease's ZIP extraction lands the single `deno`/ +// `deno.exe` binary directly in extractedDir, with no wrapper folder -- +// confirmed against the real macOS asset -- so this is just a rename into +// place, matching the final shape package.json's `dist/deno/` +// extraResources entry expects. +fs.renameSync(extractedDir, finalDist); +fs.rmSync(workDir, { recursive: true, force: true }); + +console.log(`Fetched Deno ${release.tag} -> ${path.join(finalDist, binName)}`); diff --git a/scripts/fetch-ytdlp-bin.mjs b/scripts/fetch-ytdlp-bin.mjs index d85c894..e1236c0 100644 --- a/scripts/fetch-ytdlp-bin.mjs +++ b/scripts/fetch-ytdlp-bin.mjs @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; import { resolveLatestRelease, mapPlatformToAssetName, detectMusl, fetchAndVerifyRelease } from '../src/electron/ytdlpRelease.mjs'; @@ -7,6 +8,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); const workDir = path.join(rootDir, 'build', 'ytdlp-fetch'); const finalDist = path.join(rootDir, 'dist', 'ytdlp-bin'); +const binName = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; // Pinned so every SlothArchiver release bundles a known, reproducible // yt-dlp version rather than "whatever GitHub's latest release happened to @@ -14,6 +16,28 @@ const finalDist = path.join(rootDir, 'dist', 'ytdlp-bin'); // dependency-version bump. const PINNED_YTDLP_TAG = '2026.08.19'; +// Skips the whole download+verify+extract round trip on a repeat local +// build when the already-installed binary already reports the exact pinned +// version -- this is the slow part of `npm run build:electron` (a +// multi-dozen-MB network fetch plus signature verification) and the pinned +// tag rarely changes between builds. Falls through to a full re-fetch on +// *any* uncertainty (missing binary, wrong version, or the binary failing +// to even report its own version) rather than trusting a stale/corrupt +// install. +const existingBinaryPath = path.join(finalDist, binName); +if (fs.existsSync(existingBinaryPath)) { + try { + const currentVersion = execFileSync(existingBinaryPath, ['--version'], { encoding: 'utf-8' }).trim(); + if (currentVersion === PINNED_YTDLP_TAG) { + console.log(`yt-dlp ${PINNED_YTDLP_TAG} already installed at ${existingBinaryPath} -- skipping fetch.`); + process.exit(0); + } + console.log(`Installed yt-dlp is ${currentVersion}, pinned is ${PINNED_YTDLP_TAG} -- re-fetching.`); + } catch { + console.log(`Existing yt-dlp binary at ${existingBinaryPath} could not report its version -- re-fetching.`); + } +} + fs.rmSync(workDir, { recursive: true, force: true }); fs.rmSync(finalDist, { recursive: true, force: true }); @@ -38,5 +62,4 @@ const extractedDir = await fetchAndVerifyRelease({ fs.renameSync(extractedDir, finalDist); fs.rmSync(workDir, { recursive: true, force: true }); -const binName = process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; console.log(`Fetched yt-dlp ${release.tag} -> ${path.join(finalDist, binName)}`); diff --git a/src/electron/denoRelease.mjs b/src/electron/denoRelease.mjs new file mode 100644 index 0000000..3937307 --- /dev/null +++ b/src/electron/denoRelease.mjs @@ -0,0 +1,205 @@ +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import crypto from 'crypto'; +import { unzip, findChecksumForAsset } from './ytdlpRelease.mjs'; + +// Zero third-party dependencies, same reason as ytdlpRelease.mjs: imported +// by a plain pre-build Node script (scripts/fetch-deno-bin.mjs), nothing +// PATH-resolved. `unzip`/`findChecksumForAsset` are reused directly rather +// than duplicated -- both are already generic (a plain ZIP reader, a plain +// " " line parser), and Deno's own per-asset +// checksum files use that exact same line format (confirmed directly +// against a real downloaded `deno-x86_64-apple-darwin.zip.sha256sum`). The +// small network helpers below (fetchJson/downloadFile/downloadText) are +// still independent copies, matching ytdlpRelease.mjs's own stated reason +// for not sharing those: each release-fetching module has to keep working +// standalone. + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; + +// Distinguishes "the downloaded release didn't match its own published +// checksum" from every other failure mode below (network errors, timeouts, +// a malformed .sha256sum) -- callers use this the same way +// YTDLP_VERIFICATION_ERROR_CODE is used, to tell the user the problem is +// with the external Deno release itself, not with this app. +export const DENO_VERIFICATION_ERROR_CODE = 'DENO_VERIFICATION_FAILED'; + +function verificationError(message) { + const err = new Error(message); + err.code = DENO_VERIFICATION_ERROR_CODE; + return err; +} + +function fetchJson(url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + fetchJson(res.headers.location, { timeoutMs }).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Request to ${url} failed with status ${res.statusCode}`)); + res.resume(); + return; + } + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error(`Failed to parse JSON from ${url}`)); + } + }); + }).on('error', reject); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request to ${url} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }); + }); +} + +function downloadFile(url, destPath, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + downloadFile(res.headers.location, destPath, { timeoutMs }).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Download from ${url} failed with status ${res.statusCode}`)); + res.resume(); + return; + } + const fileStream = fs.createWriteStream(destPath); + res.pipe(fileStream); + fileStream.on('finish', () => fileStream.close(() => resolve())); + fileStream.on('error', reject); + }).on('error', reject); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Download from ${url} timed out after ${Math.round(timeoutMs / 1000)}s of inactivity`)); + }); + }); +} + +function downloadText(url, opts) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'sloth-archiver' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + downloadText(res.headers.location, opts).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Request to ${url} failed with status ${res.statusCode}`)); + res.resume(); + return; + } + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve(data)); + }).on('error', reject); + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request to ${url} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }); + }); +} + +// Deno's official per-platform/arch release assets -- confirmed directly +// against the real v2.9.5 release (`gh api repos/denoland/deno/releases/ +// tags/v2.9.5`). Unlike yt-dlp, there's no separate musl variant in Deno's +// own release matrix. +export function mapPlatformToAssetName({ platform = process.platform, arch = process.arch } = {}) { + if (platform === 'darwin') { + if (arch === 'arm64') return 'deno-aarch64-apple-darwin.zip'; + if (arch === 'x64') return 'deno-x86_64-apple-darwin.zip'; + throw new Error(`Unsupported macOS architecture: ${arch}`); + } + if (platform === 'win32') { + if (arch === 'arm64') return 'deno-aarch64-pc-windows-msvc.zip'; + if (arch === 'x64') return 'deno-x86_64-pc-windows-msvc.zip'; + throw new Error(`Unsupported Windows architecture: ${arch}`); + } + if (platform === 'linux') { + if (arch === 'arm64') return 'deno-aarch64-unknown-linux-gnu.zip'; + if (arch === 'x64') return 'deno-x86_64-unknown-linux-gnu.zip'; + throw new Error(`Unsupported Linux architecture: ${arch}`); + } + throw new Error(`Unsupported platform: ${platform}`); +} + +// Same pin-shortcut shape as ytdlpRelease.mjs's own resolveLatestRelease -- +// with `pin`, resolves with no network call at all (what the build script +// always uses, for a reproducible pinned version); without it, resolves +// Deno's real latest GitHub release once. +export async function resolveLatestRelease({ pin } = {}) { + if (pin) { + return { tag: pin }; + } + const release = await fetchJson('https://api.github.com/repos/denoland/deno/releases/latest'); + return { tag: release.tag_name, assets: release.assets }; +} + +function assetDownloadUrl(tag, assetName) { + return `https://github.com/denoland/deno/releases/download/${tag}/${assetName}`; +} + +// The orchestration entry point -- downloads assetName plus its own +// `.sha256sum` companion file (confirmed directly: a single line, +// same " " format yt-dlp's combined SHA2-256SUMS +// uses, just scoped to one asset instead of every asset in the release), +// verifies the asset's SHA-256 against it, and only on success unzips the +// asset into a fresh subdirectory of workDir. +// +// Deliberately weaker than ytdlpRelease.mjs's fetchAndVerifyRelease in one +// specific way: Deno's own releases ship no GPG/detached signature at all, +// only this checksum file -- so this can only prove "the download matches +// what GitHub is currently serving as this asset," not "this is what Deno's +// maintainers actually signed." Same verification tier ffmpeg-static/ +// ffprobe-static already get today (no signature check either); not a new +// category of risk, just extended to a third bundled binary. Still fails +// closed: any missing checksum file, missing entry, or mismatch throws and +// nothing gets extracted. +export async function fetchAndVerifyDenoRelease({ release, assetName, workDir, onProgress, onLog }) { + fs.mkdirSync(workDir, { recursive: true }); + const assetPath = path.join(workDir, assetName); + + onProgress?.('fetching'); + onLog?.(`[deno-release] downloading ${assetName} (${release.tag})`); + await downloadFile(assetDownloadUrl(release.tag, assetName), assetPath); + + onLog?.(`[deno-release] downloading ${assetName}.sha256sum`); + const sumsText = await downloadText(assetDownloadUrl(release.tag, `${assetName}.sha256sum`)); + + onProgress?.('verifying'); + const expectedChecksum = findChecksumForAsset(sumsText, assetName); + if (!expectedChecksum) { + throw verificationError(`${assetName}.sha256sum has no entry for ${assetName} -- refusing to install an unverified binary`); + } + + const actualChecksum = crypto.createHash('sha256').update(fs.readFileSync(assetPath)).digest('hex'); + if (actualChecksum !== expectedChecksum) { + throw verificationError(`Checksum mismatch for ${assetName}: expected ${expectedChecksum}, got ${actualChecksum}`); + } + onLog?.(`[deno-release] ${assetName} checksum verified`); + + onProgress?.('installing'); + const extractDir = path.join(workDir, 'extracted'); + fs.rmSync(extractDir, { recursive: true, force: true }); + fs.mkdirSync(extractDir, { recursive: true }); + unzip(assetPath, extractDir); + + // Deno's official zips contain exactly one entry, already named + // `deno`/`deno.exe` (confirmed directly: `unzip -l` on the real macOS + // asset shows a single top-level `deno` entry, no wrapper directory, + // no _internal/ the way yt-dlp's onedir layout has) -- no rename step + // needed, unlike ytdlpRelease.mjs's renameLauncherToCanonicalName. + // Explicit chmod regardless of the ZIP's own Unix-permission bits, + // matching copy-ffmpeg.mjs's same defensive chmod after copying a + // third-party binary into place. + if (process.platform !== 'win32') { + fs.chmodSync(path.join(extractDir, 'deno'), 0o755); + } + + return extractDir; +} diff --git a/src/electron/denoRelease.test.mjs b/src/electron/denoRelease.test.mjs new file mode 100644 index 0000000..6be566b --- /dev/null +++ b/src/electron/denoRelease.test.mjs @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import zlib from 'zlib'; +import crypto from 'crypto'; +import { EventEmitter } from 'events'; +import { mapPlatformToAssetName, DENO_VERIFICATION_ERROR_CODE } from './denoRelease.mjs'; + +describe('mapPlatformToAssetName', () => { + it.each([ + [{ platform: 'darwin', arch: 'arm64' }, 'deno-aarch64-apple-darwin.zip'], + [{ platform: 'darwin', arch: 'x64' }, 'deno-x86_64-apple-darwin.zip'], + [{ platform: 'win32', arch: 'x64' }, 'deno-x86_64-pc-windows-msvc.zip'], + [{ platform: 'win32', arch: 'arm64' }, 'deno-aarch64-pc-windows-msvc.zip'], + [{ platform: 'linux', arch: 'x64' }, 'deno-x86_64-unknown-linux-gnu.zip'], + [{ platform: 'linux', arch: 'arm64' }, 'deno-aarch64-unknown-linux-gnu.zip'], + ])('%j -> %s', (input, expected) => { + expect(mapPlatformToAssetName(input)).toBe(expected); + }); + + it.each([ + ['darwin', 'ia32'], + ['win32', 'mips'], + ['freebsd', 'x64'], + ])('throws for unsupported %s/%s', (platform, arch) => { + expect(() => mapPlatformToAssetName({ platform, arch })).toThrow(); + }); +}); + +describe('resolveLatestRelease', () => { + it('returns the pinned tag with no network call', async () => { + const httpsGet = vi.fn(); + vi.doMock('https', () => ({ default: { get: httpsGet } })); + vi.resetModules(); + const { resolveLatestRelease: freshResolve } = await import('./denoRelease.mjs'); + + await expect(freshResolve({ pin: 'v2.9.5' })).resolves.toEqual({ tag: 'v2.9.5' }); + expect(httpsGet).not.toHaveBeenCalled(); + + vi.doUnmock('https'); + vi.resetModules(); + }); + + it('fetches and returns the real latest release tag/assets when unpinned', async () => { + vi.doMock('https', () => ({ + default: { + get: (url, options, callback) => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = {}; + res.resume = vi.fn(); + callback(res); + queueMicrotask(() => { + res.emit('data', JSON.stringify({ tag_name: 'v2.9.5', assets: [{ name: 'deno-x86_64-apple-darwin.zip' }] })); + res.emit('end'); + }); + const req = new EventEmitter(); + req.setTimeout = vi.fn(); + return req; + }, + }, + })); + vi.resetModules(); + const { resolveLatestRelease: freshResolve } = await import('./denoRelease.mjs'); + + await expect(freshResolve()).resolves.toEqual({ + tag: 'v2.9.5', + assets: [{ name: 'deno-x86_64-apple-darwin.zip' }], + }); + + vi.doUnmock('https'); + vi.resetModules(); + }); +}); + +// A minimal hand-built ZIP containing exactly the shape Deno's own release +// assets have (confirmed directly against the real v2.9.5 asset this +// session: a single top-level entry, already named canonically, no wrapper +// directory) -- not yt-dlp's real multi-MB asset, this module doesn't need +// anything from ytdlpRelease.test.mjs's own onedir-shaped fixtures. +function buildDenoZip(binaryContent) { + const name = 'deno'; + const nameBuf = Buffer.from(name, 'utf-8'); + const rawContent = Buffer.from(binaryContent, 'utf-8'); + const compressed = zlib.deflateRawSync(rawContent); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0, 6); + localHeader.writeUInt16LE(8, 8); + localHeader.writeUInt16LE(0, 10); + localHeader.writeUInt16LE(0, 12); + localHeader.writeUInt32LE(crc32(rawContent), 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(rawContent.length, 22); + localHeader.writeUInt16LE(nameBuf.length, 26); + localHeader.writeUInt16LE(0, 28); + + const mode = 0o100755; + const externalAttrs = (mode << 16) >>> 0; + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE((3 << 8) | 20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0, 8); + centralHeader.writeUInt16LE(8, 10); + centralHeader.writeUInt16LE(0, 12); + centralHeader.writeUInt16LE(0, 14); + centralHeader.writeUInt32LE(crc32(rawContent), 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(rawContent.length, 24); + centralHeader.writeUInt16LE(nameBuf.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(externalAttrs, 38); + centralHeader.writeUInt32LE(0, 42); + + const centralDirStart = localHeader.length + nameBuf.length + compressed.length; + const centralDir = Buffer.concat([centralHeader, nameBuf]); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralDir.length, 12); + eocd.writeUInt32LE(centralDirStart, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([localHeader, nameBuf, compressed, centralDir, eocd]); +} + +// Same table-free CRC32 as ytdlpRelease.test.mjs's own fixture builder -- +// test-only, unzip() never needs to compute one itself. +function crc32(buf) { + let crc = ~0; + for (const byte of buf) { + crc ^= byte; + for (let i = 0; i < 8; i++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (~crc) >>> 0; +} + +describe('fetchAndVerifyDenoRelease', () => { + function withTmpDir(fn) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sloth-archiver-test-deno-release-')); + try { + return fn(tmpDir); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + + it('downloads, verifies by checksum, and extracts the single deno binary', async () => withTmpDir(async (tmpDir) => { + const zipBuf = buildDenoZip('fake deno binary'); + const checksum = crypto.createHash('sha256').update(zipBuf).digest('hex'); + const assetName = 'deno-x86_64-apple-darwin.zip'; + + vi.doMock('https', () => ({ + default: { + get: (url, options, callback) => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = {}; + res.resume = vi.fn(); + if (url.endsWith('.sha256sum')) { + callback(res); + queueMicrotask(() => { + res.emit('data', `${checksum} ${assetName}\n`); + res.emit('end'); + }); + } else { + res.pipe = (dest) => { + dest.write(zipBuf); + dest.end(); + }; + callback(res); + } + const req = new EventEmitter(); + req.setTimeout = vi.fn(); + return req; + }, + }, + })); + vi.resetModules(); + const { fetchAndVerifyDenoRelease: freshFetch } = await import('./denoRelease.mjs'); + + const workDir = path.join(tmpDir, 'work'); + const extractDir = await freshFetch({ release: { tag: 'v2.9.5' }, assetName, workDir }); + + expect(fs.readFileSync(path.join(extractDir, 'deno'), 'utf-8')).toBe('fake deno binary'); + + vi.doUnmock('https'); + vi.resetModules(); + })); + + it('throws a DENO_VERIFICATION_FAILED error on a checksum mismatch, without extracting anything', async () => withTmpDir(async (tmpDir) => { + const zipBuf = buildDenoZip('fake deno binary'); + const assetName = 'deno-x86_64-apple-darwin.zip'; + const wrongChecksum = crypto.createHash('sha256').update('not the real content').digest('hex'); + + vi.doMock('https', () => ({ + default: { + get: (url, options, callback) => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = {}; + res.resume = vi.fn(); + if (url.endsWith('.sha256sum')) { + callback(res); + queueMicrotask(() => { + res.emit('data', `${wrongChecksum} ${assetName}\n`); + res.emit('end'); + }); + } else { + res.pipe = (dest) => { + dest.write(zipBuf); + dest.end(); + }; + callback(res); + } + const req = new EventEmitter(); + req.setTimeout = vi.fn(); + return req; + }, + }, + })); + vi.resetModules(); + const { fetchAndVerifyDenoRelease: freshFetch } = await import('./denoRelease.mjs'); + + const workDir = path.join(tmpDir, 'work'); + await expect(freshFetch({ release: { tag: 'v2.9.5' }, assetName, workDir })) + .rejects.toMatchObject({ code: DENO_VERIFICATION_ERROR_CODE, message: expect.stringContaining('Checksum mismatch') }); + expect(fs.existsSync(path.join(workDir, 'extracted'))).toBe(false); + + vi.doUnmock('https'); + vi.resetModules(); + })); + + it('throws when the checksum file has no entry for the asset', async () => withTmpDir(async (tmpDir) => { + const zipBuf = buildDenoZip('fake deno binary'); + const assetName = 'deno-x86_64-apple-darwin.zip'; + + vi.doMock('https', () => ({ + default: { + get: (url, options, callback) => { + const res = new EventEmitter(); + res.statusCode = 200; + res.headers = {}; + res.resume = vi.fn(); + if (url.endsWith('.sha256sum')) { + callback(res); + queueMicrotask(() => { + res.emit('data', 'deadbeef some-other-asset.zip\n'); + res.emit('end'); + }); + } else { + res.pipe = (dest) => { + dest.write(zipBuf); + dest.end(); + }; + callback(res); + } + const req = new EventEmitter(); + req.setTimeout = vi.fn(); + return req; + }, + }, + })); + vi.resetModules(); + const { fetchAndVerifyDenoRelease: freshFetch } = await import('./denoRelease.mjs'); + + const workDir = path.join(tmpDir, 'work'); + await expect(freshFetch({ release: { tag: 'v2.9.5' }, assetName, workDir })) + .rejects.toMatchObject({ code: DENO_VERIFICATION_ERROR_CODE, message: expect.stringContaining('no entry for') }); + + vi.doUnmock('https'); + vi.resetModules(); + })); +}); diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 0678b52..3e6af4d 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -59,6 +59,14 @@ const ffprobeBinaryName = process.platform === 'win32' ? 'ffprobe.exe' : 'ffprob const ffmpegBinaryPath = path.join(ffmpegDir, ffmpegBinaryName); const ffprobeBinaryPath = path.join(ffmpegDir, ffprobeBinaryName); +// Bundled the same way as ffmpeg/ffprobe above (read directly from +// extraResources, no userData relocation -- there's no Deno self-updater +// the way yt-dlp has one, so nothing ever needs to overwrite this at +// runtime). See jsRuntimeArgs below for why this is bundled at all. +const denoDir = isDev ? path.resolve(__dirname, '../deno') : path.join(process.resourcesPath, 'deno'); +const denoBinaryName = process.platform === 'win32' ? 'deno.exe' : 'deno'; +const denoBinaryPath = path.join(denoDir, denoBinaryName); + // extraResources (Contents/Resources on mac, the resources dir on Windows) // isn't reliably writable without elevation, so the updater could never swap // a fresh binary in there. Relocate to userData (always per-user-writable) @@ -88,26 +96,26 @@ const VIDEO_INFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // yt-dlp needs a real JS runtime to solve YouTube's nsig signature challenge; // without one, every real video/audio format silently disappears once a -// request is authenticated, leaving only storyboard formats. Rather than -// bundling a standalone JS runtime binary, this points yt-dlp's "node" -// provider at the Electron binary itself -- Electron already embeds a full -// Node.js runtime, and running it with ELECTRON_RUN_AS_NODE=1 (see -// ytdlpSpawnEnv below) makes it behave as a plain `node` executable for -// yt-dlp's purposes, at zero extra bundled bytes. +// request is authenticated, leaving only storyboard formats. Points yt-dlp's +// "deno" provider at the bundled Deno binary rather than running Electron +// itself as Node: Deno sandboxes by default (no fs/net/env/subprocess access +// unless explicitly granted, and yt-dlp's own deno.py adds zero --allow-* +// flags), closing a gap the Electron-as-node approach couldn't -- Node's own +// --permission model has no equivalent of network denial at all. Verified +// directly against a real nsig solve before switching (see +// 0tempFiles/deno-vs-electron-node-sandboxing-comparison.md). export function jsRuntimeArgs() { - return ['--js-runtimes', `node:${process.execPath}`]; + return ['--js-runtimes', `deno:${denoBinaryPath}`]; } -// Curated, not a full process.env copy -- the nsig-solving runtime -// (jsRuntimeArgs above) inherits this too, and Node's --permission sandbox -// doesn't gate env access, so a compromised solver script could otherwise -// read any secret a user's shell happens to have exported. HOME/ -// USERPROFILE/APPDATA/LOCALAPPDATA are kept because --cookies-from-browser -// (cookies.mjs) needs them to locate a browser's profile directory. +// Curated, not a full process.env copy -- Deno's own sandbox already denies +// env access by default, but this still matters for --cookies-from-browser +// (cookies.mjs), which needs HOME/USERPROFILE/APPDATA/LOCALAPPDATA to locate +// a browser's profile directory regardless of which JS runtime is in use. const YTDLP_ENV_ALLOWLIST = ['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PATH', 'TEMP', 'TMP', 'TMPDIR']; export function ytdlpSpawnEnv() { - const env = { ELECTRON_RUN_AS_NODE: '1' }; + const env = {}; for (const key of YTDLP_ENV_ALLOWLIST) { if (process.env[key] !== undefined) env[key] = process.env[key]; } diff --git a/src/electron/main.test.mjs b/src/electron/main.test.mjs index bd8b4f8..9aceaab 100644 --- a/src/electron/main.test.mjs +++ b/src/electron/main.test.mjs @@ -341,8 +341,11 @@ describe('assertValidHttpUrl', () => { }); describe('jsRuntimeArgs', () => { - it('points yt-dlp at the running Electron binary via the node provider', () => { - expect(jsRuntimeArgs()).toEqual(['--js-runtimes', `node:${process.execPath}`]); + it('points yt-dlp at the bundled Deno binary via the deno provider', () => { + const [flag, value] = jsRuntimeArgs(); + expect(flag).toBe('--js-runtimes'); + expect(value.startsWith('deno:')).toBe(true); + expect(value.endsWith(process.platform === 'win32' ? 'deno.exe' : 'deno')).toBe(true); }); }); @@ -351,8 +354,8 @@ describe('ytdlpSpawnEnv', () => { vi.unstubAllEnvs(); }); - it('always sets ELECTRON_RUN_AS_NODE, regardless of what is in process.env', () => { - expect(ytdlpSpawnEnv().ELECTRON_RUN_AS_NODE).toBe('1'); + it('does not set ELECTRON_RUN_AS_NODE -- that was only ever needed for the Electron-as-node runtime, not Deno', () => { + expect(ytdlpSpawnEnv().ELECTRON_RUN_AS_NODE).toBeUndefined(); }); it('does not leak arbitrary secrets from process.env through to the spawned env', () => { From 43cd64d54e95fca58755667d5a20aca86d44a482 Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 9 Sep 2026 20:42:15 -0700 Subject: [PATCH 03/12] feat(app): add select all on visible items for video and playlist views --- src/ui/components/PlaylistsSection.test.tsx | 109 ++++++++++++++++++++ src/ui/components/PlaylistsSection.tsx | 57 +++++++--- src/ui/screens/LibraryScreen.test.tsx | 92 +++++++++++++++++ src/ui/screens/LibraryScreen.tsx | 45 +++++++- 4 files changed, 288 insertions(+), 15 deletions(-) diff --git a/src/ui/components/PlaylistsSection.test.tsx b/src/ui/components/PlaylistsSection.test.tsx index 806d506..bfb0aa9 100644 --- a/src/ui/components/PlaylistsSection.test.tsx +++ b/src/ui/components/PlaylistsSection.test.tsx @@ -223,3 +223,112 @@ describe('PlaylistsSection bulk select', () => { expect(onBulkBarUpdate).toHaveBeenLastCalledWith(null); }); }); + +describe('PlaylistsSection select all', () => { + it('starts unchecked, and checking it selects every selectable entry', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + const onBulkBarUpdate = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByRole('checkbox', { name: 'Select Alpha Video' }); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + expect(selectAll).not.toBeChecked(); + + await user.click(selectAll); + + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Select Gamma Video' })).toBeChecked(); + expect(selectAll).toBeChecked(); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedCount: 2 }), + )); + }); + + it('shows indeterminate once some but not all selectable entries are selected individually, then checked once all are', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByRole('checkbox', { name: 'Select Alpha Video' }); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' })); + + // MUI's Checkbox never sets the native `input.indeterminate` IDL + // property -- it only reflects the indeterminate prop via + // `aria-checked="mixed"` (plus a `data-indeterminate` attribute used + // purely for styling). Assert on the a11y state, not the DOM property. + expect(selectAll).toHaveAttribute('aria-checked', 'mixed'); + expect(selectAll).not.toBeChecked(); + + await user.click(screen.getByRole('checkbox', { name: 'Select Gamma Video' })); + + expect(selectAll).not.toHaveAttribute('aria-checked', 'mixed'); + expect(selectAll).toBeChecked(); + }); + + it('unchecking "Select all" clears the whole selection', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + const onBulkBarUpdate = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByRole('checkbox', { name: 'Select Alpha Video' }); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + await user.click(selectAll); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedCount: 2 }), + )); + + await user.click(selectAll); + + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Select Gamma Video' })).not.toBeChecked(); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedCount: 0 }), + )); + }); + + it('excludes unavailable entries from "select all"', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidB', title: 'Beta Video', unavailable: true })], + localFiles: { vidA: '/lib/Channel A/vidA', vidB: null }, + }), + }); + const onBulkBarUpdate = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByRole('checkbox', { name: 'Select Alpha Video' }); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + await user.click(selectAll); + + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).toBeChecked(); + expect(screen.queryByRole('checkbox', { name: 'Select Beta Video' })).not.toBeInTheDocument(); + // Only the one selectable entry counted -- an unavailable entry never + // enters the selection even though it's nominally "visible". + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedCount: 1 }), + )); + }); +}); diff --git a/src/ui/components/PlaylistsSection.tsx b/src/ui/components/PlaylistsSection.tsx index cf8160b..f4379c2 100644 --- a/src/ui/components/PlaylistsSection.tsx +++ b/src/ui/components/PlaylistsSection.tsx @@ -119,6 +119,8 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: // already-in-library and not-yet-added ones. const selectableEntries = (selectedPlaylist?.entries || []).filter((e) => !e.unavailable); const selectedEntries = selectableEntries.filter((e) => selectedVideoIds.has(e.videoId)); + const allVisibleSelected = selectableEntries.length > 0 && selectableEntries.every((e) => selectedVideoIds.has(e.videoId)); + const someVisibleSelected = selectableEntries.some((e) => selectedVideoIds.has(e.videoId)); // An in-library entry's downloaded quality comes from its own epochs; a // not-yet-added entry (no videoDir) has nothing downloaded by definition. // Backs both the bulk-download gating below and each row's quality chip. @@ -423,6 +425,26 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: ) : ( + <> + {selectableEntries.length > 0 && + + Select all + {/* Bare Checkbox in the same fixed-size Box used per-row below + (not FormControlLabel, whose built-in margins would throw + off the alignment) so this checkbox sits in the exact same + column as the per-row ones. */} + + setSelectedVideoIds(allVisibleSelected ? new Set() : new Set(selectableEntries.map((e) => e.videoId)))} + inputProps={{ 'aria-label': 'Select all' }} + /> + + {/* Empty slot mirroring each row's "go to library" button slot below. */} + + } {selectedPlaylist?.entries.map((entry) => { const videoDir = selectedPlaylist.localFiles[entry.videoId]; @@ -432,19 +454,27 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: key={entry.videoId} secondaryAction={ - {!entry.unavailable && - toggleVideoSelected(entry.videoId)} - inputProps={{ 'aria-label': `Select ${entry.title || entry.videoId}` }} - />} - {videoDir && - - - - - } + {/* Both slots are always rendered (blank when not + applicable) so the checkbox column stays aligned + across rows regardless of whether an entry is + unavailable or lacks a library link. */} + + {!entry.unavailable && + toggleVideoSelected(entry.videoId)} + inputProps={{ 'aria-label': `Select ${entry.title || entry.videoId}` }} + />} + + + {videoDir && + + + + + } + } > @@ -477,6 +507,7 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: ); })} + )} { }); }); + describe('select all (flat video list)', () => { + it('starts unchecked, and checking it selects every currently visible video', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + render(); + await screen.findByText('Alpha Video'); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + expect(selectAll).not.toBeChecked(); + + await user.click(selectAll); + + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Select Beta Video' })).toBeChecked(); + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + expect(selectAll).toBeChecked(); + }); + + it('shows indeterminate once some but not all visible videos are selected individually, then checked once all are', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + render(); + await screen.findByText('Alpha Video'); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' })); + + // MUI's Checkbox never sets the native `input.indeterminate` IDL + // property -- it only reflects the indeterminate prop via + // `aria-checked="mixed"` (plus a `data-indeterminate` attribute used + // purely for styling). Assert on the a11y state, not the DOM property. + expect(selectAll).toHaveAttribute('aria-checked', 'mixed'); + expect(selectAll).not.toBeChecked(); + + await user.click(screen.getByRole('checkbox', { name: 'Select Beta Video' })); + + expect(selectAll).not.toHaveAttribute('aria-checked', 'mixed'); + expect(selectAll).toBeChecked(); + }); + + it('unchecking "Select all" clears the whole selection', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + render(); + await screen.findByText('Alpha Video'); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + await user.click(selectAll); + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + + await user.click(selectAll); + + expect(screen.queryByText(/item.*selected/)).not.toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).not.toBeChecked(); + }); + + it('selects only the currently tag-filtered set, not the whole library', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA'] } }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'TVshows' })); + await user.keyboard('{Escape}'); + expect(screen.queryByText('Beta Video')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('checkbox', { name: 'Select all' })); + + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).toBeChecked(); + }); + }); + + describe('select all (channel video grid)', () => { + it('selects every video within the currently open channel only', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('Channel A')); + await screen.findByText('Alpha Video'); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + expect(selectAll).not.toBeChecked(); + + await user.click(selectAll); + + expect(screen.getByRole('checkbox', { name: 'Select Alpha Video' })).toBeChecked(); + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + }); + }); + describe('deep link with ?tag=', () => { it('switches to the linked sublibrary before resolving the video, when it differs from the active one', async () => { (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 52df76c..9a326b3 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -12,6 +12,7 @@ import { Chip, CircularProgress, FormControl, + FormControlLabel, IconButton, InputLabel, MenuItem, @@ -588,6 +589,7 @@ export default function LibraryScreen() { thumbnailSize={thumbnailSize} selectedVideoDirs={selectedVideoDirs} onToggleSelect={toggleVideoSelected} + onSelectAll={(dirs) => setSelectedVideoDirs(new Set(dirs))} onBack={() => { setSelectedChannel(null); clearSelection(); }} onSelectVideo={setSelectedVideo} onChannelsUpdated={handleChannelsUpdated} @@ -601,6 +603,7 @@ export default function LibraryScreen() { thumbnailSize={thumbnailSize} selectedVideoDirs={selectedVideoDirs} onToggleSelect={toggleVideoSelected} + onSelectAll={(dirs) => setSelectedVideoDirs(new Set(dirs))} onViewModeChange={handleViewModeChange} onSelectVideo={setSelectedVideo} onRefresh={handleRefresh} @@ -882,13 +885,19 @@ function VideoCard({ video, onSelect, channelLabel, selected, selectionActive, o ); } -function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selectedVideoDirs, onToggleSelect, onViewModeChange, onSelectVideo, onRefresh, videoTags }: { +function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selectedVideoDirs, onToggleSelect, onSelectAll, onViewModeChange, onSelectVideo, onRefresh, videoTags }: { channels: LibraryChannel[]; openFolderDir: string; viewMode: LibraryViewMode; thumbnailSize: number; selectedVideoDirs: Set; onToggleSelect: (videoDir: string) => void; + // Replaces the whole selection at once with exactly the given videoDirs + // -- unlike onToggleSelect, which only ever adds/removes one item. Used + // by the "Select all" checkbox below to select exactly what's currently + // visible (search + tag filter applied), not a union with whatever was + // selected before. + onSelectAll: (videoDirs: string[]) => void; onViewModeChange: (mode: LibraryViewMode) => void; onSelectVideo: (video: LibraryVideo) => void; onRefresh: () => void; @@ -951,12 +960,28 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec ({ video }) => video.metadata.title || video.videoFolderName, ); + // "All"/"some" are both scoped to filtered -- exactly what's currently + // visible, after search + tag filter -- not the full unfiltered library. + const allVisibleSelected = filtered.length > 0 && filtered.every(({ video }) => selectedVideoDirs.has(video.videoDir)); + const someVisibleSelected = filtered.some(({ video }) => selectedVideoDirs.has(video.videoDir)); + return ( Library + onSelectAll(allVisibleSelected ? [] : filtered.map(({ video }) => video.videoDir))} + /> + } + label="Select all" + /> @@ -1111,11 +1136,14 @@ function ChannelList({ channels, openFolderDir, viewMode, onViewModeChange, onSe ); } -function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect, onBack, onSelectVideo, onChannelsUpdated, videoTags }: { +function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect, onSelectAll, onBack, onSelectVideo, onChannelsUpdated, videoTags }: { channel: LibraryChannel; thumbnailSize: number; selectedVideoDirs: Set; onToggleSelect: (videoDir: string) => void; + // Same "replace the whole selection with exactly what's visible" contract + // as FlatVideoList's own onSelectAll. + onSelectAll: (videoDirs: string[]) => void; onBack: () => void; onSelectVideo: (video: LibraryVideo) => void; onChannelsUpdated: (channels: LibraryChannel[]) => void; @@ -1127,6 +1155,8 @@ function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect, channel.videos, (video) => video.metadata.title || video.videoFolderName, ); + const allVisibleSelected = filtered.length > 0 && filtered.every((video) => selectedVideoDirs.has(video.videoDir)); + const someVisibleSelected = filtered.some((video) => selectedVideoDirs.has(video.videoDir)); const handleRefreshIcon = async () => { setRefreshingIcon(true); @@ -1155,6 +1185,17 @@ function VideoGrid({ channel, thumbnailSize, selectedVideoDirs, onToggleSelect, + onSelectAll(allVisibleSelected ? [] : filtered.map((video) => video.videoDir))} + /> + } + label="Select all" + /> From b597923bbec283aef80356421c81a29cce2e3f04 Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 9 Sep 2026 21:44:52 -0700 Subject: [PATCH 04/12] feat(app): implement tag feature to playlist and improve tag search filters --- src/ui/components/PlaylistsSection.test.tsx | 163 +++++++++++++++ src/ui/components/PlaylistsSection.tsx | 218 ++++++++++++++++---- src/ui/components/TagFilterPopover.tsx | 35 +++- src/ui/screens/LibraryScreen.test.tsx | 71 ++++++- src/ui/screens/LibraryScreen.tsx | 33 ++- 5 files changed, 464 insertions(+), 56 deletions(-) diff --git a/src/ui/components/PlaylistsSection.test.tsx b/src/ui/components/PlaylistsSection.test.tsx index bfb0aa9..a97563d 100644 --- a/src/ui/components/PlaylistsSection.test.tsx +++ b/src/ui/components/PlaylistsSection.test.tsx @@ -49,6 +49,8 @@ beforeEach(() => { deleteLibraryEntries: vi.fn().mockResolvedValue({ success: true, results: [] }), deleteLocalFiles: vi.fn().mockResolvedValue({ success: true, results: [] }), getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }), + listVideoTags: vi.fn().mockResolvedValue({ tags: {} }), + tagVideos: vi.fn().mockResolvedValue({ success: true, tags: {} }), }; window.electronAPIPythonDownload = { startDownloadPython: vi.fn(), @@ -332,3 +334,164 @@ describe('PlaylistsSection select all', () => { )); }); }); + +describe('PlaylistsSection tag/system filter', () => { + it('shows each entry\'s applied tags as chips', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ + tags: { TVshows: ['vidA'] }, + }); + render(); + await userEvent.setup().click(await screen.findByText('My Playlist')); + await screen.findByText('Alpha Video'); + + expect(screen.getByText('TVshows')).toBeInTheDocument(); + // Gamma carries no tags -- no stray chip rendered for it. + expect(await screen.findByText('Gamma Video')).toBeInTheDocument(); + }); + + it('stays reachable even with zero visible entries, and narrows the list by tag (AND semantics)', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ + tags: { TVshows: ['vidA'], games: ['vidC'] }, + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'TVshows' })); + + expect(screen.getByText('Alpha Video')).toBeInTheDocument(); + expect(screen.queryByText('Gamma Video')).not.toBeInTheDocument(); + + // AND semantics -- neither entry carries both tags, so this narrows to + // nothing rather than widening. + await user.click(screen.getByRole('checkbox', { name: 'games' })); + expect(screen.queryByText('Alpha Video')).not.toBeInTheDocument(); + expect(screen.queryByText('Gamma Video')).not.toBeInTheDocument(); + expect(screen.getByText('No videos match the selected filter.')).toBeInTheDocument(); + + // Filter button/popover stay reachable with the list empty, so the + // filter can still be cleared. + await user.click(screen.getByRole('button', { name: 'Clear filter' })); + expect(await screen.findByText('Gamma Video')).toBeInTheDocument(); + }); + + it('Downloaded / Not Downloaded narrow the list and compose with a tag filter', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: '/lib/Channel A/vidC' }, + }), + }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ + channels: [{ + channelFolderName: 'Channel A', displayName: 'Channel A', channelIconPath: null, + videos: [ + makeLibraryVideo({ epochs: [{ epoch: '1', metadata: { downloadedFilePath: '/lib/Channel A/vidA/1/video.mp4', downloadedResolution: '1080' } }] }), + makeLibraryVideo({ videoFolderName: 'vidC', videoDir: '/lib/Channel A/vidC', metadata: { videoId: 'vidC', channelId: 'UC1', channel: 'Channel A', title: 'Gamma Video' } }), + ], + }], + }); + (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ + tags: { TVshows: ['vidA', 'vidC'] }, + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'Downloaded' })); + + expect(screen.getByText('Alpha Video')).toBeInTheDocument(); + expect(screen.queryByText('Gamma Video')).not.toBeInTheDocument(); + + // Both carry TVshows -- combining with "Downloaded" should stay + // narrowed to just Alpha (AND semantics). + await user.click(screen.getByRole('checkbox', { name: 'TVshows' })); + expect(screen.getByText('Alpha Video')).toBeInTheDocument(); + expect(screen.queryByText('Gamma Video')).not.toBeInTheDocument(); + }); + + it('a manually-selected entry that gets filtered out stays selected', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ + tags: { TVshows: ['vidA'] }, + }); + const onBulkBarUpdate = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Gamma Video'); + + await user.click(screen.getByRole('checkbox', { name: 'Select Gamma Video' })); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedCount: 1 }), + )); + + // Filtering Gamma out of view doesn't drop it from the selection -- + // bulk actions still target it. + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'TVshows' })); + expect(screen.queryByText('Gamma Video')).not.toBeInTheDocument(); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ selectedCount: 1 }), + )); + }); +}); + +describe('PlaylistsSection bulk tag', () => { + it('"Tag selected" is enabled only once an in-library entry is selected, and calls tagVideos with just that entry', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, // vidC never added to the library + }), + }); + const onBulkBarUpdate = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByRole('checkbox', { name: 'Select Alpha Video' }); + + // Selecting only the not-yet-added entry -- nothing taggable yet. + await user.click(screen.getByRole('checkbox', { name: 'Select Gamma Video' })); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ canTag: false }), + )); + + // Adding the in-library entry makes it taggable. + await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' })); + await waitFor(() => expect(onBulkBarUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ canTag: true }), + )); + + const lastBar = onBulkBarUpdate.mock.calls[onBulkBarUpdate.mock.calls.length - 1][0]; + lastBar.onTagSelected(); + expect(await screen.findByText('Tag 1 selected video with an existing tag, or create a new one.')).toBeInTheDocument(); + + await user.type(screen.getByRole('combobox'), 'TVshows'); + await user.click(screen.getByRole('button', { name: 'Tag' })); + + // Only the in-library entry (vidA) is passed -- vidC has nothing to tag yet. + await waitFor(() => expect(window.electronAPI.tagVideos).toHaveBeenCalledWith(['vidA'], 'TVshows')); + }); +}); diff --git a/src/ui/components/PlaylistsSection.tsx b/src/ui/components/PlaylistsSection.tsx index f4379c2..4f7d2b6 100644 --- a/src/ui/components/PlaylistsSection.tsx +++ b/src/ui/components/PlaylistsSection.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router'; import { Alert, Avatar, + Badge, Box, Button, Card, @@ -33,10 +34,14 @@ import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; import LinkIcon from '@mui/icons-material/Link'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import { pink } from '@mui/material/colors'; import { convertYYYYMMDDStringToDate, buildAppVideoUrl, formatEpochLabel, getBestDownloadedQuality } from '../../utils/utils.ts'; import LibrarySearchBar from './LibrarySearchBar'; import BulkDownloadQualityDialog from './BulkDownloadQualityDialog'; import BulkDeleteConfirmDialog from './BulkDeleteConfirmDialog'; +import TagFilterPopover, { type SystemFilterKey } from './TagFilterPopover'; +import TagSelectedDialog from './TagSelectedDialog'; import { useLibrarySearch } from '../hooks/useLibrarySearch.tsx'; import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx'; import type { PlaylistSummary, PlaylistSnapshot, LibraryVideoMetadata } from '../../types'; @@ -48,6 +53,8 @@ export type PlaylistBulkBar = { canDeleteLocalFiles: boolean; onDeleteLocalFiles: () => void; onDeleteFromLibrary: () => void; + canTag: boolean; + onTagSelected: () => void; }; // Mirrors library.mjs's own CURRENT_PLAYLIST_SCHEMA_VERSION (main process @@ -96,6 +103,13 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: const [bulkDeleteLocalFilesDialogOpen, setBulkDeleteLocalFilesDialogOpen] = useState(false); const [bulkDeletingLocalFiles, setBulkDeletingLocalFiles] = useState(false); const [bulkDeleteLocalFilesError, setBulkDeleteLocalFilesError] = useState(null); + const [videoTags, setVideoTags] = useState>({}); + const [filterAnchorEl, setFilterAnchorEl] = useState(null); + const [selectedTagFilters, setSelectedTagFilters] = useState>(new Set()); + const [selectedSystemFilters, setSelectedSystemFilters] = useState>(new Set()); + const [tagDialogOpen, setTagDialogOpen] = useState(false); + const [tagging, setTagging] = useState(false); + const [tagError, setTagError] = useState(null); const { start } = useBulkAddQueue(); const { query, setQuery, isSearching, filtered: filteredPlaylists, clear } = useLibrarySearch( playlists, @@ -114,13 +128,23 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: }); }; const clearSelection = () => setSelectedVideoIds(new Set()); + const toggleTagFilter = (tag: string, checked: boolean) => { + setSelectedTagFilters((prev) => { + const next = new Set(prev); + if (checked) next.add(tag); + else next.delete(tag); + return next; + }); + }; + const toggleSystemFilter = (key: SystemFilterKey, checked: boolean) => { + setSelectedSystemFilters((prev) => { + const next = new Set(prev); + if (checked) next.add(key); + else next.delete(key); + return next; + }); + }; - // Selectable entries are every entry not confirmed unavailable -- both - // already-in-library and not-yet-added ones. - const selectableEntries = (selectedPlaylist?.entries || []).filter((e) => !e.unavailable); - const selectedEntries = selectableEntries.filter((e) => selectedVideoIds.has(e.videoId)); - const allVisibleSelected = selectableEntries.length > 0 && selectableEntries.every((e) => selectedVideoIds.has(e.videoId)); - const someVisibleSelected = selectableEntries.some((e) => selectedVideoIds.has(e.videoId)); // An in-library entry's downloaded quality comes from its own epochs; a // not-yet-added entry (no videoDir) has nothing downloaded by definition. // Backs both the bulk-download gating below and each row's quality chip. @@ -130,9 +154,43 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: return video ? getBestDownloadedQuality(video.epochs) : null; }; const isEntryDownloaded = (videoId: string) => getEntryQuality(videoId) !== null; + + // Entries currently on screen, after the tag/downloaded filters below -- + // used only for rendering and for what "select all" targets. Manual + // per-item selections are tracked separately (against the unfiltered set, + // see allSelectableEntries) so narrowing the filter never silently drops + // an already-selected video out of a pending bulk action. + const filteredEntries = useMemo(() => { + const entries = selectedPlaylist?.entries || []; + return entries.filter((e) => { + if (selectedSystemFilters.has('downloaded') && !isEntryDownloaded(e.videoId)) return false; + if (selectedSystemFilters.has('notDownloaded') && isEntryDownloaded(e.videoId)) return false; + if (selectedTagFilters.size > 0 && ![...selectedTagFilters].every((tag) => videoTags[tag]?.includes(e.videoId))) return false; + return true; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedPlaylist, selectedSystemFilters, selectedTagFilters, videoTags, videoByDir]); + + // Selectable entries are every entry not confirmed unavailable -- both + // already-in-library and not-yet-added ones. Unfiltered: what a bulk + // action actually operates on regardless of the current filter. + const allSelectableEntries = (selectedPlaylist?.entries || []).filter((e) => !e.unavailable); + const selectedEntries = allSelectableEntries.filter((e) => selectedVideoIds.has(e.videoId)); + // Filtered: exactly what's visible right now, what "select all" targets. + const visibleSelectableEntries = filteredEntries.filter((e) => !e.unavailable); + const allVisibleSelected = visibleSelectableEntries.length > 0 && visibleSelectableEntries.every((e) => selectedVideoIds.has(e.videoId)); + const someVisibleSelected = visibleSelectableEntries.some((e) => selectedVideoIds.has(e.videoId)); + const canBulkDownload = selectedEntries.length > 0 && selectedEntries.every((e) => !isEntryDownloaded(e.videoId)); // Mirror image of canBulkDownload -- all-or-nothing, same gating style. const canDeleteLocalFiles = selectedEntries.length > 0 && selectedEntries.every((e) => isEntryDownloaded(e.videoId)); + // Only entries actually in the library have a taggable videoId -- a + // not-yet-added entry has nothing to attach the tag to yet, same + // reasoning as deletableSelectedVideoDirs below. + const taggableSelectedVideoIds = selectedEntries + .filter((e) => !!selectedPlaylist?.localFiles[e.videoId]) + .map((e) => e.videoId); + const canTag = taggableSelectedVideoIds.length > 0; const handleConfirmBulkDownload = (targetResolution: string) => { const isMp3 = targetResolution.toLowerCase() === 'mp3'; @@ -181,7 +239,7 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: setBulkDeleteError(`${failedDirs.size} of ${deletableSelectedVideoDirs.length} video(s) couldn't be deleted. Try again, or delete them individually.`); setSelectedVideoIds((prev) => { const next = new Set(); - for (const entry of selectableEntries) { + for (const entry of allSelectableEntries) { const dir = selectedPlaylist?.localFiles[entry.videoId]; if (prev.has(entry.videoId) && dir && failedDirs.has(dir)) next.add(entry.videoId); } @@ -207,7 +265,7 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: setBulkDeleteLocalFilesError(`${failedDirs.size} of ${deletableSelectedVideoDirs.length} video(s) couldn't be updated. Try again, or delete them individually.`); setSelectedVideoIds((prev) => { const next = new Set(); - for (const entry of selectableEntries) { + for (const entry of allSelectableEntries) { const dir = selectedPlaylist?.localFiles[entry.videoId]; if (prev.has(entry.videoId) && dir && failedDirs.has(dir)) next.add(entry.videoId); } @@ -235,9 +293,11 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: canDeleteLocalFiles, onDeleteLocalFiles: () => setBulkDeleteLocalFilesDialogOpen(true), onDeleteFromLibrary: () => setBulkDeleteDialogOpen(true), + canTag, + onTagSelected: () => setTagDialogOpen(true), }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedPlaylistId, detailLoading, selectedVideoIds, canBulkDownload, canDeleteLocalFiles]); + }, [selectedPlaylistId, detailLoading, selectedVideoIds, canBulkDownload, canDeleteLocalFiles, canTag]); // Reported bar has to be torn down on unmount too -- otherwise LibraryScreen // keeps rendering a bottom bar for a PlaylistsSection that's no longer there @@ -255,11 +315,17 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: loadList(); }, []); + const refreshVideoTags = async () => { + const { tags } = await window.electronAPI.listVideoTags(); + setVideoTags(tags); + }; + const loadDetail = async (playlistId: string) => { setDetailLoading(true); const [{ playlist }, index] = await Promise.all([ window.electronAPI.getPlaylist(playlistId), window.electronAPI.getLibraryIndex(), + refreshVideoTags(), ]); setSelectedPlaylist(playlist); const map = new Map(); @@ -272,6 +338,19 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: setDetailLoading(false); }; + const handleConfirmTag = async (tagName: string) => { + setTagging(true); + setTagError(null); + try { + await window.electronAPI.tagVideos(taggableSelectedVideoIds, tagName); + await refreshVideoTags(); + setTagDialogOpen(false); + clearSelection(); + } finally { + setTagging(false); + } + }; + const handleSelectPlaylist = (playlistId: string) => { setSelectedPlaylistId(playlistId); setActionError(null); @@ -426,29 +505,52 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: ) : ( <> - {selectableEntries.length > 0 && + {selectedPlaylist && selectedPlaylist.entries.length > 0 && - Select all - {/* Bare Checkbox in the same fixed-size Box used per-row below - (not FormControlLabel, whose built-in margins would throw - off the alignment) so this checkbox sits in the exact same - column as the per-row ones. */} + {visibleSelectableEntries.length > 0 && + <> + Select all + {/* Bare Checkbox in the same fixed-size Box used per-row below + (not FormControlLabel, whose built-in margins would throw + off the alignment) so this checkbox sits in the exact same + column as the per-row ones. */} + + setSelectedVideoIds(allVisibleSelected ? new Set() : new Set(visibleSelectableEntries.map((e) => e.videoId)))} + inputProps={{ 'aria-label': 'Select all' }} + /> + + } + {/* Filter button lives in the same slot each row's "go to + library" button uses below, so it stays in that column + rather than floating loose -- and stays reachable even + once a filter hides every entry (gated on the total + entry count above, not the filtered one). */} - setSelectedVideoIds(allVisibleSelected ? new Set() : new Set(selectableEntries.map((e) => e.videoId)))} - inputProps={{ 'aria-label': 'Select all' }} - /> + + setFilterAnchorEl(e.currentTarget)} + aria-label="Filter by tag" + color={(selectedTagFilters.size > 0 || selectedSystemFilters.size > 0) ? 'primary' : 'default'} + > + + + + + - {/* Empty slot mirroring each row's "go to library" button slot below. */} - } + {selectedPlaylist && selectedPlaylist.entries.length > 0 && filteredEntries.length === 0 && + No videos match the selected filter.} - {selectedPlaylist?.entries.map((entry) => { - const videoDir = selectedPlaylist.localFiles[entry.videoId]; + {filteredEntries.map((entry) => { + const videoDir = selectedPlaylist?.localFiles[entry.videoId]; const entryQuality = getEntryQuality(entry.videoId); + const entryAppliedTags = Object.keys(videoTags).filter((name) => videoTags[name].includes(entry.videoId)); return ( - - {entry.title || Unknown title} - - {entryQuality ? ( - - ) : ( - - )} - {entry.unavailable && - } - + <> + + + {entry.title || Unknown title} + + {entryQuality ? ( + + ) : ( + + )} + {entry.unavailable && + } + + {entryAppliedTags.length > 0 && + + {entryAppliedTags.map((tag) => ( + + ))} + } + } secondary={entry.uploadDate ? convertYYYYMMDDStringToDate(entry.uploadDate) || entry.uploadDate : null} /> @@ -581,6 +691,26 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: onCancel={() => { setBulkDeleteLocalFilesDialogOpen(false); setBulkDeleteLocalFilesError(null); }} onConfirm={handleConfirmBulkDeleteLocalFiles} /> + setFilterAnchorEl(null)} + allTags={Object.keys(videoTags)} + selectedTags={selectedTagFilters} + onToggle={toggleTagFilter} + selectedSystemFilters={selectedSystemFilters} + onToggleSystemFilter={toggleSystemFilter} + onClear={() => { setSelectedTagFilters(new Set()); setSelectedSystemFilters(new Set()); }} + /> + setTagDialogOpen(false)} + count={taggableSelectedVideoIds.length} + options={Object.keys(videoTags)} + tagging={tagging} + error={tagError} + onConfirm={handleConfirmTag} + /> ); } diff --git a/src/ui/components/TagFilterPopover.tsx b/src/ui/components/TagFilterPopover.tsx index fcf1ce7..bb6c81c 100644 --- a/src/ui/components/TagFilterPopover.tsx +++ b/src/ui/components/TagFilterPopover.tsx @@ -2,24 +2,39 @@ import { Box, Button, Checkbox, + Divider, FormControlLabel, FormGroup, Popover, Typography, } from '@mui/material'; +// Fixed (not dynamic like tags), so declared once here rather than +// generated per-caller. Both entries AND together with each other and with +// any selected tags -- checking both at once is a valid, if useless, +// combination (matches "0 results" rather than being blocked). +export type SystemFilterKey = 'downloaded' | 'notDownloaded'; +export const SYSTEM_FILTER_OPTIONS: { key: SystemFilterKey; label: string }[] = [ + { key: 'downloaded', label: 'Downloaded' }, + { key: 'notDownloaded', label: 'Not Downloaded' }, +]; + // Pure selection state -- unlike VideoTagsPopover (which applies each // toggle immediately via setVideoTag, for one specific video), this has no // video of its own and no IPC call: the caller owns selectedTags and // narrows whatever list it's filtering. A video must carry every selected // tag to match (AND, not ANY), per the decided design. -export default function TagFilterPopover({ open, anchorEl, onClose, allTags, selectedTags, onToggle, onClear }: { +export default function TagFilterPopover({ + open, anchorEl, onClose, allTags, selectedTags, onToggle, selectedSystemFilters, onToggleSystemFilter, onClear, +}: { open: boolean; anchorEl: HTMLElement | null; onClose: () => void; allTags: string[]; selectedTags: Set; onToggle: (tagName: string, checked: boolean) => void; + selectedSystemFilters: Set; + onToggleSystemFilter: (key: SystemFilterKey, checked: boolean) => void; onClear: () => void; }) { return ( @@ -49,7 +64,23 @@ export default function TagFilterPopover({ open, anchorEl, onClose, allTags, sel ) : ( No tags yet in this sublibrary. )} - {selectedTags.size > 0 && + + + {SYSTEM_FILTER_OPTIONS.map(({ key, label }) => ( + onToggleSystemFilter(key, e.target.checked)} + /> + } + /> + ))} + + {(selectedTags.size > 0 || selectedSystemFilters.size > 0) && } diff --git a/src/ui/screens/LibraryScreen.test.tsx b/src/ui/screens/LibraryScreen.test.tsx index 59107ba..31e0603 100644 --- a/src/ui/screens/LibraryScreen.test.tsx +++ b/src/ui/screens/LibraryScreen.test.tsx @@ -640,7 +640,7 @@ describe('LibraryScreen', () => { await user.click(screen.getByRole('checkbox', { name: 'games' })); expect(screen.queryByText('Alpha Video')).not.toBeInTheDocument(); expect(screen.queryByText('Beta Video')).not.toBeInTheDocument(); - expect(screen.getByText('No videos match the selected tag filter.')).toBeInTheDocument(); + expect(screen.getByText('No videos match the selected filter.')).toBeInTheDocument(); }); it('the tag filter composes with search, narrowing within the already-filtered set', async () => { @@ -678,6 +678,75 @@ describe('LibraryScreen', () => { }); }); + describe('system filter (downloaded / not downloaded)', () => { + function makeChannelsWithOneDownloaded() { + const channels = makeChannels(); + channels[0].videos[0].epochs = [{ + epoch: '1', + metadata: { downloadedFilePath: '/lib/Channel A/vidA/1/video.mp4', downloadedResolution: '1080' }, + }]; + return channels; + } + + it('shows Downloaded and Not Downloaded checkboxes in the filter popover', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + + expect(screen.getByRole('checkbox', { name: 'Downloaded' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Not Downloaded' })).toBeInTheDocument(); + }); + + it('"Downloaded" narrows to only videos with a downloaded file', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ channels: makeChannelsWithOneDownloaded() }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'Downloaded' })); + + expect(screen.getByText('Alpha Video')).toBeInTheDocument(); + expect(screen.queryByText('Beta Video')).not.toBeInTheDocument(); + }); + + it('"Not Downloaded" narrows to only videos without a downloaded file', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ channels: makeChannelsWithOneDownloaded() }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'Not Downloaded' })); + + expect(screen.queryByText('Alpha Video')).not.toBeInTheDocument(); + expect(screen.getByText('Beta Video')).toBeInTheDocument(); + }); + + it('composes with a tag filter (AND semantics)', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ channels: makeChannelsWithOneDownloaded() }); + (window.electronAPI.listVideoTags as ReturnType).mockResolvedValue({ tags: { TVshows: ['vidA', 'vidB'] } }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Filter by tag' })); + await user.click(screen.getByRole('checkbox', { name: 'TVshows' })); + // Both carry the tag -- narrowing further by "Not Downloaded" should + // drop Alpha (downloaded), leaving only Beta. + await user.click(screen.getByRole('checkbox', { name: 'Not Downloaded' })); + + expect(screen.queryByText('Alpha Video')).not.toBeInTheDocument(); + expect(screen.getByText('Beta Video')).toBeInTheDocument(); + }); + }); + describe('select all (flat video list)', () => { it('starts unchecked, and checking it selects every currently visible video', async () => { const user = userEvent.setup(); diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 9a326b3..31f150f 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -47,7 +47,7 @@ import BulkDeleteConfirmDialog from '../components/BulkDeleteConfirmDialog'; import CreateSubLibraryDialog from '../components/CreateSubLibraryDialog'; import MoveToSubLibraryDialog from '../components/MoveToSubLibraryDialog'; import TagSelectedDialog from '../components/TagSelectedDialog'; -import TagFilterPopover from '../components/TagFilterPopover'; +import TagFilterPopover, { type SystemFilterKey } from '../components/TagFilterPopover'; import { useLibrarySearch } from '../hooks/useLibrarySearch.tsx'; import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx'; import type { LibraryVideoMetadata } from '../../types'; @@ -698,6 +698,8 @@ export default function LibraryScreen() { canDeleteLocalFiles={playlistBulkBar.canDeleteLocalFiles} onDeleteLocalFiles={playlistBulkBar.onDeleteLocalFiles} onDeleteFromLibrary={playlistBulkBar.onDeleteFromLibrary} + canTag={playlistBulkBar.canTag} + onTagSelected={playlistBulkBar.onTagSelected} />} (null); const [selectedFilterTags, setSelectedFilterTags] = useState>(new Set()); + const [selectedSystemFilters, setSelectedSystemFilters] = useState>(new Set()); const toggleFilterTag = (tag: string, checked: boolean) => { setSelectedFilterTags((prev) => { const next = new Set(prev); @@ -950,10 +953,20 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec return next; }); }; - const tagFilteredVideos = useMemo(() => { - if (selectedFilterTags.size === 0) return flatVideos; - return flatVideos.filter(({ video }) => [...selectedFilterTags].every((tag) => videoTags[tag]?.includes(video.metadata.videoId))); - }, [flatVideos, selectedFilterTags, videoTags]); + const toggleSystemFilter = (key: SystemFilterKey, checked: boolean) => { + setSelectedSystemFilters((prev) => { + const next = new Set(prev); + if (checked) next.add(key); + else next.delete(key); + return next; + }); + }; + const tagFilteredVideos = useMemo(() => flatVideos.filter(({ video }) => { + if (selectedSystemFilters.has('downloaded') && getBestDownloadedQuality(video.epochs) === null) return false; + if (selectedSystemFilters.has('notDownloaded') && getBestDownloadedQuality(video.epochs) !== null) return false; + if (selectedFilterTags.size > 0 && ![...selectedFilterTags].every((tag) => videoTags[tag]?.includes(video.metadata.videoId))) return false; + return true; + }), [flatVideos, selectedFilterTags, selectedSystemFilters, videoTags]); const { query, setQuery, isSearching, filtered, clear } = useLibrarySearch( tagFilteredVideos, @@ -989,9 +1002,9 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec size="small" onClick={(e) => setFilterAnchorEl(e.currentTarget)} aria-label="Filter by tag" - color={selectedFilterTags.size > 0 ? 'primary' : 'default'} + color={(selectedFilterTags.size > 0 || selectedSystemFilters.size > 0) ? 'primary' : 'default'} > - + @@ -1045,7 +1058,7 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec ) : filtered.length === 0 && ( - {isSearching ? `No videos match "${query}".` : 'No videos match the selected tag filter.'} + {isSearching ? `No videos match "${query}".` : 'No videos match the selected filter.'} )} setSelectedFilterTags(new Set())} + selectedSystemFilters={selectedSystemFilters} + onToggleSystemFilter={toggleSystemFilter} + onClear={() => { setSelectedFilterTags(new Set()); setSelectedSystemFilters(new Set()); }} /> {filtered.map(({ video, channelName }) => ( From f78904131de1a67b227bbeeb0eeea8b9a18feafb Mon Sep 17 00:00:00 2001 From: Sloth Date: Wed, 9 Sep 2026 22:18:44 -0700 Subject: [PATCH 05/12] fix(app): fix mp3 download error not showing up in the UI --- src/ui/screens/LibraryVideoDetail.test.tsx | 60 ++++++++++++++++++++ src/ui/screens/LibraryVideoDetail.tsx | 11 +++- src/ui/screens/VideoQualityDownload.tsx | 65 ++++++++++++++-------- 3 files changed, 111 insertions(+), 25 deletions(-) diff --git a/src/ui/screens/LibraryVideoDetail.test.tsx b/src/ui/screens/LibraryVideoDetail.test.tsx index 1f0ea1d..6e92ae7 100644 --- a/src/ui/screens/LibraryVideoDetail.test.tsx +++ b/src/ui/screens/LibraryVideoDetail.test.tsx @@ -190,6 +190,22 @@ describe('LibraryVideoDetail', () => { expect(window.electronAPI.openFileInDirectory).toHaveBeenCalledWith('/lib/Channel A/vidA/100/video.mp4'); }); + it('shows an error on the video quality picker when a video download fails, without bleeding into the audio section', async () => { + const video = makeVideo(); + renderDetail(video); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: /720p/ })); + emit({ type: 'error', payload: { message: 'network gone', kind: 'network' } as unknown as DownloadProgressMessage['payload'] }); + + expect(await screen.findByText('Download failed -- try again.')).toBeInTheDocument(); + // Only the video picker's message -- the Audio section's "Download + // MP3" button stays untouched, since this error belongs to the video + // flow, not audio. + expect(screen.getAllByText('Download failed -- try again.')).toHaveLength(1); + expect(screen.getByRole('button', { name: /Download MP3/ })).toBeInTheDocument(); + }); + it('runs the quality-swap flow, excluding the currently-downloaded resolution', async () => { const video = makeVideo({ downloadedFilePath: '/v/video.mp4', downloadedResolution: '480', downloadedFormat: 'dflt' }); (window.electronAPI.swapLibraryDownload as ReturnType).mockResolvedValue( @@ -248,6 +264,50 @@ describe('LibraryVideoDetail', () => { expect(await screen.findByRole('button', { name: 'Open audio file location' })).toBeInTheDocument(); }); + it('shows an error message when an MP3 download fails, and clears it on retry', async () => { + const video = makeVideo(); + renderDetail(video); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: /Download MP3/ })); + emit({ type: 'error', payload: { message: 'network gone', kind: 'network' } as unknown as DownloadProgressMessage['payload'] }); + + expect(await screen.findByText('Download failed -- try again.')).toBeInTheDocument(); + // The download button itself stays reachable so the user can retry -- + // it doesn't get replaced or hidden by the error. + expect(screen.getByRole('button', { name: /Download MP3/ })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Download MP3/ })); + expect(screen.queryByText('Download failed -- try again.')).not.toBeInTheDocument(); + }); + + it('shows a distinct "Cancelled" message when an MP3 download is cancelled', async () => { + const video = makeVideo(); + renderDetail(video); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: /Download MP3/ })); + emit({ type: 'error', payload: { message: 'Cancelled.', kind: 'cancelled' } as unknown as DownloadProgressMessage['payload'] }); + + expect(await screen.findByText('Cancelled.')).toBeInTheDocument(); + expect(screen.queryByText('Download failed -- try again.')).not.toBeInTheDocument(); + }); + + it('does not show the audio error on the video quality picker, and vice versa', async () => { + const video = makeVideo(); + renderDetail(video); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: /Download MP3/ })); + emit({ type: 'error', payload: { message: 'network gone', kind: 'network' } as unknown as DownloadProgressMessage['payload'] }); + + expect(await screen.findByText('Download failed -- try again.')).toBeInTheDocument(); + // Only one "Download failed" message should be showing (the audio + // one) -- the shared video ResolutionPicker must stay quiet, since this + // error belongs to the audio flow, not a video/swap download. + expect(screen.getAllByText('Download failed -- try again.')).toHaveLength(1); + }); + it('re-downloads the MP3 through the swap path when one already exists', async () => { const video = makeVideo({ downloadedAudioFilePath: '/v/audio.mp3' }); (window.electronAPI.swapLibraryDownload as ReturnType).mockResolvedValue( diff --git a/src/ui/screens/LibraryVideoDetail.tsx b/src/ui/screens/LibraryVideoDetail.tsx index 1207ab0..fecdfb3 100644 --- a/src/ui/screens/LibraryVideoDetail.tsx +++ b/src/ui/screens/LibraryVideoDetail.tsx @@ -657,6 +657,14 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on }; const isAudioActionActive = downloadTarget === 'audio' && !!selectedResolution && !isError; + // downloadTarget stays whatever the most recent startDownload call set it + // to (only the next call changes it), so these correctly attribute a + // shared isError to the flow that actually caused it -- video/swap and + // audio downloads share this one useDownloadVideo() instance, so without + // this split an audio download failure would also flash an unrelated + // "Download failed" on the video ResolutionPicker (and vice versa). + const isAudioError = downloadTarget === 'audio' && isError; + const isVideoError = downloadTarget === 'video' && isError; const isDownloading = downloadTarget === 'video' && !!selectedResolution && !isError && !metadata.downloadedFilePath && !swappingQuality; const isSwapDownloading = downloadTarget === 'video' && !!selectedResolution && !isError && swappingQuality; // Derived from the two flags above, not a standalone check, so it clears @@ -832,7 +840,7 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on selectedFormat={selectedFormat} onFormatChange={setSelectedFormat} selectedResolution={selectedResolution} - isError={isError} + isError={isVideoError} downloadErrorKind={downloadErrorKind} downloadStatus={downloadStatus} downloadProgress={downloadProgress} @@ -845,6 +853,7 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on onDownload={handleDownload} onSwapDownload={handleSwapDownload} isAudioActionActive={isAudioActionActive} + isAudioError={isAudioError} onOpenFileLocation={handleOpenFileLocation} onOpenExternally={handleOpenExternally} cacheBustKey={cacheBustKey} diff --git a/src/ui/screens/VideoQualityDownload.tsx b/src/ui/screens/VideoQualityDownload.tsx index 9a453aa..a2f19e3 100644 --- a/src/ui/screens/VideoQualityDownload.tsx +++ b/src/ui/screens/VideoQualityDownload.tsx @@ -161,6 +161,7 @@ export default function VideoQualityDownload({ onDownload, onSwapDownload, isAudioActionActive, + isAudioError, onOpenFileLocation, onOpenExternally, cacheBustKey, @@ -186,6 +187,11 @@ export default function VideoQualityDownload({ selectedFormat: string; onFormatChange: (format: string) => void; selectedResolution: string; + // Pre-scoped by the caller to the video/swap flow specifically (not the + // raw, shared useDownloadVideo() isError) -- see isAudioError below for + // why: video and audio downloads share one hook instance, so an + // unscoped isError would show on both ResolutionPickers at once whenever + // either flow failed. isError: boolean; downloadErrorKind: string | null; downloadStatus: string; @@ -199,6 +205,10 @@ export default function VideoQualityDownload({ onDownload: (resolution: string) => void; onSwapDownload: (resolution: string) => void; isAudioActionActive: boolean; + // True once an audio-flow download error is the *current* isError -- + // distinct from a video/swap download error, which shares the same + // isError/downloadErrorKind but is handled by ResolutionPicker above. + isAudioError: boolean; onOpenFileLocation: () => void; onOpenExternally: () => void; cacheBustKey: number; @@ -402,30 +412,37 @@ export default function VideoQualityDownload({ ) : ( - - - {isVideoDownloaded && - - - - - - - } + + {isAudioError && downloadErrorKind === 'cancelled' ? ( + Cancelled. + ) : isAudioError && ( + Download failed -- try again. + )} + + + {isVideoDownloaded && + + + + + + + } + )} From f6a12cce0a48e8b5e32e390d20741e1b9b31fdc5 Mon Sep 17 00:00:00 2001 From: Sloth Date: Thu, 10 Sep 2026 18:26:53 -0700 Subject: [PATCH 06/12] feat(app): add resume playback feature and setting to personalize whether or not it should trigger on library videos --- src/electron/library.mjs | 15 +- src/electron/library.test.mjs | 45 ++++- src/electron/main.mjs | 33 +++- src/electron/preload.cjs | 5 + src/electron/settings.mjs | 27 +-- src/types.ts | 1 + src/types/electron-api.d.ts | 5 + src/ui/components/LibraryVideoPlayer.tsx | 6 +- .../LibraryVideoPlayerWithTools.test.tsx | 187 +++++++++++++++++- .../LibraryVideoPlayerWithTools.tsx | 102 +++++++++- src/ui/screens/LibraryVideoDetail.tsx | 3 +- src/ui/screens/OptionsScreen.test.tsx | 4 + src/ui/screens/OptionsScreen.tsx | 51 +++++ 13 files changed, 453 insertions(+), 31 deletions(-) diff --git a/src/electron/library.mjs b/src/electron/library.mjs index 7e53e8c..52513b2 100644 --- a/src/electron/library.mjs +++ b/src/electron/library.mjs @@ -271,7 +271,7 @@ export function transferVideoTags(libraryDir, sourceTag, targetTag, videoIds) { // today" and surface a "this entry predates newer features, refresh it" // notice -- see LibraryVideoDetail.tsx/PlaylistsSection.tsx's own duplicated // copy of these two numbers. -export const CURRENT_VIDEO_SCHEMA_VERSION = 3; +export const CURRENT_VIDEO_SCHEMA_VERSION = 4; export const CURRENT_PLAYLIST_SCHEMA_VERSION = 1; // One cross-platform sanitizer using Windows' illegal-character set as the @@ -474,6 +474,7 @@ function buildEpochMetadata(videoMetaData, addedEpoch) { // MP3 is a separate, coexisting artifact -- its own slot (audio.mp3, // alongside video.), independent of the video fields above. downloadedAudioFilePath: null, + lastPlaybackPositionSeconds: null, }; } @@ -541,6 +542,7 @@ export function refreshLibraryEntryMetadata({ libraryDir, videoDir, epoch, video downloadedResolution: existing.downloadedResolution ?? null, downloadedFormat: existing.downloadedFormat ?? null, downloadedAudioFilePath: existing.downloadedAudioFilePath ?? null, + lastPlaybackPositionSeconds: existing.lastPlaybackPositionSeconds ?? null, }; fs.writeFileSync(metadataPath, JSON.stringify(merged, null, 2), 'utf-8'); return merged; @@ -565,6 +567,17 @@ export function recordLibraryDownload({ videoDir, epoch, filePath, resolution, f return metadata; } +// Deliberately doesn't refresh the in-memory library index the way +// recordLibraryDownload does -- this write is frequent and cheap, and +// nothing in the library grid reflects it. +export function savePlaybackPosition({ videoDir, epoch, positionSeconds }) { + const metadataPath = path.join(videoDir, epoch, 'metadata.json'); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + metadata.lastPlaybackPositionSeconds = positionSeconds; + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); + return metadata; +} + // "Download different quality" -- safety rule: never replace in place. // tempFilePath is wherever the just-completed download landed (a distinct // "video.new." path, never the live file's own path), so a diff --git a/src/electron/library.test.mjs b/src/electron/library.test.mjs index 7be51ff..bd594cf 100644 --- a/src/electron/library.test.mjs +++ b/src/electron/library.test.mjs @@ -9,7 +9,9 @@ import { videoFolderName, writeLibraryEntry, addLibraryVersion, + refreshLibraryEntryMetadata, recordLibraryDownload, + savePlaybackPosition, swapLibraryDownload, deleteLibraryEntry, deleteLocalFiles, @@ -146,12 +148,13 @@ describe('writeLibraryEntry', () => { expect(channelDir).toBe(path.join(libraryDir, DEFAULT_LIBRARY_DIR_NAME, 'Some Channel')); expect(videoDir).toBe(path.join(channelDir, 'abc123')); - expect(metadata.schemaVersion).toBe(3); + expect(metadata.schemaVersion).toBe(4); expect(metadata.videoId).toBe('abc123'); expect(metadata.channel).toBe('Some Channel'); expect(metadata.resolutions).toEqual([{ resolution: '720', filesizeMb: '10' }]); expect(metadata.downloadedFilePath).toBeNull(); expect(metadata.downloadedAudioFilePath).toBeNull(); + expect(metadata.lastPlaybackPositionSeconds).toBeNull(); expect(readMetadata(epochDir)).toEqual(metadata); }); @@ -225,6 +228,46 @@ describe('recordLibraryDownload', () => { }); }); +describe('savePlaybackPosition', () => { + it('writes just lastPlaybackPositionSeconds, leaving every other field untouched', () => { + const { videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); + const epoch = String(metadata.addedEpoch); + recordLibraryDownload({ videoDir, epoch, filePath: '/x/video.mp4', resolution: '720', format: 'mp4' }); + + const updated = savePlaybackPosition({ videoDir, epoch, positionSeconds: 42 }); + + expect(updated.lastPlaybackPositionSeconds).toBe(42); + expect(updated.downloadedFilePath).toBe('/x/video.mp4'); + expect(updated.downloadedResolution).toBe('720'); + expect(readMetadata(videoDir, epoch)).toEqual(updated); + }); + + it('overwrites a previously-saved position with the latest one', () => { + const { videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); + const epoch = String(metadata.addedEpoch); + savePlaybackPosition({ videoDir, epoch, positionSeconds: 10 }); + + const updated = savePlaybackPosition({ videoDir, epoch, positionSeconds: 55 }); + + expect(updated.lastPlaybackPositionSeconds).toBe(55); + }); +}); + +describe('refreshLibraryEntryMetadata', () => { + it('preserves a saved playback position across a metadata refresh', () => { + const { videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); + const epoch = String(metadata.addedEpoch); + savePlaybackPosition({ videoDir, epoch, positionSeconds: 77 }); + + const refreshed = refreshLibraryEntryMetadata({ + libraryDir, videoDir, epoch, videoMetaData: baseVideoMetaData({ title: 'Updated Title' }), + }); + + expect(refreshed.title).toBe('Updated Title'); + expect(refreshed.lastPlaybackPositionSeconds).toBe(77); + }); +}); + describe('swapLibraryDownload', () => { it('refuses to swap in a file outside the configured library folder', () => { const { videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 3e6af4d..9f02003 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -10,8 +10,8 @@ import os from 'node:os'; import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs'; import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs'; import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs'; -import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs'; -import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName } from './settings.mjs'; +import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs'; +import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT } from './settings.mjs'; import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; import { createFfmpegRunner } from './ffmpegUtils.mjs'; @@ -600,6 +600,30 @@ ipcMain.handle('settings:setThumbnailSize', async (e, value) => { return { success: true, thumbnailSize: settings.thumbnailSize }; }); +ipcMain.handle('settings:getResumeTrackingMode', async () => { + const { resumeTrackingMode } = readSettings(); + return { resumeTrackingMode: clampResumeTrackingMode(resumeTrackingMode ?? RESUME_TRACKING_MODE_DEFAULT) }; +}); + +ipcMain.handle('settings:setResumeTrackingMode', async (e, value) => { + const settings = readSettings(); + settings.resumeTrackingMode = clampResumeTrackingMode(value); + writeSettings(settings); + return { success: true, resumeTrackingMode: settings.resumeTrackingMode }; +}); + +ipcMain.handle('settings:getResumeMinDurationSeconds', async () => { + const { resumeMinDurationSeconds } = readSettings(); + return { resumeMinDurationSeconds: clampResumeMinDurationSeconds(resumeMinDurationSeconds ?? RESUME_MIN_DURATION_SECONDS_DEFAULT) }; +}); + +ipcMain.handle('settings:setResumeMinDurationSeconds', async (e, value) => { + const settings = readSettings(); + settings.resumeMinDurationSeconds = clampResumeMinDurationSeconds(value); + writeSettings(settings); + return { success: true, resumeMinDurationSeconds: settings.resumeMinDurationSeconds }; +}); + // User-added muxers for the Library view's "convert to" ffmpeg utility, // beyond the small hardcoded popular set (LibraryVideoDetail.tsx) -- a plain // string list, not validated against ffmpeg's own muxer list. @@ -953,6 +977,11 @@ ipcMain.handle('library:recordDownload', async (e, { videoDir, epoch, filePath, return { success: true }; }); +ipcMain.handle('library:savePlaybackPosition', async (e, { videoDir, epoch, positionSeconds }) => { + savePlaybackPosition({ videoDir, epoch, positionSeconds }); + return { success: true }; +}); + ipcMain.handle('library:swapDownload', async (e, { videoDir, epoch, tempFilePath, oldFilePath, resolution, format, kind }) => { const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); const metadata = swapLibraryDownload({ libraryDir, videoDir, epoch, tempFilePath, oldFilePath, resolution, format, kind }); diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index 29f7b36..16979a9 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -53,6 +53,10 @@ contextBridge.exposeInMainWorld('electronAPI', { setMaxSimultaneousDownloads: (value) => ipcRenderer.invoke('settings:setMaxSimultaneousDownloads', value), getThumbnailSize: () => ipcRenderer.invoke('settings:getThumbnailSize'), setThumbnailSize: (value) => ipcRenderer.invoke('settings:setThumbnailSize', value), + getResumeTrackingMode: () => ipcRenderer.invoke('settings:getResumeTrackingMode'), + setResumeTrackingMode: (value) => ipcRenderer.invoke('settings:setResumeTrackingMode', value), + getResumeMinDurationSeconds: () => ipcRenderer.invoke('settings:getResumeMinDurationSeconds'), + setResumeMinDurationSeconds: (value) => ipcRenderer.invoke('settings:setResumeMinDurationSeconds', value), getLibraryIndex: () => ipcRenderer.invoke('library:getIndex'), refreshLibraryIndex: () => ipcRenderer.invoke('library:refreshIndex'), refreshChannelIcon: (payload) => ipcRenderer.invoke('library:refreshChannelIcon', payload), @@ -70,6 +74,7 @@ contextBridge.exposeInMainWorld('electronAPI', { undoPlaylistRefresh: (playlistId) => ipcRenderer.invoke('library:undoPlaylistRefresh', playlistId), deletePlaylist: (playlistId) => ipcRenderer.invoke('library:deletePlaylist', playlistId), recordLibraryDownload: (payload) => ipcRenderer.invoke('library:recordDownload', payload), + savePlaybackPosition: (payload) => ipcRenderer.invoke('library:savePlaybackPosition', payload), swapLibraryDownload: (payload) => ipcRenderer.invoke('library:swapDownload', payload), deleteLibraryEntry: (videoDir, epoch) => ipcRenderer.invoke('library:deleteEntry', { videoDir, epoch }), deleteLibraryEntries: (videoDirs) => ipcRenderer.invoke('library:deleteEntries', { videoDirs }), diff --git a/src/electron/settings.mjs b/src/electron/settings.mjs index 338656c..eb7489b 100644 --- a/src/electron/settings.mjs +++ b/src/electron/settings.mjs @@ -21,10 +21,6 @@ export function createSettingsStore(settingsPath) { return { readSettings, writeSettings }; } -// Caps how many bulk-add items the renderer's queue (useBulkAddQueue.tsx) -// will download at once -- clamped here too, not just in the Options UI, -// since this value round-trips through a plain JSON settings file a user -// could hand-edit. export const MAX_SIMULTANEOUS_DOWNLOADS_CEILING = 5; export function clampMaxSimultaneousDownloads(value) { @@ -51,20 +47,12 @@ export function clampLibrarySortDirection(value) { // mode -- 'default' is this app's original plain-MUI look, 'slothui' is the // palette pulled from SlothArchiver-info's landing page (see theme.ts). export const THEME_NAMES = ['default', 'slothui']; -// SlothUI is the default for a fresh install -- 'default' (plain MUI) is -// still fully supported and one toggle away, just no longer what a new user -// sees before ever touching Options. export const THEME_NAME_DEFAULT = 'slothui'; export function clampThemeName(value) { return THEME_NAMES.includes(value) ? value : THEME_NAME_DEFAULT; } -// Bounds for the Library tab's thumbnail-size slider (LibraryBottomBar.tsx). -// 160px floor keeps a video card's title/quality-chip row from wrapping -// awkwardly; 360px ceiling still fits 2+ columns at typical content widths. -// 220px default approximates the old fixed sm:6/md:4 breakpoint sizing, so -// existing users see an unsurprising layout until they touch the slider. export const THUMBNAIL_SIZE_MIN = 160; export const THUMBNAIL_SIZE_MAX = 360; export const THUMBNAIL_SIZE_DEFAULT = 220; @@ -74,3 +62,18 @@ export function clampThumbnailSize(value) { if (!Number.isFinite(n)) return THUMBNAIL_SIZE_DEFAULT; return Math.min(Math.max(n, THUMBNAIL_SIZE_MIN), THUMBNAIL_SIZE_MAX); } + +export const RESUME_TRACKING_MODES = ['never', 'always', 'custom']; +export const RESUME_TRACKING_MODE_DEFAULT = 'custom'; + +export function clampResumeTrackingMode(value) { + return RESUME_TRACKING_MODES.includes(value) ? value : RESUME_TRACKING_MODE_DEFAULT; +} + +export const RESUME_MIN_DURATION_SECONDS_DEFAULT = 1200; + +export function clampResumeMinDurationSeconds(value) { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return RESUME_MIN_DURATION_SECONDS_DEFAULT; + return Math.floor(n); +} diff --git a/src/types.ts b/src/types.ts index 75ff6b1..2fcb5b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ export type LibraryVideoMetadata = { downloadedResolution: string | null; downloadedFormat: string | null; downloadedAudioFilePath: string | null; + lastPlaybackPositionSeconds: number | null; } // Video-level (not epoch-level) -- a clip is derived from whichever version diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index b3f34a2..5b266c9 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -106,6 +106,10 @@ declare global { setMaxSimultaneousDownloads: (value: number) => Promise<{ success: boolean; maxSimultaneousDownloads: number }> getThumbnailSize: () => Promise<{ thumbnailSize: number }> setThumbnailSize: (value: number) => Promise<{ success: boolean; thumbnailSize: number }> + getResumeTrackingMode: () => Promise<{ resumeTrackingMode: 'never' | 'always' | 'custom' }> + setResumeTrackingMode: (value: 'never' | 'always' | 'custom') => Promise<{ success: boolean; resumeTrackingMode: 'never' | 'always' | 'custom' }> + getResumeMinDurationSeconds: () => Promise<{ resumeMinDurationSeconds: number }> + setResumeMinDurationSeconds: (value: number) => Promise<{ success: boolean; resumeMinDurationSeconds: number }> getLibraryIndex: () => Promise refreshLibraryIndex: () => Promise refreshChannelIcon: (payload: { channelFolderName: string; channelId: string | null }) => Promise @@ -123,6 +127,7 @@ declare global { undoPlaylistRefresh: (playlistId: string) => Promise<{ success: boolean; metadata?: PlaylistSnapshot; message?: string }> deletePlaylist: (playlistId: string) => Promise<{ success: boolean; message?: string }> recordLibraryDownload: (payload: { videoDir: string; epoch: string; filePath: string; resolution: string; format?: string; kind?: 'video' | 'audio' }) => Promise<{ success: boolean }> + savePlaybackPosition: (payload: { videoDir: string; epoch: string; positionSeconds: number }) => Promise<{ success: boolean }> swapLibraryDownload: (payload: { videoDir: string; epoch: string; tempFilePath: string; oldFilePath: string | null; resolution: string; format?: string; kind?: 'video' | 'audio' }) => Promise deleteLibraryEntry: (videoDir: string, epoch?: string) => Promise<{ success: boolean; videoDeleted: boolean }> deleteLibraryEntries: (videoDirs: string[]) => Promise<{ success: boolean; results: { videoDir: string; success: boolean; error?: string }[] }> diff --git a/src/ui/components/LibraryVideoPlayer.tsx b/src/ui/components/LibraryVideoPlayer.tsx index 8141864..4f7da69 100644 --- a/src/ui/components/LibraryVideoPlayer.tsx +++ b/src/ui/components/LibraryVideoPlayer.tsx @@ -93,7 +93,8 @@ const LibraryVideoPlayer = forwardRef(function LibraryVideoPlayer({ metadata, thumbnailPath, cacheBustKey = 0, overrideFilePath, clipMarkers }, ref) { + onPlaybackStateChange?: (playing: boolean) => void; +}>(function LibraryVideoPlayer({ metadata, thumbnailPath, cacheBustKey = 0, overrideFilePath, clipMarkers, onPlaybackStateChange }, ref) { const { thumbnail, videoId } = metadata; const filePath = overrideFilePath ?? metadata.downloadedFilePath; const [state, setState] = useState(() => computeInitialState(filePath)); @@ -189,7 +190,8 @@ const LibraryVideoPlayer = forwardRef setState({ kind: 'runtimeFailed' })} - onPlay={() => setHasStartedPlayback(true)} + onPlay={() => { setHasStartedPlayback(true); onPlaybackStateChange?.(true); }} + onPause={() => onPlaybackStateChange?.(false)} > {/* Click-anywhere-on-the-video-to-toggle. Deliberately NOT diff --git a/src/ui/components/LibraryVideoPlayerWithTools.test.tsx b/src/ui/components/LibraryVideoPlayerWithTools.test.tsx index 755b3aa..449d7ab 100644 --- a/src/ui/components/LibraryVideoPlayerWithTools.test.tsx +++ b/src/ui/components/LibraryVideoPlayerWithTools.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { forwardRef, useImperativeHandle } from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import LibraryVideoPlayerWithTools from './LibraryVideoPlayerWithTools'; import type { ClipMarkersControl } from './LibraryVideoPlayer'; @@ -17,26 +17,35 @@ import type { LibraryVideoMetadata } from '../../types'; // exercise LibraryVideoPlayerWithTools's own logic (the state it derives, // which IPC calls it makes) in isolation from Vidstack entirely. let fakeCurrentTime = 0; +let seekToSpy: ReturnType; +let playSpy: ReturnType; vi.mock('./LibraryVideoPlayer', () => ({ __esModule: true, default: forwardRef(function FakeLibraryVideoPlayer( - { clipMarkers }: { clipMarkers?: ClipMarkersControl }, + { clipMarkers, onPlaybackStateChange }: { clipMarkers?: ClipMarkersControl; onPlaybackStateChange?: (playing: boolean) => void }, ref: React.ForwardedRef, ) { useImperativeHandle(ref, () => ({ getCurrentTime: () => fakeCurrentTime, getDuration: () => 100, - seekTo: () => {}, - play: () => {}, + seekTo: (seconds: number) => seekToSpy(seconds), + play: () => playSpy(), pause: () => {}, })); - if (!clipMarkers) return null; return (
- - - - + {clipMarkers && ( + <> + + + + + + )} + {/* Test-only triggers standing in for Vidstack's real onPlay/onPause -- see this + file's own comment above about not reverse-engineering Vidstack's reactive internals. */} + +
); }), @@ -76,6 +85,8 @@ async function openSaveClipDialog(user: ReturnType, star beforeEach(() => { fakeCurrentTime = 0; + seekToSpy = vi.fn(); + playSpy = vi.fn(); window.electronAPI = { ...window.electronAPI, createClip: vi.fn().mockResolvedValue({ @@ -85,6 +96,9 @@ beforeEach(() => { saveExportedFile: vi.fn().mockResolvedValue({ canceled: false, filePath: '/picked/My Clip.mp4' }), extractClipFromFile: vi.fn().mockResolvedValue({ success: true, outputPath: '/picked/My Clip.mp4' }), openFileInDirectory: vi.fn(), + getResumeTrackingMode: vi.fn().mockResolvedValue({ resumeTrackingMode: 'always' }), + getResumeMinDurationSeconds: vi.fn().mockResolvedValue({ resumeMinDurationSeconds: 1200 }), + savePlaybackPosition: vi.fn().mockResolvedValue({ success: true }), }; }); @@ -222,3 +236,156 @@ describe('LibraryVideoPlayerWithTools', () => { expect(screen.getByRole('button', { name: 'Save clip' })).toBeEnabled(); }); }); + +describe('LibraryVideoPlayerWithTools resume playback position', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('periodically saves the current position while playing', async () => { + render( + , + ); + await waitFor(() => expect(window.electronAPI.getResumeTrackingMode).toHaveBeenCalled()); + // waitFor above only confirms the settings mocks were *invoked* -- + // let their already-resolved promises actually flow into state before + // switching to fake timers, or shouldSavePosition would still read its + // initial false value once "Simulate play" fires. + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + vi.useFakeTimers(); + fakeCurrentTime = 30; + fireEvent.click(screen.getByRole('button', { name: 'Simulate play' })); + await vi.advanceTimersByTimeAsync(10_000); + + expect(window.electronAPI.savePlaybackPosition).toHaveBeenCalledWith({ videoDir: '/lib/c/v1', epoch: '1', positionSeconds: 30 }); + }); + + it('saves an immediate position on pause, without waiting for the next periodic tick', async () => { + render( + , + ); + await waitFor(() => expect(window.electronAPI.getResumeTrackingMode).toHaveBeenCalled()); + + fakeCurrentTime = 45; + fireEvent.click(screen.getByRole('button', { name: 'Simulate play' })); + fireEvent.click(screen.getByRole('button', { name: 'Simulate pause' })); + + await waitFor(() => expect(window.electronAPI.savePlaybackPosition).toHaveBeenCalledWith({ videoDir: '/lib/c/v1', epoch: '1', positionSeconds: 45 })); + }); + + it('does not track position at all in standaloneClipping mode', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Simulate play' })); + fireEvent.click(screen.getByRole('button', { name: 'Simulate pause' })); + + expect(window.electronAPI.getResumeTrackingMode).not.toHaveBeenCalled(); + expect(window.electronAPI.savePlaybackPosition).not.toHaveBeenCalled(); + expect(screen.queryByText('Resume where you left off?')).not.toBeInTheDocument(); + }); + + it('offers to resume when a meaningfully-saved position exists', async () => { + render( + , + ); + + expect(await screen.findByText('Resume where you left off?')).toBeInTheDocument(); + }); + + it('does not offer to resume a position at the very start', () => { + render( + , + ); + + expect(screen.queryByText('Resume where you left off?')).not.toBeInTheDocument(); + }); + + it('does not offer to resume a position right at the end (already finished)', () => { + render( + , + ); + + expect(screen.queryByText('Resume where you left off?')).not.toBeInTheDocument(); + }); + + it('does not offer to resume when nothing was ever saved', () => { + render( + , + ); + + expect(screen.queryByText('Resume where you left off?')).not.toBeInTheDocument(); + }); + + it('clicking "Resume" seeks to and plays from the saved position', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByRole('button', { name: 'Resume' })); + + expect(seekToSpy).toHaveBeenCalledWith(50); + expect(playSpy).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByText('Resume where you left off?')).not.toBeInTheDocument()); + }); + + it('dismissing the toast just closes it -- the saved position is left untouched', async () => { + const user = userEvent.setup(); + render( + , + ); + const alert = (await screen.findByText('Resume where you left off?')).closest('div[role="alert"]') as HTMLElement; + + await user.click(within(alert).getByRole('button', { name: 'Close' })); + + await waitFor(() => expect(screen.queryByText('Resume where you left off?')).not.toBeInTheDocument()); + expect(window.electronAPI.savePlaybackPosition).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/LibraryVideoPlayerWithTools.tsx b/src/ui/components/LibraryVideoPlayerWithTools.tsx index ad615ed..cd4b30b 100644 --- a/src/ui/components/LibraryVideoPlayerWithTools.tsx +++ b/src/ui/components/LibraryVideoPlayerWithTools.tsx @@ -1,10 +1,14 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Alert, Link, Snackbar } from '@mui/material'; import LibraryVideoPlayer, { type LibraryVideoPlayerHandle } from './LibraryVideoPlayer'; import SaveClipDialog from './SaveClipDialog'; import { formatSecondsAsClipTimestamp } from '../screens/FfmpegUtilitiesPanel'; import type { LibraryVideoMetadata, LibraryClip } from '../../types'; +const PLAYBACK_POSITION_SAVE_INTERVAL_MS = 10_000; +const RESUME_OFFER_MIN_SECONDS = 5; +const RESUME_OFFER_END_BUFFER_SECONDS = 5; + // Self-contained wrapper around LibraryVideoPlayer: owns the entire // clip-creation flow (start/end state, SaveClipDialog, the // createClip/extractClipFromFile IPC calls) so a caller just drops this in @@ -20,6 +24,10 @@ export type LibraryVideoPlayerWithToolsProps = { // permanently into this video's own clips/ folder + clips.json manifest. // Required in that mode; unused when standaloneClipping is true. videoDir?: string; + // Which epoch's metadata.json to write playback position into -- required + // alongside videoDir for the resume-position feature (see below), unused + // in standaloneClipping mode (a clip has no epoch identity of its own). + epoch?: string | null; existingClipTitles?: string[]; convertFormatOptions: string[]; // Unused in standaloneClipping mode (there's no LibraryClip record). @@ -40,7 +48,7 @@ function extensionForFormat(format: string, inputPath: string): string { } export default function LibraryVideoPlayerWithTools({ - metadata, thumbnailPath, cacheBustKey, overrideFilePath, videoDir, existingClipTitles, convertFormatOptions, onClipCreated, + metadata, thumbnailPath, cacheBustKey, overrideFilePath, videoDir, epoch, existingClipTitles, convertFormatOptions, onClipCreated, standaloneClipping = false, onClipSavedToFile, }: LibraryVideoPlayerWithToolsProps) { const playerRef = useRef(null); @@ -51,6 +59,76 @@ export default function LibraryVideoPlayerWithTools({ const [saveClipError, setSaveClipError] = useState(null); const [savedFilePath, setSavedFilePath] = useState(null); + const canTrackPosition = !standaloneClipping && !!videoDir && !!epoch; + const [resumeToastOpen, setResumeToastOpen] = useState(false); + const [shouldSavePosition, setShouldSavePosition] = useState(false); + const saveIntervalRef = useRef | null>(null); + + useEffect(() => { + if (!canTrackPosition) return; + let cancelled = false; + Promise.all([ + window.electronAPI.getResumeTrackingMode(), + window.electronAPI.getResumeMinDurationSeconds(), + ]).then(([{ resumeTrackingMode }, { resumeMinDurationSeconds }]) => { + if (cancelled) return; + const duration = metadata.duration; + const shouldTrack = resumeTrackingMode === 'always' + || (resumeTrackingMode === 'custom' && duration != null && duration >= resumeMinDurationSeconds); + setShouldSavePosition(shouldTrack); + }); + const position = metadata.lastPlaybackPositionSeconds; + if ( + typeof position === 'number' && position > RESUME_OFFER_MIN_SECONDS + && (metadata.duration == null || position < metadata.duration - RESUME_OFFER_END_BUFFER_SECONDS) + ) { + setResumeToastOpen(true); + } + return () => { cancelled = true; }; + // Deliberately mount-only per epoch -- this component's caller remounts + // it on every epoch switch (see LibraryVideoDetail.tsx's key={selectedEpoch}), + // so re-running this on every metadata change would re-offer the toast + // after the position that triggered it has already moved on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canTrackPosition]); + + const savePosition = (positionSeconds: number) => { + if (!canTrackPosition || !shouldSavePosition) return; + window.electronAPI.savePlaybackPosition({ videoDir: videoDir!, epoch: epoch!, positionSeconds: Math.floor(positionSeconds) }); + }; + + const handlePlaybackStateChange = (playing: boolean) => { + if (!canTrackPosition) return; + if (playing) { + if (saveIntervalRef.current) clearInterval(saveIntervalRef.current); + saveIntervalRef.current = setInterval(() => { + const time = playerRef.current?.getCurrentTime(); + if (time != null) savePosition(time); + }, PLAYBACK_POSITION_SAVE_INTERVAL_MS); + } else { + if (saveIntervalRef.current) { + clearInterval(saveIntervalRef.current); + saveIntervalRef.current = null; + } + const time = playerRef.current?.getCurrentTime(); + if (time != null) savePosition(time); + } + }; + + // Covers navigating away while still playing -- the interval above would + // otherwise just get torn down with no final save. + useEffect(() => () => { + if (saveIntervalRef.current) clearInterval(saveIntervalRef.current); + }, []); + + const handleResumePlayback = () => { + const position = metadata.lastPlaybackPositionSeconds; + setResumeToastOpen(false); + if (position == null) return; + playerRef.current?.seekTo(position); + playerRef.current?.play(); + }; + const inputPath = overrideFilePath ?? metadata.downloadedFilePath; const clipRangeInvalid = clipStartSeconds != null && clipEndSeconds != null && clipEndSeconds < clipStartSeconds + 1; const saveDisabled = !inputPath || clipStartSeconds == null || clipEndSeconds == null || clipRangeInvalid; @@ -139,6 +217,7 @@ export default function LibraryVideoPlayerWithTools({ thumbnailPath={thumbnailPath} cacheBustKey={cacheBustKey} overrideFilePath={overrideFilePath} + onPlaybackStateChange={handlePlaybackStateChange} clipMarkers={{ startSeconds: clipStartSeconds, endSeconds: clipEndSeconds, @@ -194,6 +273,25 @@ export default function LibraryVideoPlayerWithTools({
+ setResumeToastOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setResumeToastOpen(false)} severity="info" variant="filled"> + Resume where you left off? + + Resume + + + ); } diff --git a/src/ui/screens/LibraryVideoDetail.tsx b/src/ui/screens/LibraryVideoDetail.tsx index fecdfb3..71a41b3 100644 --- a/src/ui/screens/LibraryVideoDetail.tsx +++ b/src/ui/screens/LibraryVideoDetail.tsx @@ -50,7 +50,7 @@ type LibraryVideo = { // renderer never cross-import here). An entry whose stored schemaVersion is // older than this predates a metadata-shape change and won't have whatever // that change added -- "Refresh from YouTube" is what fixes it. -const CURRENT_VIDEO_SCHEMA_VERSION = 3; +const CURRENT_VIDEO_SCHEMA_VERSION = 4; export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, onDeleted, onVersionsChanged, videoTags, onVideoTagsChanged }: { video: LibraryVideo; @@ -805,6 +805,7 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on thumbnailPath={video.thumbnailPath} cacheBustKey={cacheBustKey} videoDir={video.videoDir} + epoch={selectedEpoch} existingClipTitles={clips.map((c) => c.title)} convertFormatOptions={convertFormatOptions} onClipCreated={(clip) => { diff --git a/src/ui/screens/OptionsScreen.test.tsx b/src/ui/screens/OptionsScreen.test.tsx index 19dc2fc..b84cfb9 100644 --- a/src/ui/screens/OptionsScreen.test.tsx +++ b/src/ui/screens/OptionsScreen.test.tsx @@ -35,6 +35,10 @@ beforeEach(() => { setThemeName: vi.fn().mockResolvedValue({ success: true, themeName: 'slothui' }), getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }), setMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ success: true, maxSimultaneousDownloads: 1 }), + getResumeTrackingMode: vi.fn().mockResolvedValue({ resumeTrackingMode: 'custom' }), + setResumeTrackingMode: vi.fn().mockResolvedValue({ success: true, resumeTrackingMode: 'custom' }), + getResumeMinDurationSeconds: vi.fn().mockResolvedValue({ resumeMinDurationSeconds: 1200 }), + setResumeMinDurationSeconds: vi.fn().mockResolvedValue({ success: true, resumeMinDurationSeconds: 1200 }), getAppVersion: vi.fn().mockResolvedValue('0.0.0'), }; }); diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index 0a9cf06..b2e7d1d 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -130,6 +130,8 @@ export default function OptionsScreen() { const [errorLogExists, setErrorLogExists] = useState(false); const [customConvertFormats, setCustomConvertFormatsState] = useState([]); const [maxSimultaneousDownloads, setMaxSimultaneousDownloadsState] = useState(1); + const [resumeTrackingMode, setResumeTrackingModeState] = useState<'never' | 'always' | 'custom'>('custom'); + const [resumeMinDurationSeconds, setResumeMinDurationSecondsState] = useState(1200); const [appVersion, setAppVersion] = useState(''); const refreshStatus = async () => { @@ -179,6 +181,16 @@ export default function OptionsScreen() { setMaxSimultaneousDownloadsState(maxSimultaneousDownloads); }; + const refreshResumeTrackingMode = async () => { + const { resumeTrackingMode } = await window.electronAPI.getResumeTrackingMode(); + setResumeTrackingModeState(resumeTrackingMode); + }; + + const refreshResumeMinDurationSeconds = async () => { + const { resumeMinDurationSeconds } = await window.electronAPI.getResumeMinDurationSeconds(); + setResumeMinDurationSecondsState(resumeMinDurationSeconds); + }; + useEffect(() => { refreshStatus(); refreshCookiesConfig(); @@ -187,6 +199,8 @@ export default function OptionsScreen() { refreshErrorLogInfo(); refreshCustomConvertFormats(); refreshMaxSimultaneousDownloads(); + refreshResumeTrackingMode(); + refreshResumeMinDurationSeconds(); window.electronAPI.getAppVersion().then(setAppVersion); }, []); @@ -217,6 +231,16 @@ export default function OptionsScreen() { await window.electronAPI.setMaxSimultaneousDownloads(value); }; + const handleResumeTrackingModeChange = async (value: 'never' | 'always' | 'custom') => { + setResumeTrackingModeState(value); + await window.electronAPI.setResumeTrackingMode(value); + }; + + const handleResumeMinDurationSecondsChange = async (value: number) => { + setResumeMinDurationSecondsState(value); + await window.electronAPI.setResumeMinDurationSeconds(value); + }; + const handleChooseDownloadDir = async () => { // Electron 43+ opens unset-defaultPath dialogs at Downloads instead of // remembering the last-picked folder -- pass the current setting @@ -433,6 +457,33 @@ export default function OptionsScreen() { your connection as a bot. Keep this low if you run into that. + + + Save Playback Position + + Remembers where you stopped a video so you can pick it back up later. + + handleResumeTrackingModeChange(e.target.value as 'never' | 'always' | 'custom')} + sx={{ minWidth: 160, mb: 2, display: 'block' }} + > + Never + Always + Videos longer than... + + {resumeTrackingMode === 'custom' && + handleResumeMinDurationSecondsChange(Math.max(0, Number(e.target.value)) * 60)} + sx={{ minWidth: 120 }} + />} + From a54cb86f213b2ce536c813d0804eaad52142b92d Mon Sep 17 00:00:00 2001 From: Sloth Date: Thu, 10 Sep 2026 19:18:11 -0700 Subject: [PATCH 07/12] feat(app): add optio to always embed metadata on downloads, by default it's set as enabled in the options menu --- src/electron/library.mjs | 18 +++-- src/electron/library.test.mjs | 24 +++++++ src/electron/main.mjs | 72 +++++++++++++++++-- src/electron/preload.cjs | 2 + src/electron/settings.mjs | 6 ++ src/types.ts | 6 ++ src/types/electron-api.d.ts | 2 + src/ui/components/LibraryVideoPlayer.test.tsx | 1 + .../LibraryVideoPlayerWithTools.test.tsx | 5 +- src/ui/hooks/useDownloadVideo.tsx | 4 +- src/ui/screens/LibraryVideoDetail.test.tsx | 1 + src/ui/screens/OptionsScreen.test.tsx | 2 + src/ui/screens/OptionsScreen.tsx | 26 +++++++ .../OtherPlatformDownloadCard.test.tsx | 51 +++++++++++++ src/ui/screens/OtherPlatformDownloadCard.tsx | 11 ++- src/ui/screens/VideoDetailCard.test.tsx | 22 ++++++ src/ui/screens/VideoDetailCard.tsx | 12 +++- testing/mockData/electronAPIMocks.ts | 8 +++ 18 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 src/ui/screens/OtherPlatformDownloadCard.test.tsx diff --git a/src/electron/library.mjs b/src/electron/library.mjs index 52513b2..04f2afa 100644 --- a/src/electron/library.mjs +++ b/src/electron/library.mjs @@ -943,6 +943,17 @@ export function checkAndRepairEpochFiles({ libraryDir, videoDir, epoch }) { }; } +// Video-level, not per-epoch -- ensureVideoThumbnail (main.mjs) saves +// exactly one video-thumbnail.* file directly in videoDir, a sibling of the +// epoch folders, same pattern as channel-icon.* one level up. Accepts +// already-fetched directory entries (scanLibrary already has them from its +// own walk) to avoid a redundant readdir; fetches its own otherwise. +export function findVideoThumbnailPath(videoDir, entries = null) { + const dirEntries = entries || fs.readdirSync(videoDir, { withFileTypes: true }); + const thumbnailEntry = dirEntries.find((e) => e.isFile() && e.name.startsWith('video-thumbnail.')); + return thumbnailEntry ? path.join(videoDir, thumbnailEntry.name) : null; +} + // Bounded 3-level walk (channel/video/epoch), tolerant of partial or corrupt // folders -- a missing or unparseable metadata.json is skipped rather than // failing the whole scan, since an interrupted write is always conceivable. @@ -1026,11 +1037,6 @@ export async function scanLibrary(libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_N if (!metadata) continue; - // Video-level, not per-epoch -- ensureVideoThumbnail (main.mjs) - // saves exactly one video-thumbnail.* file directly in videoPath, - // a sibling of the epoch folders, same pattern as channel-icon.* - // one level up. - const thumbnailEntry = epochEntries.find((e) => e.isFile() && e.name.startsWith('video-thumbnail.')); // Cheap (one JSON parse) -- only the count rides along in the main // index; the full per-clip list is fetched lazily via // library:getClips when the Clip Collection view actually opens. @@ -1042,7 +1048,7 @@ export async function scanLibrary(libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_N latestEpoch, metadata, epochs, - thumbnailPath: thumbnailEntry ? path.join(videoPath, thumbnailEntry.name) : null, + thumbnailPath: findVideoThumbnailPath(videoPath, epochEntries), clipCount, }); } diff --git a/src/electron/library.test.mjs b/src/electron/library.test.mjs index bd594cf..531ff7e 100644 --- a/src/electron/library.test.mjs +++ b/src/electron/library.test.mjs @@ -12,6 +12,7 @@ import { refreshLibraryEntryMetadata, recordLibraryDownload, savePlaybackPosition, + findVideoThumbnailPath, swapLibraryDownload, deleteLibraryEntry, deleteLocalFiles, @@ -204,6 +205,29 @@ describe('addLibraryVersion', () => { }); }); +describe('findVideoThumbnailPath', () => { + it('finds a video-thumbnail.* file directly in videoDir', () => { + const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); + fs.writeFileSync(path.join(videoDir, 'video-thumbnail.jpg'), 'fake image bytes'); + + expect(findVideoThumbnailPath(videoDir)).toBe(path.join(videoDir, 'video-thumbnail.jpg')); + }); + + it('returns null when no thumbnail file exists', () => { + const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); + + expect(findVideoThumbnailPath(videoDir)).toBeNull(); + }); + + it('accepts already-fetched directory entries instead of re-reading the directory', () => { + const { videoDir } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); + fs.writeFileSync(path.join(videoDir, 'video-thumbnail.png'), 'fake image bytes'); + const entries = fs.readdirSync(videoDir, { withFileTypes: true }); + + expect(findVideoThumbnailPath(videoDir, entries)).toBe(path.join(videoDir, 'video-thumbnail.png')); + }); +}); + describe('recordLibraryDownload', () => { it('sets video fields for kind "video" without touching audio fields', () => { const { videoDir, metadata } = writeLibraryEntry({ libraryDir, videoMetaData: baseVideoMetaData() }); diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 9f02003..154dce4 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -10,8 +10,8 @@ import os from 'node:os'; import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs'; import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs'; import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs'; -import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs'; -import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT } from './settings.mjs'; +import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, findVideoThumbnailPath, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs'; +import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs'; import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; import { createFfmpegRunner } from './ffmpegUtils.mjs'; @@ -624,6 +624,18 @@ ipcMain.handle('settings:setResumeMinDurationSeconds', async (e, value) => { return { success: true, resumeMinDurationSeconds: settings.resumeMinDurationSeconds }; }); +ipcMain.handle('settings:getEmbedMetadataByDefault', async () => { + const { embedMetadataByDefault } = readSettings(); + return { embedMetadataByDefault: clampEmbedMetadataByDefault(embedMetadataByDefault ?? EMBED_METADATA_BY_DEFAULT_DEFAULT) }; +}); + +ipcMain.handle('settings:setEmbedMetadataByDefault', async (e, value) => { + const settings = readSettings(); + settings.embedMetadataByDefault = clampEmbedMetadataByDefault(value); + writeSettings(settings); + return { success: true, embedMetadataByDefault: settings.embedMetadataByDefault }; +}); + // User-added muxers for the Library view's "convert to" ffmpeg utility, // beyond the small hardcoded popular set (LibraryVideoDetail.tsx) -- a plain // string list, not validated against ffmpeg's own muxer list. @@ -971,9 +983,11 @@ ipcMain.handle('library:findVideo', async (e, videoId, libraryTag) => { }); ipcMain.handle('library:recordDownload', async (e, { videoDir, epoch, filePath, resolution, format, kind }) => { - recordLibraryDownload({ videoDir, epoch, filePath, resolution, format, kind }); + const metadata = recordLibraryDownload({ videoDir, epoch, filePath, resolution, format, kind }); const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); await refreshLibraryIndex(libraryDir, activeLibraryTag); + const metadataTags = { title: metadata.title, artist: metadata.channel, date: metadata.uploadDate, description: metadata.description }; + await maybeAutoEmbedMetadata({ filePath, kind, metadataTags, thumbnailPath: findVideoThumbnailPath(videoDir), libraryDir, logContext: `${videoDir}/${epoch}` }); return { success: true }; }); @@ -986,6 +1000,9 @@ ipcMain.handle('library:swapDownload', async (e, { videoDir, epoch, tempFilePath const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); const metadata = swapLibraryDownload({ libraryDir, videoDir, epoch, tempFilePath, oldFilePath, resolution, format, kind }); await refreshLibraryIndex(libraryDir, activeLibraryTag); + const filePath = kind === 'audio' ? metadata.downloadedAudioFilePath : metadata.downloadedFilePath; + const metadataTags = { title: metadata.title, artist: metadata.channel, date: metadata.uploadDate, description: metadata.description }; + await maybeAutoEmbedMetadata({ filePath, kind, metadataTags, thumbnailPath: findVideoThumbnailPath(videoDir), libraryDir, logContext: `${videoDir}/${epoch}` }); return metadata; }); @@ -1672,6 +1689,14 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { const finalFile = findFinalFile(options.outputPath); log(`[download] ${options.requestId} attempt ${attempt} done -> ${finalFile}`); rememberAppPath(finalFile); + await maybeAutoEmbedMetadata({ + filePath: finalFile, + kind: options.resolution && options.resolution.toLowerCase() === 'mp3' ? 'audio' : 'video', + metadataTags: options.metadataTags, + thumbnailPath: options.thumbnailPath, + libraryDir: readSettings().libraryDir, + logContext: options.requestId, + }); send({ type: 'done', payload: { filename: finalFile } }); return; } @@ -1708,6 +1733,14 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { finishDownload(); log(`[download] ${options.requestId} attempt ${attempt} postprocess complete -> ${postprocessOutputPath}`); rememberAppPath(postprocessOutputPath); + await maybeAutoEmbedMetadata({ + filePath: postprocessOutputPath, + kind: options.resolution && options.resolution.toLowerCase() === 'mp3' ? 'audio' : 'video', + metadataTags: options.metadataTags, + thumbnailPath: options.thumbnailPath, + libraryDir: readSettings().libraryDir, + logContext: options.requestId, + }); send({ type: 'done', payload: { filename: postprocessOutputPath } }); } catch (err) { // Our own direct ffmpeg pass, not yt-dlp -- still worth the @@ -2077,12 +2110,14 @@ ipcMain.handle('library:convertClip', async (e, { videoDir, clipId, format, forc // this replaces it rather than stacking old covers. The cover stream is // always re-encoded to mjpeg since webp isn't a valid embedded-cover codec // for ID3/mov -- everything else stays -c copy, no quality loss. -ipcMain.handle('library:embedMetadata', async (e, { inputPath, metadataTags, thumbnailPath, kind }) => { +// Shared by the manual "Embed metadata" IPC handler below and the +// auto-embed-on-download hook in library:recordDownload/library:swapDownload +// -- one implementation, two callers. +async function embedMetadataIntoFile({ inputPath, metadataTags, thumbnailPath, kind, libraryDir }) { // Broader than resolveInsideLibrary alone: this is also used from the // plain Downloader tab (OtherPlatformDownloadCard.tsx), where inputPath // is a file that was never added to the library -- just downloaded to // wherever dialog:saveVideoFile put it. - const { libraryDir } = readSettings(); const resolvedInput = resolveAppOrLibraryPath(libraryDir, inputPath); if (!resolvedInput) { return { success: false, message: 'Refusing to modify a file outside the configured library folder.' }; @@ -2151,7 +2186,32 @@ ipcMain.handle('library:embedMetadata', async (e, { inputPath, metadataTags, thu } finally { if (downloadedThumbnailPath) fs.rmSync(downloadedThumbnailPath, { force: true }); } -}); +} + +ipcMain.handle('library:embedMetadata', async (e, { inputPath, metadataTags, thumbnailPath, kind }) => { + const { libraryDir } = readSettings(); + return embedMetadataIntoFile({ inputPath, metadataTags, thumbnailPath, kind, libraryDir }); +}); + +// Best-effort, matching enrichPlaylistEntry's own precedent for a +// background step riding along an already-succeeded action. Shared by the +// library flow (library:recordDownload/swapDownload, which build +// metadataTags/thumbnailPath from the just-written metadata.json) and the +// plain Downloader tab (downloadVideoWithProgressUpdates, which only calls +// this when a caller actually supplied metadataTags -- library-flow +// downloads never do, so this is a no-op there and the library hook stays +// the sole trigger for library downloads). +async function maybeAutoEmbedMetadata({ filePath, kind, metadataTags, thumbnailPath, libraryDir, logContext }) { + if (!metadataTags) return; + const { embedMetadataByDefault } = readSettings(); + if (!clampEmbedMetadataByDefault(embedMetadataByDefault ?? EMBED_METADATA_BY_DEFAULT_DEFAULT)) return; + try { + const result = await embedMetadataIntoFile({ inputPath: filePath, metadataTags, thumbnailPath, kind, libraryDir }); + if (!result.success) log(`[auto-embed] failed for ${logContext}: ${result.message}`); + } catch (err) { + log(`[auto-embed] failed for ${logContext}: ${err instanceof Error ? err.message : String(err)}`); + } +} ipcMain.handle('ytdlp:checkForUpdate', async () => { const current = await getCurrentYtdlpVersion(ytdlpPath); diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index 16979a9..ba2434c 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -57,6 +57,8 @@ contextBridge.exposeInMainWorld('electronAPI', { setResumeTrackingMode: (value) => ipcRenderer.invoke('settings:setResumeTrackingMode', value), getResumeMinDurationSeconds: () => ipcRenderer.invoke('settings:getResumeMinDurationSeconds'), setResumeMinDurationSeconds: (value) => ipcRenderer.invoke('settings:setResumeMinDurationSeconds', value), + getEmbedMetadataByDefault: () => ipcRenderer.invoke('settings:getEmbedMetadataByDefault'), + setEmbedMetadataByDefault: (value) => ipcRenderer.invoke('settings:setEmbedMetadataByDefault', value), getLibraryIndex: () => ipcRenderer.invoke('library:getIndex'), refreshLibraryIndex: () => ipcRenderer.invoke('library:refreshIndex'), refreshChannelIcon: (payload) => ipcRenderer.invoke('library:refreshChannelIcon', payload), diff --git a/src/electron/settings.mjs b/src/electron/settings.mjs index eb7489b..fe45db6 100644 --- a/src/electron/settings.mjs +++ b/src/electron/settings.mjs @@ -77,3 +77,9 @@ export function clampResumeMinDurationSeconds(value) { if (!Number.isFinite(n) || n < 0) return RESUME_MIN_DURATION_SECONDS_DEFAULT; return Math.floor(n); } + +export const EMBED_METADATA_BY_DEFAULT_DEFAULT = true; + +export function clampEmbedMetadataByDefault(value) { + return typeof value === 'boolean' ? value : EMBED_METADATA_BY_DEFAULT_DEFAULT; +} diff --git a/src/types.ts b/src/types.ts index 2fcb5b0..48f9801 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,6 +140,12 @@ export type DownloadFailure = resolution: string; overwriteMode?: 'overwrite' | 'resume'; additionalOptions?: object; + // Opt-in auto-embed for downloads outside the library (main.mjs's + // downloadVideoWithProgressUpdates only attempts an embed when a caller + // actually supplies these) -- library-flow downloads embed separately + // via library:recordDownload/swapDownload instead. + metadataTags?: Record; + thumbnailPath?: string | null; // Generated fresh by useDownloadVideo.tsx's startDownload() on every // call, not caller-supplied -- optional here so callers building this // object don't need to invent one. diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 5b266c9..c6f4270 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -110,6 +110,8 @@ declare global { setResumeTrackingMode: (value: 'never' | 'always' | 'custom') => Promise<{ success: boolean; resumeTrackingMode: 'never' | 'always' | 'custom' }> getResumeMinDurationSeconds: () => Promise<{ resumeMinDurationSeconds: number }> setResumeMinDurationSeconds: (value: number) => Promise<{ success: boolean; resumeMinDurationSeconds: number }> + getEmbedMetadataByDefault: () => Promise<{ embedMetadataByDefault: boolean }> + setEmbedMetadataByDefault: (value: boolean) => Promise<{ success: boolean; embedMetadataByDefault: boolean }> getLibraryIndex: () => Promise refreshLibraryIndex: () => Promise refreshChannelIcon: (payload: { channelFolderName: string; channelId: string | null }) => Promise diff --git a/src/ui/components/LibraryVideoPlayer.test.tsx b/src/ui/components/LibraryVideoPlayer.test.tsx index 702849d..68fdd8a 100644 --- a/src/ui/components/LibraryVideoPlayer.test.tsx +++ b/src/ui/components/LibraryVideoPlayer.test.tsx @@ -23,6 +23,7 @@ function baseMetadata(overrides: Partial = {}): LibraryVid downloadedResolution: null, downloadedFormat: null, downloadedAudioFilePath: null, + lastPlaybackPositionSeconds: null, ...overrides, }; } diff --git a/src/ui/components/LibraryVideoPlayerWithTools.test.tsx b/src/ui/components/LibraryVideoPlayerWithTools.test.tsx index 449d7ab..4a78f44 100644 --- a/src/ui/components/LibraryVideoPlayerWithTools.test.tsx +++ b/src/ui/components/LibraryVideoPlayerWithTools.test.tsx @@ -17,8 +17,8 @@ import type { LibraryVideoMetadata } from '../../types'; // exercise LibraryVideoPlayerWithTools's own logic (the state it derives, // which IPC calls it makes) in isolation from Vidstack entirely. let fakeCurrentTime = 0; -let seekToSpy: ReturnType; -let playSpy: ReturnType; +let seekToSpy: ReturnType void>>; +let playSpy: ReturnType void>>; vi.mock('./LibraryVideoPlayer', () => ({ __esModule: true, default: forwardRef(function FakeLibraryVideoPlayer( @@ -69,6 +69,7 @@ function baseMetadata(overrides: Partial = {}): LibraryVid downloadedResolution: '720', downloadedFormat: 'dflt', downloadedAudioFilePath: null, + lastPlaybackPositionSeconds: null, ...overrides, }; } diff --git a/src/ui/hooks/useDownloadVideo.tsx b/src/ui/hooks/useDownloadVideo.tsx index 107961d..bb79984 100644 --- a/src/ui/hooks/useDownloadVideo.tsx +++ b/src/ui/hooks/useDownloadVideo.tsx @@ -35,7 +35,7 @@ function useDownloadVideo() { const startDownload = (props: DownloadVideoParams) => { - const { videoUrl, outputPath, format, resolution, overwriteMode, additionalOptions } = props; + const { videoUrl, outputPath, format, resolution, overwriteMode, additionalOptions, metadataTags, thumbnailPath } = props; const requestId = crypto.randomUUID(); requestIdRef.current = requestId; @@ -51,7 +51,7 @@ function useDownloadVideo() { setIsRetrying(false); setRetryInfo(null); - window.electronAPIPythonDownload.startDownloadPython({ videoUrl, outputPath, format, resolution, overwriteMode, additionalOptions, requestId }) + window.electronAPIPythonDownload.startDownloadPython({ videoUrl, outputPath, format, resolution, overwriteMode, additionalOptions, metadataTags, thumbnailPath, requestId }) } // Only meaningful while this hook's own download is in flight -- the main diff --git a/src/ui/screens/LibraryVideoDetail.test.tsx b/src/ui/screens/LibraryVideoDetail.test.tsx index 6e92ae7..441c2d8 100644 --- a/src/ui/screens/LibraryVideoDetail.test.tsx +++ b/src/ui/screens/LibraryVideoDetail.test.tsx @@ -52,6 +52,7 @@ function baseMetadata(overrides: Record = {}) { downloadedResolution: null, downloadedFormat: null, downloadedAudioFilePath: null, + lastPlaybackPositionSeconds: null, ...overrides, }; } diff --git a/src/ui/screens/OptionsScreen.test.tsx b/src/ui/screens/OptionsScreen.test.tsx index b84cfb9..dca1e1d 100644 --- a/src/ui/screens/OptionsScreen.test.tsx +++ b/src/ui/screens/OptionsScreen.test.tsx @@ -39,6 +39,8 @@ beforeEach(() => { setResumeTrackingMode: vi.fn().mockResolvedValue({ success: true, resumeTrackingMode: 'custom' }), getResumeMinDurationSeconds: vi.fn().mockResolvedValue({ resumeMinDurationSeconds: 1200 }), setResumeMinDurationSeconds: vi.fn().mockResolvedValue({ success: true, resumeMinDurationSeconds: 1200 }), + getEmbedMetadataByDefault: vi.fn().mockResolvedValue({ embedMetadataByDefault: true }), + setEmbedMetadataByDefault: vi.fn().mockResolvedValue({ success: true, embedMetadataByDefault: true }), getAppVersion: vi.fn().mockResolvedValue('0.0.0'), }; }); diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index b2e7d1d..ca86222 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -132,6 +132,7 @@ export default function OptionsScreen() { const [maxSimultaneousDownloads, setMaxSimultaneousDownloadsState] = useState(1); const [resumeTrackingMode, setResumeTrackingModeState] = useState<'never' | 'always' | 'custom'>('custom'); const [resumeMinDurationSeconds, setResumeMinDurationSecondsState] = useState(1200); + const [embedMetadataByDefault, setEmbedMetadataByDefaultState] = useState(true); const [appVersion, setAppVersion] = useState(''); const refreshStatus = async () => { @@ -191,6 +192,11 @@ export default function OptionsScreen() { setResumeMinDurationSecondsState(resumeMinDurationSeconds); }; + const refreshEmbedMetadataByDefault = async () => { + const { embedMetadataByDefault } = await window.electronAPI.getEmbedMetadataByDefault(); + setEmbedMetadataByDefaultState(embedMetadataByDefault); + }; + useEffect(() => { refreshStatus(); refreshCookiesConfig(); @@ -201,6 +207,7 @@ export default function OptionsScreen() { refreshMaxSimultaneousDownloads(); refreshResumeTrackingMode(); refreshResumeMinDurationSeconds(); + refreshEmbedMetadataByDefault(); window.electronAPI.getAppVersion().then(setAppVersion); }, []); @@ -241,6 +248,12 @@ export default function OptionsScreen() { await window.electronAPI.setResumeMinDurationSeconds(value); }; + const handleEmbedMetadataByDefaultChange = async (e: ChangeEvent) => { + const checked = e.target.checked; + setEmbedMetadataByDefaultState(checked); + await window.electronAPI.setEmbedMetadataByDefault(checked); + }; + const handleChooseDownloadDir = async () => { // Electron 43+ opens unset-defaultPath dialogs at Downloads instead of // remembering the last-picked folder -- pass the current setting @@ -484,6 +497,19 @@ export default function OptionsScreen() { sx={{ minWidth: 120 }} />} + + + Embed Metadata Automatically + + Writes title/channel/date/description and cover art into every downloaded + file as soon as the download finishes, instead of requiring the manual + "Embed metadata" action. + + } + label="Embed metadata into new downloads automatically" + /> + diff --git a/src/ui/screens/OtherPlatformDownloadCard.test.tsx b/src/ui/screens/OtherPlatformDownloadCard.test.tsx new file mode 100644 index 0000000..fc00ed4 --- /dev/null +++ b/src/ui/screens/OtherPlatformDownloadCard.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import OtherPlatformDownloadCard from './OtherPlatformDownloadCard'; + +const videoMetaData = { + title: 'Team Fortress 2 OST Mannrobics', + fullTitle: 'Team Fortress 2 OST Mannrobics', + thumbnail: 'https://example.com/thumb.jpg', + uploader: 'dj-fegit', + durationString: '2:00', + uploadDate: '20260101', + description: 'a description', + originalUrl: 'https://soundcloud.com/dj-fegit/team-fortress-2-ost-mannrobics', +}; + +beforeEach(() => { + window.electronAPI = { + ...window.electronAPI, + saveVideoFile: vi.fn().mockResolvedValue({ canceled: false, filePath: '/x/Team Fortress 2 OST Mannrobics.mp4' }), + checkFileExists: vi.fn().mockResolvedValue(false), + }; + window.electronAPIPythonDownload = { + startDownloadPython: vi.fn(), + onProgressUpdate: vi.fn(), + removeProgressListener: vi.fn(), + cancelDownload: vi.fn(), + } as unknown as typeof window.electronAPIPythonDownload; +}); + +describe('OtherPlatformDownloadCard', () => { + it('passes metadataTags/thumbnailPath through so the download can be auto-embedded', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Download MP3' })); + + expect(window.electronAPIPythonDownload.startDownloadPython).toHaveBeenCalledWith( + expect.objectContaining({ + metadataTags: { + title: videoMetaData.fullTitle, + artist: videoMetaData.uploader, + date: videoMetaData.uploadDate, + description: videoMetaData.description, + }, + thumbnailPath: videoMetaData.thumbnail, + }), + ); + }); +}); diff --git a/src/ui/screens/OtherPlatformDownloadCard.tsx b/src/ui/screens/OtherPlatformDownloadCard.tsx index 7ca463d..c649563 100644 --- a/src/ui/screens/OtherPlatformDownloadCard.tsx +++ b/src/ui/screens/OtherPlatformDownloadCard.tsx @@ -86,7 +86,16 @@ export default function OtherPlatformDownloadCard({ videoMetaData }: OtherPlatfo setSelectedResolution(resolution); setIsDownloading(true); setEmbedError(null); - startDownload({ videoUrl: videoMetaData.originalUrl, outputPath, format: 'dflt', resolution, overwriteMode }); + startDownload({ + videoUrl: videoMetaData.originalUrl, outputPath, format: 'dflt', resolution, overwriteMode, + metadataTags: { + title: videoMetaData.fullTitle || videoMetaData.title, + artist: videoMetaData.uploader, + date: videoMetaData.uploadDate, + description: videoMetaData.description, + }, + thumbnailPath: videoMetaData.thumbnail, + }); }; // Reuses the same generic embedFileMetadata IPC LibraryVideoDetail.tsx's diff --git a/src/ui/screens/VideoDetailCard.test.tsx b/src/ui/screens/VideoDetailCard.test.tsx index ee6c3a4..6052c5d 100644 --- a/src/ui/screens/VideoDetailCard.test.tsx +++ b/src/ui/screens/VideoDetailCard.test.tsx @@ -19,6 +19,7 @@ const videoMetaData = { originalUrl: 'https://youtube.com/watch?v=vid1', durationString: '2:00', uploadDate: '20260115', + uploader: 'Some Channel', }; beforeEach(() => { @@ -86,6 +87,27 @@ describe('VideoDetailCard', () => { expect(screen.getByText(/Downloading \(720p\)/)).toBeInTheDocument(); }); + it('passes metadataTags/thumbnailPath through so the download can be auto-embedded', async () => { + const user = userEvent.setup(); + (window.electronAPI.saveVideoFile as ReturnType).mockResolvedValue({ canceled: false, filePath: '/x/video.mp4' }); + (window.electronAPI.checkFileExists as ReturnType).mockResolvedValue(false); + render(); + + await user.click(screen.getByRole('button', { name: /720p/ })); + + expect(window.electronAPIPythonDownload.startDownloadPython).toHaveBeenCalledWith( + expect.objectContaining({ + metadataTags: { + title: videoMetaData.fullTitle, + artist: videoMetaData.uploader, + date: videoMetaData.uploadDate, + description: videoMetaData.description, + }, + thumbnailPath: videoMetaData.thumbnail, + }), + ); + }); + it('does not start a download when the save dialog is cancelled', async () => { const user = userEvent.setup(); (window.electronAPI.saveVideoFile as ReturnType).mockResolvedValue({ canceled: true }); diff --git a/src/ui/screens/VideoDetailCard.tsx b/src/ui/screens/VideoDetailCard.tsx index a5dd025..d9de6a4 100644 --- a/src/ui/screens/VideoDetailCard.tsx +++ b/src/ui/screens/VideoDetailCard.tsx @@ -56,6 +56,7 @@ interface VideoDataProps { duration?: number | null; durationString: string; uploadDate: string; + uploader: string | null; } } @@ -75,7 +76,16 @@ const VideoDetailCard: React.FC = ({ videoMetaData }) => { const beginDownload = (outputPath: string, resolution: string, overwriteMode?: DownloadVideoParams['overwriteMode']) => { setSelectedResolution(resolution); - startDownload({ videoUrl: videoMetaData.originalUrl, outputPath, format: selectedFormat, resolution, overwriteMode }); + startDownload({ + videoUrl: videoMetaData.originalUrl, outputPath, format: selectedFormat, resolution, overwriteMode, + metadataTags: { + title: videoMetaData.fullTitle, + artist: videoMetaData.uploader, + date: videoMetaData.uploadDate, + description: videoMetaData.description, + }, + thumbnailPath: videoMetaData.thumbnail, + }); } const handleDownloadOperationFromResolution = async (resolution: string) => { diff --git a/testing/mockData/electronAPIMocks.ts b/testing/mockData/electronAPIMocks.ts index f535b23..044f5e6 100644 --- a/testing/mockData/electronAPIMocks.ts +++ b/testing/mockData/electronAPIMocks.ts @@ -20,6 +20,7 @@ const emptyLibraryVideoMetadata: LibraryVideoMetadata = { downloadedResolution: null, downloadedFormat: null, downloadedAudioFilePath: null, + lastPlaybackPositionSeconds: null, }; // DownloaderScreen seeds its initial videoInfo state from this in dev-mock @@ -80,6 +81,12 @@ export const electronAPIMock = { setMaxSimultaneousDownloads: async () => ({ success: true, maxSimultaneousDownloads: 1 }), getThumbnailSize: async () => ({ thumbnailSize: 0 }), setThumbnailSize: async () => ({ success: true, thumbnailSize: 0 }), + getResumeTrackingMode: async () => ({ resumeTrackingMode: 'custom' as const }), + setResumeTrackingMode: async () => ({ success: true, resumeTrackingMode: 'custom' as const }), + getResumeMinDurationSeconds: async () => ({ resumeMinDurationSeconds: 1200 }), + setResumeMinDurationSeconds: async () => ({ success: true, resumeMinDurationSeconds: 1200 }), + getEmbedMetadataByDefault: async () => ({ embedMetadataByDefault: true }), + setEmbedMetadataByDefault: async () => ({ success: true, embedMetadataByDefault: true }), getLibraryIndex: async () => ({ channels: [] }), refreshLibraryIndex: async () => ({ channels: [] }), refreshChannelIcon: async () => ({ channels: [] }), @@ -96,6 +103,7 @@ export const electronAPIMock = { undoPlaylistRefresh: async () => ({ success: true }), deletePlaylist: async () => ({ success: true }), recordLibraryDownload: async () => ({ success: true }), + savePlaybackPosition: async () => ({ success: true }), swapLibraryDownload: async () => (emptyLibraryVideoMetadata), deleteLibraryEntry: async () => ({ success: true, videoDeleted: true }), deleteLibraryEntries: async () => ({ success: true, results: [] }), From 569c57f4b06ea1e4ab08590cd2494d6c904e8f9f Mon Sep 17 00:00:00 2001 From: Sloth Date: Thu, 10 Sep 2026 21:37:39 -0700 Subject: [PATCH 08/12] feat(app): introduce abiity to loop in library player from the entire video and the selected sequence --- src/ui/components/LibraryVideoPlayer.test.tsx | 181 +++++++++++++++++- src/ui/components/LibraryVideoPlayer.tsx | 55 +++++- src/ui/components/PlayerContextMenu.test.tsx | 72 +++++++ src/ui/components/PlayerContextMenu.tsx | 35 ++++ src/ui/screens/OptionsScreen.test.tsx | 35 ++++ 5 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/ui/components/PlayerContextMenu.test.tsx create mode 100644 src/ui/components/PlayerContextMenu.tsx diff --git a/src/ui/components/LibraryVideoPlayer.test.tsx b/src/ui/components/LibraryVideoPlayer.test.tsx index 68fdd8a..fe0224c 100644 --- a/src/ui/components/LibraryVideoPlayer.test.tsx +++ b/src/ui/components/LibraryVideoPlayer.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import LibraryVideoPlayer, { type ClipMarkersControl } from './LibraryVideoPlayer'; import type { LibraryVideoMetadata } from '../../types'; @@ -66,6 +66,17 @@ function fireDecodeError(video: HTMLVideoElement) { fireEvent.error(video); } +// The click-anywhere-to-toggle-play overlay (and now the right-click +// context-menu surface too) is an unlabeled, childless sibling of +// [data-media-provider] inside Vidstack's own root element -- :empty +// reliably picks it out from the provider (has a