Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/src/main/downloader/__tests__/downloader-config-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';

// Point the store's userData dir at a real temp dir and stub electron. The
// factory runs when the store first imports 'electron', by which time `tmpDir`
// (declared above) is initialised.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wn-dl-cfg-'));
vi.mock('electron', () => ({ app: { getPath: () => tmpDir } }));

import {
getBackendPath,
getBackendPaths,
setBackendPath,
} from '../downloader-config-store';

const configFile = path.join(tmpDir, 'downloader-config.json');

beforeEach(() => {
if (fs.existsSync(configFile)) fs.unlinkSync(configFile);
});

describe('downloader-config-store', () => {
it('returns an empty map when no config file exists', () => {
expect(getBackendPaths()).toEqual({});
expect(getBackendPath('ytdlp')).toBeUndefined();
});

it('persists and reads back a custom path', () => {
setBackendPath('ytdlp', '/opt/bin/yt-dlp');
expect(getBackendPath('ytdlp')).toBe('/opt/bin/yt-dlp');
expect(getBackendPaths()).toEqual({ ytdlp: '/opt/bin/yt-dlp' });
// Written to disk so the main process reads it on next launch.
expect(fs.existsSync(configFile)).toBe(true);
});

it('trims whitespace around a stored path', () => {
setBackendPath('spotdl', ' /usr/local/bin/spotdl ');
expect(getBackendPath('spotdl')).toBe('/usr/local/bin/spotdl');
});

it('clears a path when given null or blank', () => {
setBackendPath('spotdl', '/x/spotdl');
expect(setBackendPath('spotdl', null)).toEqual({});
expect(getBackendPath('spotdl')).toBeUndefined();

setBackendPath('spotdl', '/x/spotdl');
setBackendPath('spotdl', ' ');
expect(getBackendPath('spotdl')).toBeUndefined();
});

it('ignores unknown backend ids', () => {
expect(setBackendPath('bogus', '/x')).toEqual({});
expect(getBackendPath('bogus')).toBeUndefined();
});

it('drops unknown / blank entries when reading a hand-edited file', () => {
fs.writeFileSync(
configFile,
JSON.stringify({ paths: { ytdlp: '/a', bogus: '/b', spotdl: ' ' } }),
'utf-8',
);
expect(getBackendPaths()).toEqual({ ytdlp: '/a' });
});

it('recovers from a corrupt config file', () => {
fs.writeFileSync(configFile, 'not json', 'utf-8');
expect(getBackendPaths()).toEqual({});
});

it('keeps independent paths per backend', () => {
setBackendPath('ytdlp', '/a/yt-dlp');
setBackendPath('spytify', 'C:\\tools\\spytify.exe');
expect(getBackendPaths()).toEqual({ ytdlp: '/a/yt-dlp', spytify: 'C:\\tools\\spytify.exe' });
});
});
99 changes: 99 additions & 0 deletions app/src/main/downloader/downloader-config-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Downloader Configuration Store
*
* Persists per-backend custom executable paths to disk so users whose binary
* is not on PATH (common on Windows, or for pyenv/pipx installs) can point
* WhatNext at it. This lives in the *main* process because the path is consumed
* where backends are spawned — localStorage (renderer-only) cannot reach it.
*
* Storage: JSON file in Electron's userData directory.
* ~/.config/WhatNext/downloader-config.json (Linux)
* ~/Library/Application Support/WhatNext/... (macOS)
* %APPDATA%\WhatNext\downloader-config.json (Windows)
*
* No bundling is introduced — binaries remain user-installed. This only records
* *where* a user-installed binary lives.
*/

import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';

export type DownloaderBackendId = 'ytdlp' | 'spotdl' | 'spytify';

const BACKEND_IDS: readonly DownloaderBackendId[] = ['ytdlp', 'spotdl', 'spytify'];

/** A path map keyed by backend id. Absent/blank entries mean "use PATH". */
export type BackendPaths = Partial<Record<DownloaderBackendId, string>>;

interface DownloaderConfig {
paths: BackendPaths;
}

const DEFAULT_CONFIG: DownloaderConfig = { paths: {} };

function isBackendId(id: string): id is DownloaderBackendId {
return (BACKEND_IDS as readonly string[]).includes(id);
}

function getConfigPath(): string {
return path.join(app.getPath('userData'), 'downloader-config.json');
}

function readConfig(): DownloaderConfig {
try {
const raw = fs.readFileSync(getConfigPath(), 'utf-8');
const parsed = JSON.parse(raw) as DownloaderConfig;
if (!parsed || typeof parsed.paths !== 'object' || parsed.paths === null) {
return { paths: {} };
}
// Keep only known ids with non-empty string values.
const clean: BackendPaths = {};
for (const id of BACKEND_IDS) {
const value = parsed.paths[id];
if (typeof value === 'string' && value.trim()) {
clean[id] = value.trim();
}
}
return { paths: clean };
} catch {
return { paths: { ...DEFAULT_CONFIG.paths } };
}
}

function writeConfig(config: DownloaderConfig): void {
try {
fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8');
} catch (err) {
console.error('[DownloaderConfigStore] Failed to write config:', err);
}
}

/** Return the full map of configured custom paths. */
export function getBackendPaths(): BackendPaths {
return readConfig().paths;
}

/** Return the configured custom path for one backend, or undefined. */
export function getBackendPath(id: string): string | undefined {
if (!isBackendId(id)) return undefined;
return readConfig().paths[id];
}

/**
* Set (or, with a blank/null value, clear) the custom executable path for a
* backend. Returns the updated path map. Unknown ids are ignored.
*/
export function setBackendPath(id: string, executablePath: string | null): BackendPaths {
const config = readConfig();
if (!isBackendId(id)) return config.paths;

const trimmed = executablePath?.trim();
if (trimmed) {
config.paths[id] = trimmed;
} else {
delete config.paths[id];
}
writeConfig(config);
return config.paths;
}
32 changes: 29 additions & 3 deletions app/src/main/downloader/downloader-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { IPC_CHANNELS } from '../../shared/core/ipc-protocol';
import type {
BackendStatusResult,
DownloadResolveRequest,
BackendPathMap,
SetBackendPathPayload,
} from '../../shared/core/ipc-protocol';
import {
getBackendPath,
getBackendPaths,
setBackendPath,
} from './downloader-config-store';
import { killAll as killDownloadProcesses } from '../../../../service/downloader/subprocess';

export { killDownloadProcesses };
Expand Down Expand Up @@ -60,15 +67,15 @@ async function getBackend(
): Promise<import('../../../../service/downloader/backend').DownloadBackend> {
const mod = await getDownloaderModules();
if (id === 'ytdlp') {
if (!backends.ytdlp) backends.ytdlp = new mod.YtdlpBackend();
if (!backends.ytdlp) backends.ytdlp = new mod.YtdlpBackend(getBackendPath('ytdlp'));
return backends.ytdlp!;
}
if (id === 'spotdl') {
if (!backends.spotdl) backends.spotdl = new mod.SpotdlBackend();
if (!backends.spotdl) backends.spotdl = new mod.SpotdlBackend(getBackendPath('spotdl'));
return backends.spotdl!;
}
if (id === 'spytify') {
if (!backends.spytify) backends.spytify = new mod.SpytifyBackend();
if (!backends.spytify) backends.spytify = new mod.SpytifyBackend(getBackendPath('spytify'));
return backends.spytify!;
}
throw new Error(`Unknown download backend: "${id}"`);
Expand Down Expand Up @@ -108,6 +115,25 @@ export async function registerDownloadHandlers(win: BrowserWindow): Promise<void
return mod.suggestBackend(url);
});

// -----------------------------------------------------------------------
// download:get-backend-paths / download:set-backend-path
// Per-backend custom executable paths (persisted in userData).
// -----------------------------------------------------------------------
ipcMain.handle(
IPC_CHANNELS.DOWNLOAD_GET_BACKEND_PATHS,
async (): Promise<BackendPathMap> => getBackendPaths(),
);

ipcMain.handle(
IPC_CHANNELS.DOWNLOAD_SET_BACKEND_PATH,
async (_e, payload: SetBackendPathPayload): Promise<BackendPathMap> => {
const updated = setBackendPath(payload.id, payload.path);
// Invalidate the cached instance so the next call rebuilds with the new path.
backends[payload.id] = null;
return updated;
},
);

// -----------------------------------------------------------------------
// download:resolve
// Resolves a URL (or spotify IDs) into a list of ResolvedTrack metadata.
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
ScanDirectoryResult,
BackendStatusResult,
DownloadResolveRequest,
BackendPathMap,
SetBackendPathPayload,
PurchaseResolvePayload,
PurchaseLinkResult,
} from '../shared/core/ipc-protocol';
Expand Down Expand Up @@ -450,6 +452,12 @@ const electronHandler = {
cancel: (): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.DOWNLOAD_CANCEL),

getBackendPaths: (): Promise<BackendPathMap> =>
ipcRenderer.invoke(IPC_CHANNELS.DOWNLOAD_GET_BACKEND_PATHS),

setBackendPath: (payload: SetBackendPathPayload): Promise<BackendPathMap> =>
ipcRenderer.invoke(IPC_CHANNELS.DOWNLOAD_SET_BACKEND_PATH, payload),

onProgress: (cb: (event: DownloadEvent) => void) => {
const listener = (_e: IpcRendererEvent, ev: DownloadEvent) => cb(ev);
ipcRenderer.on(IPC_CHANNELS.DOWNLOAD_PROGRESS, listener);
Expand Down
25 changes: 19 additions & 6 deletions app/src/renderer/components/Download/BackendGate.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* BackendGate — shown when no download backend is installed.
* Provides install instructions for yt-dlp, spotDL, and Spytify.
* Provides install instructions for yt-dlp, spotDL, and Spytify, and points
* users who already have a binary (just not on PATH) at the custom-path setting.
*/

import type { BackendStatusResult } from '../../../shared/core/ipc-protocol';
import { describeBackendStatus } from './backend-status';

interface BackendGateProps {
backends: BackendStatusResult[];
Expand Down Expand Up @@ -48,6 +50,8 @@ export function BackendGate({ backends }: BackendGateProps) {
{missing.map((b) => {
const info = INSTALL_INSTRUCTIONS[b.id];
if (!info) return null;
const view = describeBackendStatus(b);
const misconfigured = view.state === 'misconfigured';
return (
<div
key={b.id}
Expand All @@ -66,10 +70,17 @@ export function BackendGate({ backends }: BackendGateProps) {
Docs →
</button>
</div>
<code className="block text-xs bg-surface-high text-on-surface-variant rounded-lg px-3 py-2 font-mono">
{info.instructions}
</code>
{b.error && (
{misconfigured ? (
<p className="text-xs text-amber-500">
Configured path not found:{' '}
<span className="font-mono break-all">{view.path}</span>
</p>
) : (
<code className="block text-xs bg-surface-high text-on-surface-variant rounded-lg px-3 py-2 font-mono">
{info.instructions}
</code>
)}
{b.error && !misconfigured && (
<p className="text-xs text-error">{b.error}</p>
)}
</div>
Expand All @@ -78,7 +89,9 @@ export function BackendGate({ backends }: BackendGateProps) {
</div>

<p className="text-xs text-on-surface-variant text-center">
After installing, restart WhatNext and return here.
Already installed, just not on your PATH? Set a custom path under{' '}
<span className="text-on-surface">Settings → Download → Backend Tools</span>, then
re-check. Otherwise, install one above, restart WhatNext, and return here.
</p>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { describeBackendStatus } from '../backend-status';
import type { BackendStatusResult } from '../../../../shared/core/ipc-protocol';

function status(p: Partial<BackendStatusResult>): BackendStatusResult {
return { id: 'ytdlp', name: 'yt-dlp', installed: false, ...p };
}

describe('describeBackendStatus', () => {
it('installed via PATH (no custom path) -> installed-default', () => {
const v = describeBackendStatus(status({ installed: true, version: '2024.08.06' }));
expect(v.state).toBe('installed-default');
expect(v.path).toBeUndefined();
expect(v.version).toBe('2024.08.06');
});

it('installed at a configured path -> installed-custom', () => {
const v = describeBackendStatus(status({ installed: true, path: '/opt/bin/yt-dlp' }));
expect(v.state).toBe('installed-custom');
expect(v.path).toBe('/opt/bin/yt-dlp');
});

it('not installed with a configured path -> misconfigured', () => {
const v = describeBackendStatus(status({ installed: false, path: '/bad/path', error: 'ENOENT' }));
expect(v.state).toBe('misconfigured');
expect(v.path).toBe('/bad/path');
expect(v.error).toBe('ENOENT');
});

it('not installed, no path -> missing', () => {
expect(describeBackendStatus(status({ installed: false })).state).toBe('missing');
});

it('treats a blank/whitespace path as no path', () => {
expect(describeBackendStatus(status({ installed: false, path: ' ' })).state).toBe('missing');
expect(describeBackendStatus(status({ installed: true, path: '' })).state).toBe('installed-default');
});
});
44 changes: 44 additions & 0 deletions app/src/renderer/components/Download/backend-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Pure interpretation of a backend's install status for the Download UI.
*
* Extracted so the "not installed" vs "installed at a custom path" vs
* "configured path is wrong" distinction (#46) can be unit-tested without a DOM —
* the renderer test environment is `node`, with no jsdom.
*/
import type { BackendStatusResult } from '../../../shared/core/ipc-protocol';

export type BackendState =
/** Found via PATH, no custom path configured. */
| 'installed-default'
/** Found at a user-configured custom path. */
| 'installed-custom'
/** A custom path is configured but the binary there did not run. */
| 'misconfigured'
/** Not found and no custom path configured. */
| 'missing';

export interface BackendStatusView {
state: BackendState;
installed: boolean;
/** The configured custom path, if any. */
path?: string;
version?: string;
error?: string;
}

export function describeBackendStatus(status: BackendStatusResult): BackendStatusView {
const hasPath = typeof status.path === 'string' && status.path.trim().length > 0;
let state: BackendState;
if (status.installed) {
state = hasPath ? 'installed-custom' : 'installed-default';
} else {
state = hasPath ? 'misconfigured' : 'missing';
}
return {
state,
installed: status.installed,
path: hasPath ? status.path : undefined,
version: status.version,
error: status.error,
};
}
Loading
Loading