Files
ytplayer/server/innertube.test.js

63 lines
4.2 KiB
JavaScript

import { test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { parseSearch, lengthToSeconds, search, searchDeep, parseContinuation } from './innertube.js';
const fixture = JSON.parse(readFileSync(new URL('./fixtures/innertube-search.json', import.meta.url)));
test('parses video renderers into slim cards', () => {
const r = parseSearch(fixture);
expect(r.length).toBe(4);
expect(r[0]).toEqual({
id: 'nQWFzMvCfLE', title: 'What A Beautiful Name - Hillsong Worship', channel: 'Hillsong Worship',
channelId: 'UC4q12NoPNySbVqwpw4iO5Vg', channelUrl: 'https://www.youtube.com/channel/UC4q12NoPNySbVqwpw4iO5Vg',
duration: 343, thumbnail: 'https://i.ytimg.com/vi/nQWFzMvCfLE/mqdefault.jpg',
});
expect(r[1].duration).toBe(7187);
expect(r[3].duration).toBe(0); // live, no lengthText
});
test('lengthToSeconds', () => {
expect(lengthToSeconds('5:43')).toBe(343);
expect(lengthToSeconds('1:59:47')).toBe(7187);
expect(lengthToSeconds('LIVE')).toBe(0);
expect(lengthToSeconds(undefined)).toBe(0);
});
test('unexpected shape throws (caller falls back to yt-dlp)', () => {
expect(() => parseSearch({})).toThrow();
});
test('search() throws on HTTP error', async () => {
const fetchImpl = async () => new Response('no', { status: 429 });
await expect(search('x', { fetchImpl })).rejects.toThrow('429');
});
const card = (id) => ({ videoRenderer: { videoId: id, title: { runs: [{ text: 't' + id }] }, ownerText: { runs: [{ text: 'c' }] } } });
test('searchDeep follows continuation tokens, de-duplicates and stops at the limit', async () => {
const calls = [];
const fetchImpl = async (_u, init) => {
const body = JSON.parse(init.body);
calls.push(body.continuation || 'first');
const mk = (ids, token) => [{ itemSectionRenderer: { contents: ids.map(card) } }, ...(token ? [{ continuationItemRenderer: { continuationEndpoint: { continuationCommand: { token } } } }] : [])];
if (!body.continuation) return Response.json({ contents: { twoColumnSearchResultsRenderer: { primaryContents: { sectionListRenderer: { contents: mk(['a', 'b', 'c'], 'T1') } } } } });
if (body.continuation === 'T1') return Response.json({ onResponseReceivedCommands: [{ appendContinuationItemsAction: { continuationItems: mk(['c', 'd', 'e'], 'T2') } }] });
return Response.json({ onResponseReceivedCommands: [{ appendContinuationItemsAction: { continuationItems: mk(['f', 'g'], '') } }] });
};
const all = await searchDeep('x', { fetchImpl, limit: 200 });
expect(all.map((c) => c.id)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
expect(calls).toEqual(['first', 'T1', 'T2']);
expect((await searchDeep('x', { fetchImpl, limit: 4 })).length).toBe(4);
});
test('parseContinuation of an empty response is empty', () => {
expect(parseContinuation({})).toEqual({ cards: [], next: '' });
});
test('related parser reads compact and modern lockup cards, excluding playlists and ads', async () => {
const { parseRelated } = await import('./innertube.js');
const compact = { compactVideoRenderer: { videoId: 'aaaaaaaaaaa', title: { simpleText: 'First' }, shortBylineText: { runs: [{ text: 'Artist' }] }, lengthText: { simpleText: '3:12' } } };
const modern = { lockupViewModel: { contentId: 'bbbbbbbbbbb', contentType: 'LOCKUP_CONTENT_TYPE_VIDEO', metadata: { lockupMetadataViewModel: { title: { content: 'Second' }, metadata: { contentMetadataViewModel: { metadataRows: [{ metadataParts: [{ text: { content: 'Band' } }] }] } } } } } };
const result = parseRelated({ contents: { twoColumnWatchNextResults: { secondaryResults: { secondaryResults: { results: [compact, modern, compact, { lockupViewModel: { ...modern.lockupViewModel, contentType: 'LOCKUP_CONTENT_TYPE_PLAYLIST' } }, { adSlotRenderer: { contents: compact } }] } } } } });
expect(result.map(c => [c.id, c.title, c.channel])).toEqual([['aaaaaaaaaaa', 'First', 'Artist'], ['bbbbbbbbbbb', 'Second', 'Band']]);expect(result[0].duration).toBe(192);
});
test('related HTTP failures and unsupported shapes throw for search fallback', async () => {
const { related, parseRelated } = await import('./innertube.js');
expect(() => parseRelated({ error: {} })).toThrow('response shape');
await expect(related('aaaaaaaaaaa', { fetchImpl: async () => ({ ok: false, status: 429 }) })).rejects.toThrow('429');
});