-
Notifications
You must be signed in to change notification settings - Fork 1
feat(carousel): YouTube account-cookie auth for caption fetching #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ytexplorer
wants to merge
2
commits into
ragTechDev:main
Choose a base branch
from
ytexplorer:feat/yt-cookie-auth
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| import { createHash } from 'node:crypto'; | ||
| import { | ||
| decodeCookieB64, | ||
| parseCookies, | ||
| sapisidHash, | ||
| getCookieMode, | ||
| loadYtCookies, | ||
| makeCookieFetch, | ||
| } from '../carousel/ytCookies.js'; | ||
|
|
||
| // Helper: build a Netscape cookies.txt line | ||
| const nsLine = (name, value, domain = '.youtube.com') => | ||
| [domain, 'TRUE', '/', 'TRUE', '9999999999', name, value].join('\t'); | ||
|
|
||
| describe('decodeCookieB64', () => { | ||
| test('decodes a base64url payload back to the original UTF-8 text', () => { | ||
| const original = 'SID=abc123; HSID=def456'; | ||
| const b64 = Buffer.from(original, 'utf8').toString('base64url'); | ||
| expect(decodeCookieB64(b64)).toBe(original); | ||
| }); | ||
|
|
||
| test('tolerates surrounding whitespace/newlines in the encoded value', () => { | ||
| const original = 'SID=abc'; | ||
| const b64 = ' ' + Buffer.from(original, 'utf8').toString('base64url') + '\n'; | ||
| expect(decodeCookieB64(b64)).toBe(original); | ||
| }); | ||
| }); | ||
|
|
||
| describe('parseCookies', () => { | ||
| test('parses a Netscape cookies.txt into a cookie header string', () => { | ||
| const txt = ['# Netscape HTTP Cookie File', nsLine('SID', 'abc123'), nsLine('HSID', 'def456')].join('\n'); | ||
| const { cookies, cookieHeader } = parseCookies(txt); | ||
| expect(cookies.get('SID')).toBe('abc123'); | ||
| expect(cookies.get('HSID')).toBe('def456'); | ||
| expect(cookieHeader).toBe('SID=abc123; HSID=def456'); | ||
| }); | ||
|
|
||
| test('parses a raw Cookie header string', () => { | ||
| const { cookies, cookieHeader } = parseCookies('SID=abc123; HSID=def456'); | ||
| expect(cookies.get('SID')).toBe('abc123'); | ||
| expect(cookies.get('HSID')).toBe('def456'); | ||
| expect(cookieHeader).toBe('SID=abc123; HSID=def456'); | ||
| }); | ||
|
|
||
| test('preserves "=" characters inside cookie values (base64 tokens)', () => { | ||
| const { cookies } = parseCookies(nsLine('__Secure-3PSID', 'aa==bb==')); | ||
| expect(cookies.get('__Secure-3PSID')).toBe('aa==bb=='); | ||
| }); | ||
|
|
||
| test('ignores comment lines and blank lines', () => { | ||
| const txt = ['# comment', '', nsLine('SID', 'abc'), ' '].join('\n'); | ||
| const { cookies } = parseCookies(txt); | ||
| expect(cookies.size).toBe(1); | ||
| expect(cookies.get('SID')).toBe('abc'); | ||
| }); | ||
|
|
||
| test('parses #HttpOnly_-prefixed lines as data (HttpOnly cookies are not comments)', () => { | ||
| const txt = [ | ||
| '# Netscape HTTP Cookie File', | ||
| nsLine('SAPISID', 'S1'), // plain (not HttpOnly) | ||
| `#HttpOnly_${nsLine('SID', 'sid1')}`, // HttpOnly-prefixed | ||
| `#HttpOnly_${nsLine('__Secure-3PSID', 'p3')}`, | ||
| ].join('\n'); | ||
| const { cookies } = parseCookies(txt); | ||
| expect(cookies.get('SAPISID')).toBe('S1'); | ||
| expect(cookies.get('SID')).toBe('sid1'); | ||
| expect(cookies.get('__Secure-3PSID')).toBe('p3'); | ||
| }); | ||
|
|
||
| test('parses a cookies.txt that is entirely #HttpOnly_ lines', () => { | ||
| const txt = [`#HttpOnly_${nsLine('SID', 'sid1')}`, `#HttpOnly_${nsLine('HSID', 'h1')}`].join('\n'); | ||
| const { cookies } = parseCookies(txt); | ||
| expect(cookies.size).toBe(2); | ||
| expect(cookies.get('SID')).toBe('sid1'); | ||
| expect(cookies.get('HSID')).toBe('h1'); | ||
| }); | ||
|
|
||
| test('raw-header branch splits on the first "=" so base64 padding survives', () => { | ||
| const { cookies } = parseCookies('__Secure-3PSID=aa==bb==; SID=xyz'); | ||
| expect(cookies.get('__Secure-3PSID')).toBe('aa==bb=='); | ||
| expect(cookies.get('SID')).toBe('xyz'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('sapisidHash', () => { | ||
| const now = 1700000000; | ||
| const origin = 'https://www.youtube.com'; | ||
| const expected = (sapisid) => | ||
| `SAPISIDHASH ${now}_${createHash('sha1').update(`${now} ${sapisid} ${origin}`).digest('hex')}`; | ||
|
|
||
| test('computes the yt-dlp SAPISIDHASH header deterministically from SAPISID', () => { | ||
| const cookies = new Map([['SAPISID', 'MYSAPISID']]); | ||
| expect(sapisidHash(cookies, now, origin)).toBe(expected('MYSAPISID')); | ||
| }); | ||
|
|
||
| test('falls back to __Secure-3PAPISID when SAPISID is absent', () => { | ||
| const cookies = new Map([['__Secure-3PAPISID', 'THREEP']]); | ||
| expect(sapisidHash(cookies, now, origin)).toBe(expected('THREEP')); | ||
| }); | ||
|
|
||
| test('returns null when no SAPISID-family cookie is present', () => { | ||
| const cookies = new Map([['SID', 'abc']]); | ||
| expect(sapisidHash(cookies, now, origin)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getCookieMode', () => { | ||
| test('defaults to "fallback" when unset', () => { | ||
| expect(getCookieMode({})).toBe('fallback'); | ||
| }); | ||
|
|
||
| test('returns "primary" when YT_COOKIES_MODE=primary (case-insensitive)', () => { | ||
| expect(getCookieMode({ YT_COOKIES_MODE: 'Primary' })).toBe('primary'); | ||
| }); | ||
|
|
||
| test('falls back to "fallback" for unrecognized values', () => { | ||
| expect(getCookieMode({ YT_COOKIES_MODE: 'nonsense' })).toBe('fallback'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('loadYtCookies', () => { | ||
| const encode = (txt) => Buffer.from(txt, 'utf8').toString('base64url'); | ||
|
|
||
| test('loads and parses auth from YT_COOKIES_B64', () => { | ||
| const env = { YT_COOKIES_B64: encode(nsLine('SAPISID', 'S1') + '\n' + nsLine('SID', 'abc')) }; | ||
| const auth = loadYtCookies(env); | ||
| expect(auth).not.toBeNull(); | ||
| expect(auth.cookies.get('SAPISID')).toBe('S1'); | ||
| expect(auth.cookieHeader).toContain('SID=abc'); | ||
| }); | ||
|
|
||
| test('falls back to the legacy YT_COOKIE_B64 name', () => { | ||
| const env = { YT_COOKIE_B64: encode(nsLine('SID', 'abc')) }; | ||
| expect(loadYtCookies(env).cookies.get('SID')).toBe('abc'); | ||
| }); | ||
|
|
||
| test('returns null when neither env var is set', () => { | ||
| expect(loadYtCookies({})).toBeNull(); | ||
| }); | ||
|
|
||
| test('returns null when the payload contains no parseable cookies', () => { | ||
| // random binary that yields no name=value pairs | ||
| const env = { YT_COOKIES_B64: Buffer.from([0x00, 0x8c, 0xda, 0x4b]).toString('base64url') }; | ||
| expect(loadYtCookies(env)).toBeNull(); | ||
| }); | ||
|
|
||
| test('loads a realistic mixed export (plain SAPISID + #HttpOnly_ session cookies)', () => { | ||
| const txt = [ | ||
| '# Netscape HTTP Cookie File', | ||
| nsLine('SAPISID', 'S1'), | ||
| `#HttpOnly_${nsLine('SID', 'sid1')}`, | ||
| `#HttpOnly_${nsLine('__Secure-3PSID', 'p3')}`, | ||
| ].join('\n'); | ||
| const auth = loadYtCookies({ YT_COOKIES_B64: encode(txt) }); | ||
| expect(auth.cookies.get('SAPISID')).toBe('S1'); | ||
| expect(auth.cookies.get('SID')).toBe('sid1'); // must NOT be dropped as a comment | ||
| expect(auth.cookieHeader).toContain('__Secure-3PSID=p3'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('makeCookieFetch', () => { | ||
| const auth = { cookieHeader: 'SID=abc; SAPISID=S1', cookies: new Map([['SAPISID', 'S1'], ['SID', 'abc']]) }; | ||
|
|
||
| test('injects the Cookie header on youtube.com requests', async () => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const cf = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await cf('https://www.youtube.com/api/timedtext?v=x'); | ||
| expect(spy.mock.calls[0][1].headers.Cookie).toBe('SID=abc; SAPISID=S1'); | ||
| }); | ||
|
|
||
| test('does NOT inject the Cookie header on non-youtube requests', async () => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const cf = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await cf('https://example.com/thing'); | ||
| expect(spy.mock.calls[0][1].headers?.Cookie).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('adds SAPISIDHASH Authorization + Origin only when withAuth is set', async () => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const withAuth = makeCookieFetch(auth, { fetchImpl: spy, withAuth: true, now: 1700000000 }); | ||
| await withAuth('https://www.youtube.com/youtubei/v1/player'); | ||
| const h1 = spy.mock.calls[0][1].headers; | ||
| expect(h1.Authorization).toMatch(/^SAPISIDHASH 1700000000_/); | ||
| expect(h1.Origin).toBe('https://www.youtube.com'); | ||
|
|
||
| spy.mockClear(); | ||
| const noAuth = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await noAuth('https://www.youtube.com/api/timedtext'); | ||
| expect(spy.mock.calls[0][1].headers.Authorization).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('preserves caller-supplied headers', async () => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const cf = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await cf('https://www.youtube.com/', { headers: { 'User-Agent': 'UA-X' } }); | ||
| const h = spy.mock.calls[0][1].headers; | ||
| expect(h['User-Agent']).toBe('UA-X'); | ||
| expect(h.Cookie).toBe('SID=abc; SAPISID=S1'); | ||
| }); | ||
|
|
||
| test.each([ | ||
| 'https://youtube.com.attacker.net/api/timedtext', | ||
| 'https://www.youtube.com.evil.com/', | ||
| 'https://evil.example/?next=.youtube.com', | ||
| 'https://youtube.com@evil.com/', | ||
| 'https://notyoutube.com/', | ||
| 'https://googlevideo.com.evil.com/', | ||
| ])('does NOT attach the cookie to hostile/lookalike host: %s', async (url) => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const cf = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await cf(url); | ||
| expect(spy.mock.calls[0][1]?.headers?.Cookie).toBeUndefined(); | ||
| }); | ||
|
|
||
| test.each([ | ||
| 'https://www.youtube.com/api/timedtext?v=x', | ||
| 'https://m.youtube.com/', | ||
| 'https://youtube.com/', | ||
| 'https://r1---sn-abc.googlevideo.com/videoplayback', | ||
| ])('attaches the cookie to genuine YouTube host: %s', async (url) => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const cf = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await cf(url); | ||
| expect(spy.mock.calls[0][1].headers.Cookie).toBe('SID=abc; SAPISID=S1'); | ||
| }); | ||
|
|
||
| test('merges the account cookie with a caller-supplied Cookie header (both survive)', async () => { | ||
| const spy = jest.fn().mockResolvedValue({ ok: true }); | ||
| const cf = makeCookieFetch(auth, { fetchImpl: spy }); | ||
| await cf('https://www.youtube.com/api/timedtext', { headers: { Cookie: 'YSC=page123' } }); | ||
| const sent = spy.mock.calls[0][1].headers.Cookie; | ||
| expect(sent).toContain('YSC=page123'); | ||
| expect(sent).toContain('SAPISID=S1'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
loadYtCookies()inscripts/carousel/ytCookies.jsreadsYT_COOKIES_B64or the legacy aliasYT_COOKIE_B64, but onlyYT_COOKIES_B64is documented here — there's no way to discover the alias exists from this file.