diff --git a/CHANGELOG.md b/CHANGELOG.md index 762b99b..301adf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ This is the first documented entry — a snapshot of what SlothArchiver could do as of this release, not a history of every change that got it here. Future releases will log what actually changed from the previous one. +## [1.1.0] — 2026-09-11 +- **Player improvements.** + - **Resume where you left off.** Reopening a video you didn't finish now + offers to pick up right where you paused, via a dismissible toast. A + new Options setting controls when a position gets remembered at all — + never, always, or only past a minimum length you choose (20 minutes by + default), so a fresh install doesn't start tracking every short clip. + - **Loop a video, or loop just a clipped sequence.** Right-click the + player for a new context menu (with room for more playback options + down the line) offering "Loop" for the whole video and "Loop + sequence" to loop only between your clip markers, once both are set. + - **Clip markers and extraction are now millisecond-precise**, not + rounded to the nearest whole second — dragging a clip marker or + setting one from the current playback position keeps the exact + fraction of a second you picked, instead of losing it to rounding. + Also fixed a bug that could cause an unnecessary, slower re-encode + when clipping an audio-only file. + - **Double-click the time display for a millisecond-precision readout**, useful alongside the new fine-grained clip markers. + Double-click again to go back to the normal display. +- **Downloads embed their metadata by default now.** Title, channel, upload + date, description, and cover art get written into the file automatically + on every download — library and the plain Downloader tab alike. This was + previously a manual "Embed metadata" button; it's now on by default and + can be turned off in Options. +- **Tag playlist entries, and filter playlists by tag** the same way the + library already lets you — including bulk-tagging several entries at + once, and new "Downloaded"/"Not Downloaded" filters available in both the + library and playlist tag filters. +- **"Select all" for whatever's currently on screen** — the flat video + list, a channel's video grid, and a playlist's entry list all gained a + header checkbox that selects (or clears) exactly the currently + filtered/searched set, for use with the existing bulk actions. +- **Fixed a bug where an MP3 download failure showed no error at all** in + the video view, silently reverting to the plain "Download MP3" button + instead of telling you something went wrong. +- **The sandboxed JS runtime yt-dlp uses to solve site challenges is now Deno instead of Node** closing a gap where the previous approach could + not fully block that runtime's network and environment-variable access. + No user-facing change in behavior — this is a security hardening + improvement under the hood. + ## [1.0.0] — 2026-09-05 - **First public release** 🎉🎉🥳🎉🎉 - **Added a theme selector.** Options now has a "Theme" dropdown alongside diff --git a/README.md b/README.md index ad1dbf7..dda33f8 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,17 @@ itself. ## Download - | Platform | Link | |---|---| -| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver.Setup.1.0.0.exe) | -| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver.1.0.0.exe) | -| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0-arm64.dmg) | -| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0.dmg) | -| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.0.0/SlothArchiver-1.0.0.AppImage) | +| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.0/SlothArchiver.Setup.1.1.0.exe) | +| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.0/SlothArchiver.1.1.0.exe) | +| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.0/SlothArchiver-1.1.0-arm64.dmg) | +| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.0/SlothArchiver-1.1.0.dmg) | +| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.0/SlothArchiver-1.1.0.AppImage) | All builds are unsigned, so your OS will show a first-run security warning. See [Installing](#installing) below. diff --git a/package-lock.json b/package-lock.json index 1777880..fe75dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sloth-archiver", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sloth-archiver", - "version": "1.0.0", + "version": "1.1.0", "license": "GPL-3.0-or-later", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index f45697e..1ebd783 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sloth-archiver", "private": true, - "version": "1.0.0", + "version": "1.1.0", "description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.", "author": "Sloth ", "license": "GPL-3.0-or-later", @@ -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/ffmpegUtils.mjs b/src/electron/ffmpegUtils.mjs index 489bdb4..284969a 100644 --- a/src/electron/ffmpegUtils.mjs +++ b/src/electron/ffmpegUtils.mjs @@ -351,7 +351,18 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath, onLog, const preInputArgs = ['-ss', start]; const durationArgs = ['-t', String(totalDurationSeconds)]; const isSourceFormat = !format || format === 'source'; - const risky = !forceReencode && startSeconds != null + // Keyframe rounding is a video-only concern (GOP/keyframe boundaries + // don't exist for audio the same way) -- findLastKeyframeAtOrBefore + // selects only the video stream (-select_streams v:0), so on an + // audio-only source it finds no packets and falls back to the probe + // window's own start, which reads as a large, near-guaranteed-risky + // offset for any startSeconds past the window size. That spuriously + // forces a re-encode on audio clips that never had a keyframe problem + // to begin with, so skip the check entirely once there's no video + // stream to be at risk. + const hasVideoStream = (await probeMediaStreams(inputPath).catch(() => [])) + .some((s) => s.codecType === 'video'); + const risky = hasVideoStream && !forceReencode && startSeconds != null // Fails open toward re-encoding (slower, but always correct) // rather than silently trusting the fast path if the probe // itself errors out for some reason. diff --git a/src/electron/ffmpegUtils.test.mjs b/src/electron/ffmpegUtils.test.mjs index 57f7bf8..8167ec6 100644 --- a/src/electron/ffmpegUtils.test.mjs +++ b/src/electron/ffmpegUtils.test.mjs @@ -182,4 +182,28 @@ describe('clipAndConvert', () => { // path, exactly as it should once the probe itself is fast and correct. expect(ffmpegCallArgs()).toEqual(expect.arrayContaining(['-c', 'copy'])); }); + + // The keyframe-risk check is video-only (findLastKeyframeAtOrBefore + // selects only the video stream) -- on an audio-only source it would find + // no packets and fall back to the probe window's own start, reading as a + // large, near-guaranteed-risky offset and spuriously forcing a re-encode + // that was never needed. clipAndConvert must skip the risk check entirely + // (and never even issue the -read_intervals keyframe probe) once + // probeMediaStreams reports no video stream, regardless of how large an + // offset that fallback would otherwise compute. + it('skips the keyframe-risk probe entirely and stays on the fast copy path for an audio-only clip', async () => { + stubSpawn({ streams: [{ codecType: 'audio', codecName: 'mp3' }] }); + const { clipAndConvert } = createFfmpegRunner({ ffmpegBinaryPath: FFMPEG_BIN, ffprobeBinaryPath: FFPROBE_BIN }); + + // A short clip deep into the file -- exactly the shape that would trip + // both risk thresholds for a video file, per the fallback-to-window-start + // behavior findLastKeyframeAtOrBefore has when it finds no packets. + await clipAndConvert({ + inputPath: '/in.mp3', outputPath: '/out.mp3', start: '00:10:00', startSeconds: 600, + format: 'source', totalDurationSeconds: 12, + }); + + expect(spawnMock.mock.calls.some(([bin, args]) => bin === FFPROBE_BIN && args.includes('-read_intervals'))).toBe(false); + expect(ffmpegCallArgs()).toEqual(expect.arrayContaining(['-c', 'copy'])); + }); }); diff --git a/src/electron/library.mjs b/src/electron/library.mjs index 7e53e8c..04f2afa 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 @@ -930,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. @@ -1013,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. @@ -1029,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 7be51ff..531ff7e 100644 --- a/src/electron/library.test.mjs +++ b/src/electron/library.test.mjs @@ -9,7 +9,10 @@ import { videoFolderName, writeLibraryEntry, addLibraryVersion, + refreshLibraryEntryMetadata, recordLibraryDownload, + savePlaybackPosition, + findVideoThumbnailPath, swapLibraryDownload, deleteLibraryEntry, deleteLocalFiles, @@ -146,12 +149,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); }); @@ -201,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() }); @@ -225,6 +252,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 5b91e9a..7918001 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, 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'; @@ -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,22 +96,30 @@ 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}`]; } -// 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 -- 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() { - return { ...process.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]; + } + return env; } // Defense-in-depth for every IPC handler that hands a caller-supplied URL to @@ -584,6 +600,42 @@ 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 }; +}); + +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. @@ -931,9 +983,16 @@ 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 }; +}); + +ipcMain.handle('library:savePlaybackPosition', async (e, { videoDir, epoch, positionSeconds }) => { + savePlaybackPosition({ videoDir, epoch, positionSeconds }); return { success: true }; }); @@ -941,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; }); @@ -1627,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; } @@ -1663,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 @@ -1724,11 +1802,13 @@ export function resolveAppOrLibraryPath(libraryDir, candidatePath) { // Matches every shape FfmpegUtilitiesPanel.tsx's own timestamp helpers can // produce: formatClipTimestampInput (typed input, anywhere from a bare // seconds value up to HH:MM:SS) and formatSecondsAsClipTimestamp (the -// "set from current playback position" default fill, always HH:MM:SS with -// unbounded hours for a very long video). Rejects anything else -- start/end -// are spliced directly into ffmpeg's -ss/-to argv slots, so this is what -// stands between a crafted string and an injected ffmpeg option. -const CLIP_TIMESTAMP_PATTERN = /^\d{1,6}(:\d{2}){0,2}$/; +// "set from current playback position" default fill, always HH:MM:SS, +// optionally with a .mmm millisecond suffix on the seconds group for a +// drag-derived sub-second clip boundary, and unbounded hours for a very +// long video). Rejects anything else -- start/end are spliced directly into +// ffmpeg's -ss/-to argv slots, so this is what stands between a crafted +// string and an injected ffmpeg option. +const CLIP_TIMESTAMP_PATTERN = /^\d{1,6}(:\d{2}){0,2}(\.\d{1,3})?$/; export function isValidClipTimestamp(value) { return typeof value === 'string' && CLIP_TIMESTAMP_PATTERN.test(value); } @@ -1831,16 +1911,19 @@ ipcMain.handle('library:convertFormat', async (e, { inputPath, outputPath, forma } }); -// start/end are passed straight through to ffmpeg's own -ss/-to, which -// already accepts the flexible time formats the UI's fields take -- so they -// pass through unparsed, but isValidClipTimestamp below still gates them -// against the shape this app's own UI can ever actually produce, since -// they're spliced directly into ffmpeg's argv. Both as output options -// (after -i), so they're unambiguous timestamps in the source's timeline -- -// slower to seek than input-side -ss, but -c copy never decodes video either -// way, so it's only an I/O cost. -c copy snaps to the nearest keyframe -// rather than an exact frame, a documented tradeoff; frame-accurate -// re-encoded cuts are a deliberately separate, not-yet-offered option. +// start is passed straight through to ffmpeg's own input-side -ss (before +// -i), which already accepts the flexible time formats the UI's fields take +// -- so it passes through unparsed, but isValidClipTimestamp below still +// gates it against the shape this app's own UI can ever actually produce, +// since it's spliced directly into ffmpeg's argv. Input-side rather than +// output-side: an output-side -ss under -c copy can't re-cut the already- +// copied video track, so it only starts at the next keyframe after the seek +// point while audio starts exactly on time, producing a frozen last frame. +// Input-side -ss makes the demuxer jump to the keyframe at or before the +// point instead, keeping video/audio in sync at the cost of the clip +// possibly starting up to one GOP length earlier than requested on the +// -c copy fast path; clipAndConvert auto-upgrades to a re-encode when that +// rounding risk is large relative to the clip length. // Arbitrary-output-path clip export: unlike library:createClip below, this // never touches clips.json and writes wherever the caller (a save dialog) // picked, for player instances with no "library video entry" to attach a @@ -2032,12 +2115,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.' }; @@ -2106,7 +2191,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/main.test.mjs b/src/electron/main.test.mjs index 6a1455d..84a0f07 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,62 @@ describe('assertValidHttpUrl', () => { }); }); +describe('jsRuntimeArgs', () => { + 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); + }); +}); + +describe('ytdlpSpawnEnv', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + 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', () => { + 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); @@ -346,6 +404,8 @@ describe('isValidClipTimestamp', () => { expect(isValidClipTimestamp('12:34')).toBe(true); expect(isValidClipTimestamp('12:34:56')).toBe(true); expect(isValidClipTimestamp('100000:00:00')).toBe(true); // very long video, unbounded hours + expect(isValidClipTimestamp('12.5')).toBe(true); // drag-derived sub-second boundary + expect(isValidClipTimestamp('12:34:56.789')).toBe(true); }); it('rejects malformed groups and non-string/empty input', () => { @@ -355,6 +415,8 @@ describe('isValidClipTimestamp', () => { expect(isValidClipTimestamp('1:2:3:4')).toBe(false); // too many groups expect(isValidClipTimestamp(null)).toBe(false); expect(isValidClipTimestamp(undefined)).toBe(false); + expect(isValidClipTimestamp('12.5678')).toBe(false); // more than 3 fractional digits + expect(isValidClipTimestamp('12.5:34')).toBe(false); // '.' only allowed on the trailing (seconds) group }); it('rejects a value shaped to reach ffmpeg as an injected option', () => { diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index 29f7b36..ba2434c 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -53,6 +53,12 @@ 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), + 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), @@ -70,6 +76,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..fe45db6 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,24 @@ 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); +} + +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 75ff6b1..48f9801 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 @@ -139,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 b3f34a2..c6f4270 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -106,6 +106,12 @@ 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 }> + getEmbedMetadataByDefault: () => Promise<{ embedMetadataByDefault: boolean }> + setEmbedMetadataByDefault: (value: boolean) => Promise<{ success: boolean; embedMetadataByDefault: boolean }> getLibraryIndex: () => Promise refreshLibraryIndex: () => Promise refreshChannelIcon: (payload: { channelFolderName: string; channelId: string | null }) => Promise @@ -123,6 +129,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.test.tsx b/src/ui/components/LibraryVideoPlayer.test.tsx index 702849d..e968ad0 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'; @@ -23,6 +23,7 @@ function baseMetadata(overrides: Partial = {}): LibraryVid downloadedResolution: null, downloadedFormat: null, downloadedAudioFilePath: null, + lastPlaybackPositionSeconds: null, ...overrides, }; } @@ -65,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