import { test, expect } from 'bun:test'; import { createResilientYtdlp, canTryAnotherClient } from './ytdlp-resilience.js'; const quiet = { warn() {} }; const unavailable = () => new Error('ERROR: [youtube] wZzRoXymOUU: Video unavailable'); test('metadata and save calls retry unavailable videos with alternate clients', async () => { for (const args of [['-J', '--no-warnings', 'https://www.youtube.com/watch?v=wZzRoXymOUU'], ['https://www.youtube.com/watch?v=wZzRoXymOUU', '-f', 'bv*+ba/b', '--merge-output-format', 'mp4', '-o', '/tmp/save.mp4']]) { const calls = []; const signal = new AbortController().signal; const run = createResilientYtdlp({ clients: ['web_embedded', 'web_safari'], log: quiet, withCookies: argv => ['--cookies', '/fixture/cookies.txt', ...argv], run: async (argv, opts) => { calls.push([argv, opts]); if (calls.length < 3) throw calls.length === 1 ? unavailable() : new Error('Requested format is not available'); return 'success'; }, }); expect(await run(args, { signal })).toBe('success'); expect(calls).toHaveLength(3); expect(calls[2][0]).toEqual(['--cookies', '/fixture/cookies.txt', '--extractor-args', 'youtube:player_client=web_safari', ...args]); expect(calls.every(([, opts]) => opts.signal === signal)).toBe(true); } }); test('an initial missing-format error also gets a client fallback', async () => { let calls = 0; const run = createResilientYtdlp({ log: quiet, clients: ['web_safari'], run: async () => { if (++calls === 1) throw new Error('Requested format is not available'); return 'ok'; } }); expect(await run(['-f', 'b'])).toBe('ok'); }); test('exhaustion preserves the original diagnosis and the attempt trail', async () => { let calls = 0; const run = createResilientYtdlp({ clients: ['web_embedded', 'web_embedded', 'mweb'], log: quiet, run: async () => { if (++calls === 1) throw unavailable(); throw new Error('Requested format is not available'); } }); const err = await run(['-J']).catch(e => e); expect(calls).toBe(3); expect(err.message).toContain('wZzRoXymOUU: Video unavailable'); expect(err.message).toContain('default, web_embedded, mweb'); expect(err.attempts).toHaveLength(3); }); test('explicit client selection and unrelated failures are not retried', async () => { for (const [args, message] of [[['--extractor-args', 'youtube:player_client=mweb'], 'Video unavailable'], [['--extractor-args', 'youtube:player-client=mweb'], 'Video unavailable'], [[], 'No space left on device'], [[], 'yt-dlp not found'], [[], 'HTTP Error 429: Too Many Requests']]) { let calls = 0; const run = createResilientYtdlp({ log: quiet, run: async () => { calls++; throw new Error(message); } }); await expect(run(args)).rejects.toThrow(message); expect(calls).toBe(1); } }); test('explicit access restrictions stop retries, even with an unavailable prefix', () => { for (const detail of ['Private video', 'Video has been removed', 'not available in your country', 'members-only', 'Confirm your age', 'copyright claim']) expect(canTryAnotherClient(new Error('Video unavailable. ' + detail))).toBe(false); expect(canTryAnotherClient(new Error('Sign in to confirm you are not a bot'))).toBe(true); }); test('cancellation stops the fallback chain', async () => { const controller = new AbortController(); let calls = 0; const run = createResilientYtdlp({ log: quiet, run: async () => { calls++; controller.abort(); throw unavailable(); } }); await expect(run(['-J'], { signal: controller.signal })).rejects.toThrow('Video unavailable'); expect(calls).toBe(1); await expect(run(['-J'], { signal: controller.signal })).rejects.toThrow(); expect(calls).toBe(1); });