diff --git a/README.md b/README.md index 5b8dfbf..2dfb767 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ automatically. $ yoinks https://youtu.be/dQw4w9WgXcQ # straight to the format picker $ yoinks # prompts for a url $ yoinks --theme light # force the light palette +$ yoinks --embed-chapters # preserve chapters when available ``` yoinks takes over the terminal (full-screen, centered — and restores your @@ -50,6 +51,11 @@ click the theme control in the footer to cycle through `auto`, `light`, and `dark` for the current session. Use `--theme auto`, `--theme light`, or `--theme dark` to choose the starting theme for one launch. +From the format picker, press `^o` to open the optional **Download Options** +menu. Use space to toggle **Embed chapters** (when the source provides them), +then press enter to apply or `esc` to cancel. This never adds a step to the +default flow. + yoinks format picker — resolutions with estimated file sizes, plus audio-only mp3 ## How it works diff --git a/src/app.tsx b/src/app.tsx index c600dec..35de0ca 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -14,6 +14,13 @@ import {TextInput} from './components/text-input.js' import {clickTargetAt, findFrameRow, frameRowSpan, type ClickTarget} from './lib/click-map.js' import {formatBytes, formatDuration, formatEta, formatSpeed, shortenPath, truncate, wrapText} from './lib/format.js' import {addToHistory, loadHistory} from './lib/history.js' +import { + DEFAULT_DOWNLOAD_OPTIONS, + DOWNLOAD_OPTION_DEFINITIONS, + setDownloadOption, + type DownloadOptionId, + type DownloadOptions, +} from './lib/download-options.js' import {detectPlatform, isProbablyUrl, type Platform} from './lib/platforms.js' import {useMouseClick} from './lib/use-mouse-click.js' import {nextThemeMode, ThemeProvider, type ThemeMode, useTheme} from './theme.js' @@ -32,6 +39,7 @@ const OUT_DIR = path.join(os.homedir(), 'Downloads') const YOINK_BUTTON = 'yoink' const DONE_LABEL = '↵ yoink another' const TAGLINE = 'yoink any video. paste. yoink. done.' +const OPTION_LIST_LIMIT = 8 const choiceLabel = (choice: DownloadChoice) => `${choice.kind === 'audio' ? '♪ ' : '▶ '}${choice.label}` @@ -111,6 +119,7 @@ const HINTS: Record> = { picking: [ ['↑↓', 'choose'], ['↵', 'yoink'], + ['^o', 'options'], ['esc', 'back'], ['^c', 'quit'], ], @@ -129,10 +138,11 @@ type AppProps = { initialUrl?: string clipboardUrl?: string initialThemeMode?: ThemeMode + initialDownloadOptions?: DownloadOptions onOutcome: (outcome: Outcome) => void } -export function App({initialThemeMode = 'auto', ...props}: AppProps) { +export function App({initialThemeMode = 'auto', initialDownloadOptions = DEFAULT_DOWNLOAD_OPTIONS, ...props}: AppProps) { const [themeMode, setThemeMode] = useState(initialThemeMode) const cycleTheme = useCallback(() => { setThemeMode(nextThemeMode) @@ -140,7 +150,7 @@ export function App({initialThemeMode = 'auto', ...props}: AppProps) { return ( - + ) } @@ -150,11 +160,13 @@ function AppContent({ clipboardUrl, onOutcome, cycleTheme, + initialDownloadOptions, }: { initialUrl?: string clipboardUrl?: string onOutcome: (outcome: Outcome) => void cycleTheme: () => void + initialDownloadOptions: DownloadOptions }) { const theme = useTheme() const {exit} = useApp() @@ -165,8 +177,12 @@ function AppContent({ const [platform, setPlatform] = useState() const [info, setInfo] = useState() const [choices, setChoices] = useState([]) + const [downloadOptions, setDownloadOptions] = useState(initialDownloadOptions) + const [downloadOptionsDraft, setDownloadOptionsDraft] = useState() + const [pickerView, setPickerView] = useState<'formats' | 'options'>('formats') const ytdlpRef = useRef('') const highlightRef = useRef(0) // choice under the cursor, for the ↵ hint click + const optionHighlightRef = useRef(DOWNLOAD_OPTION_DEFINITIONS[0].id) const infoJsonRef = useRef(undefined) const abortRef = useRef(undefined) const [phase, setPhase] = useState(initialUrl ? {name: 'probing', status: 'warming up…'} : {name: 'input'}) @@ -193,6 +209,8 @@ function AppContent({ setInfo(videoInfo) setChoices(buildChoices(videoInfo)) highlightRef.current = 0 + setDownloadOptionsDraft(undefined) + setPickerView('formats') setPhase({name: 'picking'}) } catch (error) { if (controller.signal.aborted) return @@ -210,6 +228,8 @@ function AppContent({ setPlatform(undefined) setInfo(undefined) setChoices([]) + setDownloadOptionsDraft(undefined) + setPickerView('formats') setPhase({name: 'input'}) }, []) @@ -219,13 +239,50 @@ function AppContent({ setUrlInput(url) // keep the link around so a cancel isn't destructive }, [resetToInput, url]) + const openDownloadOptions = () => { + setDownloadOptionsDraft(downloadOptions) + setPickerView('options') + } + + const cancelDownloadOptions = () => { + setDownloadOptionsDraft(undefined) + setPickerView('formats') + } + + const saveDownloadOptions = () => { + if (downloadOptionsDraft) setDownloadOptions(downloadOptionsDraft) + setDownloadOptionsDraft(undefined) + setPickerView('formats') + } + + const toggleDownloadOption = (option: DownloadOptionId) => { + setDownloadOptionsDraft(current => { + const options = current ?? downloadOptions + return setDownloadOption(options, option, !options[option]) + }) + } + + const toggleHighlightedDownloadOption = () => toggleDownloadOption(optionHighlightRef.current) + useInput( (input, key) => { if (key.ctrl && input === 't') { cycleTheme() return } - if (key.escape && (phase.name === 'picking' || phase.name === 'error' || phase.name === 'done')) resetToInput() + if (key.ctrl && input === 'o' && phase.name === 'picking' && pickerView === 'formats') { + openDownloadOptions() + return + } + if (phase.name === 'picking' && pickerView === 'options' && input === ' ') { + toggleHighlightedDownloadOption() + return + } + if (key.escape && phase.name === 'picking') { + if (pickerView === 'options') cancelDownloadOptions() + else resetToInput() + } + if (key.escape && (phase.name === 'error' || phase.name === 'done')) resetToInput() if (key.escape && (phase.name === 'probing' || phase.name === 'downloading')) cancelRun() if (key.return && (phase.name === 'error' || phase.name === 'done')) resetToInput() }, @@ -259,7 +316,7 @@ function AppContent({ } try { const ffmpegLocation = await findFfmpeg() - const base = {ytdlp: ytdlpRef.current, ffmpegLocation, url, choice, outDir: OUT_DIR} + const base = {ytdlp: ytdlpRef.current, ffmpegLocation, url, choice, downloadOptions, outDir: OUT_DIR} let filepath: string try { // reuse the probe's metadata — starts immediately instead of re-extracting @@ -282,7 +339,17 @@ function AppContent({ })() } - let hints: Array<[string, string]> = [...HINTS[phase.name], ['^t', `theme:${theme.mode}`]] + const phaseHints: Array<[string, string]> = + phase.name === 'picking' && pickerView === 'options' + ? [ + ['↑↓', 'choose'], + ['space', 'toggle'], + ['↵', 'done'], + ['esc', 'cancel'], + ['^c', 'quit'], + ] + : HINTS[phase.name] + let hints: Array<[string, string]> = [...phaseHints, ['^t', `theme:${theme.mode}`]] if (phase.name === 'input' && history.length > 0) { hints = [hints[0]!, ['↑', 'history'], ...hints.slice(1)] } @@ -293,10 +360,18 @@ function AppContent({ const hintAction = (key: string): (() => void) | undefined => { if (key === '^c') return () => exit() if (key === '^t') return cycleTheme - if (key === 'esc') return phase.name === 'probing' || phase.name === 'downloading' ? cancelRun : resetToInput + if (key === '^o' && phase.name === 'picking' && pickerView === 'formats') return openDownloadOptions + if (key === 'space' && phase.name === 'picking' && pickerView === 'options') return toggleHighlightedDownloadOption + if (key === 'esc') { + if (phase.name === 'probing' || phase.name === 'downloading') return cancelRun + if (phase.name === 'picking' && pickerView === 'options') return cancelDownloadOptions + return resetToInput + } if (key === '↵') { if (phase.name === 'input') return () => handleUrlSubmit(urlInput) - if (phase.name === 'picking') return () => handlePick({value: highlightRef.current}) + if (phase.name === 'picking') { + return pickerView === 'options' ? saveDownloadOptions : () => handlePick({value: highlightRef.current}) + } if (phase.name === 'error' || phase.name === 'done') return resetToInput } return undefined // ↑↓ / ↑ stay keyboard-only @@ -307,8 +382,17 @@ function AppContent({ clickTargets.push({match: ` ${YOINK_BUTTON} `, padY: 1, action: () => handleUrlSubmit(urlInput)}) } if (phase.name === 'picking') { - for (const [index, choice] of choices.entries()) { - clickTargets.push({match: choiceLabel(choice), action: () => handlePick({value: index})}) + if (pickerView === 'formats') { + for (const [index, choice] of choices.entries()) { + clickTargets.push({match: choiceLabel(choice), action: () => handlePick({value: index})}) + } + } else { + for (const option of DOWNLOAD_OPTION_DEFINITIONS) { + clickTargets.push({ + match: option.label, + action: () => toggleDownloadOption(option.id), + }) + } } } if (phase.name === 'done') { @@ -395,18 +479,35 @@ function AppContent({ {info?.uploader ? ` · ${info.uploader}` : ''} - - ({ - key: String(index), - label: choiceLabel(choice), - value: index, - }))} - onSelect={handlePick} - onHighlight={item => (highlightRef.current = item.value)} - /> + + {pickerView === 'formats' ? ( + ({ + key: String(index), + label: choiceLabel(choice), + value: index, + }))} + onSelect={handlePick} + onHighlight={item => (highlightRef.current = item.value)} + /> + ) : ( + + + indicatorComponent={ChoiceIndicator} + itemComponent={ChoiceItem} + items={DOWNLOAD_OPTION_DEFINITIONS.map(option => ({ + key: option.id, + label: `${(downloadOptionsDraft ?? downloadOptions)[option.id] ? '●' : '○'} ${option.label}`, + value: option.id, + }))} + limit={OPTION_LIST_LIMIT} + onSelect={saveDownloadOptions} + onHighlight={item => (optionHighlightRef.current = item.value)} + /> + + )} )} diff --git a/src/cli.tsx b/src/cli.tsx index 54a5946..344111f 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -24,6 +24,7 @@ const HELP = ` Options --theme use auto, light, or dark for this run + --embed-chapters embed video chapters when available -h, --help show this help -v, --version show version @@ -85,6 +86,7 @@ const {waitUntilExit} = render( initialUrl={initialUrl} clipboardUrl={clipboardUrl} initialThemeMode={initialThemeMode} + initialDownloadOptions={args.downloadOptions} onOutcome={result => (outcome = result)} />, // keep a copy of every frame so clicks can be hit-tested against it diff --git a/src/lib/args.test.ts b/src/lib/args.test.ts index a7d25bb..3970722 100644 --- a/src/lib/args.test.ts +++ b/src/lib/args.test.ts @@ -7,6 +7,7 @@ test('parses a url and a spaced theme option without confusing the value for the assert.deepEqual(parseArgs(['--theme', 'light', 'https://example.com/video']), { help: false, version: false, + downloadOptions: {embedChapters: false}, themeMode: 'light', initialUrl: 'https://example.com/video', }) @@ -16,11 +17,21 @@ test('parses an equals-style theme option after the url', () => { assert.deepEqual(parseArgs(['https://example.com/video', '--theme=dark']), { help: false, version: false, + downloadOptions: {embedChapters: false}, themeMode: 'dark', initialUrl: 'https://example.com/video', }) }) +test('parses download options alongside a url without treating them as positional arguments', () => { + assert.deepEqual(parseArgs(['https://example.com/video', '--embed-chapters']), { + help: false, + version: false, + initialUrl: 'https://example.com/video', + downloadOptions: {embedChapters: true}, + }) +}) + test('rejects missing, invalid, and unknown options', () => { assert.match(parseArgs(['--theme']).error ?? '', /needs a value/) assert.match(parseArgs(['--theme', 'sepia']).error ?? '', /unknown theme/) diff --git a/src/lib/args.ts b/src/lib/args.ts index 4d2d14d..a75b2b6 100644 --- a/src/lib/args.ts +++ b/src/lib/args.ts @@ -1,19 +1,28 @@ import {isThemeMode, type ThemeMode} from '../theme.js' +import { + DEFAULT_DOWNLOAD_OPTIONS, + downloadOptionForCliFlag, + setDownloadOption, + type DownloadOptions, +} from './download-options.js' export type CliArgs = { help: boolean version: boolean initialUrl?: string themeMode?: ThemeMode + /** Complete global download preferences, shared with the TUI. */ + downloadOptions: DownloadOptions error?: string } export function parseArgs(args: string[]): CliArgs { - const result: CliArgs = {help: false, version: false} + const result: CliArgs = {help: false, version: false, downloadOptions: DEFAULT_DOWNLOAD_OPTIONS} const positional: string[] = [] for (let index = 0; index < args.length; index++) { const arg = args[index]! + const downloadOption = downloadOptionForCliFlag(arg) if (arg === '-h' || arg === '--help') { result.help = true } else if (arg === '-v' || arg === '--version') { @@ -27,6 +36,8 @@ export function parseArgs(args: string[]): CliArgs { const value = arg.slice('--theme='.length) if (!isThemeMode(value)) return {...result, error: `unknown theme “${value}” — use auto, light, or dark`} result.themeMode = value + } else if (downloadOption) { + result.downloadOptions = setDownloadOption(result.downloadOptions, downloadOption, true) } else if (arg.startsWith('-')) { return {...result, error: `unknown option “${arg}”`} } else { diff --git a/src/lib/download-options.test.ts b/src/lib/download-options.test.ts new file mode 100644 index 0000000..47ce4e4 --- /dev/null +++ b/src/lib/download-options.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import {DEFAULT_DOWNLOAD_OPTIONS, setDownloadOption} from './download-options.js' + +test('returns a new options value without mutating the prior value', () => { + const enabled = setDownloadOption(DEFAULT_DOWNLOAD_OPTIONS, 'embedChapters', true) + + assert.notEqual(enabled, DEFAULT_DOWNLOAD_OPTIONS) + assert.deepEqual(DEFAULT_DOWNLOAD_OPTIONS, {embedChapters: false}) + assert.deepEqual(enabled, {embedChapters: true}) +}) diff --git a/src/lib/download-options.ts b/src/lib/download-options.ts new file mode 100644 index 0000000..fc62011 --- /dev/null +++ b/src/lib/download-options.ts @@ -0,0 +1,34 @@ +/** + * User-facing preferences that apply to a whole download, independent of the + * selected audio/video format. Keep this model free of yt-dlp arguments so + * the TUI and CLI express intent rather than command-line implementation. + */ +export const DOWNLOAD_OPTION_DEFINITIONS = [ + { + id: 'embedChapters', + label: 'Embed chapters', + cliFlag: '--embed-chapters', + }, +] as const + +export type DownloadOptionId = (typeof DOWNLOAD_OPTION_DEFINITIONS)[number]['id'] + +export type DownloadOptions = Readonly> + +export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = { + embedChapters: false, +} + +/** Resolve a supported CLI flag to the option it enables. */ +export function downloadOptionForCliFlag(flag: string): DownloadOptionId | undefined { + return DOWNLOAD_OPTION_DEFINITIONS.find(option => option.cliFlag === flag)?.id +} + +/** Return a new preference object after changing one option. */ +export function setDownloadOption( + options: DownloadOptions, + id: DownloadOptionId, + enabled: boolean, +): DownloadOptions { + return {...options, [id]: enabled} +} diff --git a/src/lib/ytdlp.test.ts b/src/lib/ytdlp.test.ts new file mode 100644 index 0000000..6225357 --- /dev/null +++ b/src/lib/ytdlp.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import {DEFAULT_DOWNLOAD_OPTIONS} from './download-options.js' +import {buildDownloadArgs, type DownloadChoice} from './ytdlp.js' + +const choice: DownloadChoice = { + kind: 'video', + label: 'best available · mp4', + args: ['-f', 'bv*+ba/b'], +} + +test('adds embed-chapters only when the download preference is enabled', () => { + const base = { + url: 'https://example.com/video', + choice, + downloadOptions: DEFAULT_DOWNLOAD_OPTIONS, + outDir: '/downloads', + } + + assert.equal(buildDownloadArgs(base).includes('--embed-chapters'), false) + assert.ok(buildDownloadArgs({...base, downloadOptions: {embedChapters: true}}).includes('--embed-chapters')) +}) diff --git a/src/lib/ytdlp.ts b/src/lib/ytdlp.ts index 9bb5dff..9257d1d 100644 --- a/src/lib/ytdlp.ts +++ b/src/lib/ytdlp.ts @@ -6,6 +6,7 @@ import path from 'node:path' import {Readable} from 'node:stream' import {pipeline} from 'node:stream/promises' import {formatBytes} from './format.js' +import {DOWNLOAD_OPTION_DEFINITIONS, type DownloadOptionId, type DownloadOptions} from './download-options.js' const YOINKS_DIR = path.join(os.homedir(), '.yoinks', 'bin') const RELEASE_BASE = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download' @@ -209,28 +210,40 @@ export type DownloadHandlers = { onProcessing: () => void } +export type DownloadRequest = { + ytdlp: string + ffmpegLocation?: string + url: string + /** When set, reuse the probe's metadata instead of re-extracting — starts much faster. */ + infoJsonPath?: string + choice: DownloadChoice + /** User intent, translated to yt-dlp flags only in this module. */ + downloadOptions: DownloadOptions + outDir: string +} + +const YT_DLP_DOWNLOAD_OPTION_ARGS: Readonly> = { + embedChapters: ['--embed-chapters'], +} + +function downloadOptionArgs(options: DownloadOptions): string[] { + return DOWNLOAD_OPTION_DEFINITIONS.flatMap(option => + options[option.id] ? YT_DLP_DOWNLOAD_OPTION_ARGS[option.id] : [], + ) +} + const PROGRESS_PREFIX = 'YOINK|' const PROGRESS_TEMPLATE = `${PROGRESS_PREFIX}%(progress.downloaded_bytes)s|%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|%(progress.speed)s|%(progress.eta)s` let activeChild: ChildProcess | undefined process.on('exit', () => activeChild?.kill('SIGTERM')) -export function download( - opts: { - ytdlp: string - ffmpegLocation?: string - url: string - /** When set, reuse the probe's metadata instead of re-extracting — starts much faster. */ - infoJsonPath?: string - choice: DownloadChoice - outDir: string - }, - handlers: DownloadHandlers, - signal?: AbortSignal, -): Promise { +/** Build the yt-dlp command for a requested download without spawning it. */ +export function buildDownloadArgs(opts: Omit): string[] { const args = [ ...(opts.infoJsonPath ? ['--load-info-json', opts.infoJsonPath] : [opts.url]), ...opts.choice.args, + ...downloadOptionArgs(opts.downloadOptions), '--no-playlist', '--no-warnings', '--newline', @@ -247,6 +260,15 @@ export function download( path.join(opts.outDir, '%(title).60s.%(ext)s'), ] if (opts.ffmpegLocation) args.push('--ffmpeg-location', opts.ffmpegLocation) + return args +} + +export function download( + opts: DownloadRequest, + handlers: DownloadHandlers, + signal?: AbortSignal, +): Promise { + const args = buildDownloadArgs(opts) return new Promise((resolve, reject) => { const child = spawn(opts.ytdlp, args, {signal})