Skip to content

Commit ad17d03

Browse files
committed
feat(YouTube): Remove dependency on YouTube.js
1 parent 5e3a7c8 commit ad17d03

File tree

5 files changed

+692
-146
lines changed

5 files changed

+692
-146
lines changed

deno.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@
2929
"std/": "https://deno.land/std@0.216.0/",
3030
"tabler-icons/": "https://deno.land/x/tabler_icons_tsx@0.0.6/tsx/",
3131
"ts-custom-error": "https://esm.sh/ts-custom-error@3.3.1",
32-
"utils/": "https://deno.land/x/es_utils@v0.2.1/",
33-
"youtubei.js": "https://deno.land/x/youtubei@v14.0.0-deno/deno.ts"
32+
"utils/": "https://deno.land/x/es_utils@v0.2.1/"
3433
},
3534
"lock": false,
3635
"tasks": {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
type TrackingParams = { trackingParams: string };
2+
3+
type APIResponse = {
4+
responseContext?: {
5+
serviceTrackingParams: unknown[];
6+
};
7+
} & TrackingParams;
8+
9+
export type Album = {
10+
contents?: Renderer<'TwoColumnBrowseResults'>;
11+
microformat: Renderer<'MicroformatData'>;
12+
background?: Renderer<'MusicThumbnail'>;
13+
} & APIResponse;
14+
15+
export type Playlist = {
16+
contents: Renderer<'TwoColumnBrowseResults'>;
17+
} & APIResponse;
18+
19+
export type Credits = {
20+
onResponseReceivedActions: {
21+
clickTrackingParams: string;
22+
openPopupAction: {
23+
popup: Renderer<'DismissableDialog'>;
24+
popupType: string;
25+
};
26+
}[];
27+
} & APIResponse;
28+
29+
export type SearchResult = {
30+
contents: Renderer<'TabbedSearchResults'>;
31+
} & APIResponse;
32+
33+
type Icon = { iconType: string };
34+
35+
type Thumbnail = {
36+
thumbnails: { url: string; width: number; height: number }[];
37+
thumbnailCrop: string;
38+
thumbnailScale: string;
39+
} & TrackingParams;
40+
41+
export type BrowseEndpoint = {
42+
browseEndpoint: {
43+
browseId: string;
44+
params: string;
45+
browseEndpointContextSupportedConfigs: {
46+
browseEndpointContextMusicConfig: { pageType: string };
47+
};
48+
};
49+
};
50+
51+
export type WatchEndpoint = {
52+
watchEndpoint: {
53+
videoId: string;
54+
playlistId?: string;
55+
watchEndpointMusicSupportedConfigs?: {
56+
musicVideoType?: string;
57+
};
58+
};
59+
};
60+
61+
export type QueueAddEndpoint = {
62+
queueAddEndpoint: unknown;
63+
};
64+
65+
export type ModalEndpoint = {
66+
modalEndpoint: unknown;
67+
};
68+
69+
export type Nodes = {
70+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MicroformatData.ts */
71+
MicroformatData: {
72+
urlCanonical: string;
73+
};
74+
DismissableDialog: unknown;
75+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/Tab.ts */
76+
Tab: {
77+
title: string;
78+
selected: boolean;
79+
content: Renderer<'SectionList'>;
80+
tabIdentifier: string;
81+
} & TrackingParams;
82+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/SectionList.ts */
83+
SectionList: {
84+
contents: (
85+
| Renderer<'ItemSection'>
86+
| Renderer<'MusicCardShelf'>
87+
| Renderer<'MusicShelf'>
88+
| Renderer<'MusicPlaylistShelf'>
89+
| Renderer<'MusicResponsiveHeader'>
90+
)[];
91+
};
92+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/ItemSection.ts */
93+
ItemSection: {
94+
contents: unknown[];
95+
} & TrackingParams;
96+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicShelf.ts */
97+
MusicShelf: {
98+
title: string;
99+
contents: Renderer<'MusicResponsiveListItem'>[];
100+
bottomText: YTText;
101+
bottomEndpoint: unknown;
102+
};
103+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicPlaylistShelf.ts */
104+
MusicPlaylistShelf: {
105+
contents: Renderer<'MusicResponsiveListItem'>[];
106+
collapsedItemCount: number;
107+
targetId: string;
108+
} & TrackingParams;
109+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicCardShelf.ts */
110+
MusicCardShelf: {
111+
thumbnail: Renderer<'MusicThumbnail'>;
112+
title: YTText;
113+
subtitle: YTText;
114+
buttons: unknown[];
115+
menu: Renderer<'Menu'>;
116+
onTap: unknown;
117+
header: Renderer<'MusicCardShelfHeaderBasic'>;
118+
endIcon: Icon;
119+
thumbnailOverlay: Renderer<'MusicItemThumbnailOverlay'>;
120+
};
121+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicThumbnail.ts */
122+
MusicThumbnail: {
123+
thumbnail: Thumbnail;
124+
};
125+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/menus/Menu.ts */
126+
Menu: {
127+
items: (Renderer<'MenuNavigationItem'> | Renderer<'MenuServiceItem'>)[];
128+
};
129+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/menus/MenuNavigationItem.ts */
130+
MenuNavigationItem: {
131+
text: YTText;
132+
icon: Icon;
133+
navigationEndpoint: BrowseEndpoint | ModalEndpoint | QueueAddEndpoint | WatchEndpoint;
134+
};
135+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/menus/MenuServiceItem.ts */
136+
MenuServiceItem: unknown;
137+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicCardShelfHeaderBasic.ts */
138+
MusicCardShelfHeaderBasic: unknown;
139+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicItemThumbnailOverlay.ts */
140+
MusicItemThumbnailOverlay: unknown;
141+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicResponsiveListItem.ts */
142+
MusicResponsiveListItem: {
143+
thumbnail: Renderer<'MusicThumbnail'>;
144+
overlay: Renderer<'MusicItemThumbnailOverlay'>;
145+
flexColumns: Renderer<'MusicResponsiveListItemFlexColumn'>[];
146+
fixedColumns?: Renderer<'MusicResponsiveListItemFixedColumn'>[];
147+
menu: Renderer<'Menu'>;
148+
badges: Renderer<'MusicInlineBadge'>[];
149+
flexColumnDisplayStyle: string;
150+
navigationEndpoint: BrowseEndpoint;
151+
index?: YTText;
152+
} & TrackingParams;
153+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicResponsiveHeader.ts */
154+
MusicResponsiveHeader: {
155+
thumbnail?: Renderer<'MusicThumbnail'>;
156+
buttons?: unknown[];
157+
title: YTText;
158+
subtitle: YTText;
159+
straplineTextOne: YTText;
160+
straplineThumbnail: Renderer<'MusicThumbnail'>;
161+
subtitleBadge: Renderer<'MusicInlineBadge'>[];
162+
description: Renderer<'MusicDescriptionShelf'>;
163+
secondSubtitle: YTText;
164+
};
165+
MusicResponsiveListItemFixedColumn: {
166+
text: YTText;
167+
displayPriority?: string;
168+
size?: string;
169+
};
170+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicResponsiveListItemFlexColumn.ts */
171+
MusicResponsiveListItemFlexColumn: {
172+
text: YTText;
173+
displayPriority: string;
174+
};
175+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicDescriptionShelf.ts */
176+
MusicDescriptionShelf: {
177+
description: YTText;
178+
straplineBadge?: Renderer<'MusicInlineBadge'>[];
179+
};
180+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/MusicInlineBadge.ts */
181+
MusicInlineBadge: {
182+
icon: Icon;
183+
accessibilityData: unknown[];
184+
};
185+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/TabbedSearchResults.ts */
186+
TabbedSearchResults: {
187+
tabs: Renderer<'Tab'>[];
188+
};
189+
/** @see https://github.com/LuanRT/YouTube.js/blob/v14.0.0/src/parser/classes/TwoColumnBrowseResults.ts */
190+
TwoColumnBrowseResults: {
191+
secondaryContents: Renderer<'SectionList'>;
192+
tabs: Renderer<'Tab'>[];
193+
};
194+
};
195+
196+
type RendererName<T extends string> = `${Uncapitalize<T>}Renderer`;
197+
198+
export type Renderer<Node extends keyof Nodes> = { [K in RendererName<Node>]: Nodes[Node] };
199+
200+
type YTText = { runs: TextRun[]; accessibility?: { accessibilityData?: unknown } };
201+
202+
export type TextRun = { text: string; navigationEndpoint?: BrowseEndpoint | WatchEndpoint };
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
export const BROWSE_URL = new URL('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false&alt=json');
2+
export const SEARCH_URL = new URL('https://www.youtube.com/youtubei/v1/search?prettyPrint=false&alt=json');
3+
4+
export const USER_AGENT =
5+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36';
6+
7+
export const YOUTUBEI_HEADERS = {
8+
accept: '*/*',
9+
'accept-language': '*',
10+
'content-type': 'application/json',
11+
origin: 'https://www.youtube.com',
12+
'user-agent': USER_AGENT,
13+
'x-youtube-client-name': '67',
14+
'x-youtube-client-version': '1.20250219.01.00',
15+
};
16+
17+
export const YOUTUBEI_BODY = {
18+
isAudioOnly: true,
19+
context: {
20+
client: {
21+
hl: 'en',
22+
gl: 'US',
23+
screenDensityFloat: 1,
24+
screenHeightPoints: 1440,
25+
screenPixelDensity: 1,
26+
screenWidthPoints: 2560,
27+
clientName: 'WEB_REMIX',
28+
clientVersion: '1.20250219.01.00',
29+
osName: 'Windows',
30+
osVersion: '10.0',
31+
userAgent:
32+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
33+
platform: 'DESKTOP',
34+
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
35+
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
36+
timeZone: 'Europe/Berlin',
37+
originalUrl: 'https://www.youtube.com',
38+
deviceMake: '',
39+
deviceModel: '',
40+
browserName: 'Edge Chromium',
41+
browserVersion: '109.0.1518.61',
42+
utcOffsetMinutes: 120,
43+
memoryTotalKbytes: '8000000',
44+
mainAppWebInfo: {
45+
graftUrl: 'https://www.youtube.com',
46+
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
47+
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
48+
isWebNativeShareAvailable: true,
49+
},
50+
},
51+
user: { enableSafetyMode: false, lockedSafetyMode: false },
52+
request: { useSsl: true, internalExperimentFlags: [] },
53+
},
54+
};

providers/YouTubeMusic/mod.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts';
2+
import { stubProviderLookups } from '@/providers/test_stubs.ts';
3+
import { afterAll, describe } from '@std/testing/bdd';
4+
5+
import YouTubeMusicProvider from './mod.ts';
6+
7+
describe('YouTube Music provider', () => {
8+
const youtubeMusic = new YouTubeMusicProvider(makeProviderOptions());
9+
const stub = stubProviderLookups(youtubeMusic);
10+
11+
describeProvider(youtubeMusic, {
12+
urls: [
13+
{
14+
description: 'channel page',
15+
url: new URL('https://music.youtube.com/channel/UCxgN32UVVztKAQd2HkXzBtw'),
16+
id: { type: 'channel', id: 'UCxgN32UVVztKAQd2HkXzBtw' },
17+
isCanonical: true,
18+
},
19+
{
20+
description: 'playlist page',
21+
url: new URL('https://music.youtube.com/playlist?list=OLAK5uy_ncbxWnjKunOOgJ7N1XELrneNgiaMMPXxA'),
22+
id: { type: 'playlist', id: 'OLAK5uy_ncbxWnjKunOOgJ7N1XELrneNgiaMMPXxA' },
23+
isCanonical: true,
24+
serializedId: 'OLAK5uy_ncbxWnjKunOOgJ7N1XELrneNgiaMMPXxA',
25+
},
26+
{
27+
description: 'playlist page with additional query parameters',
28+
url: new URL(
29+
'https://music.youtube.com/playlist?list=OLAK5uy_ncbxWnjKunOOgJ7N1XELrneNgiaMMPXxA&feature=shared',
30+
),
31+
id: { type: 'playlist', id: 'OLAK5uy_ncbxWnjKunOOgJ7N1XELrneNgiaMMPXxA' },
32+
serializedId: 'OLAK5uy_ncbxWnjKunOOgJ7N1XELrneNgiaMMPXxA',
33+
},
34+
{
35+
description: 'album (browse) page',
36+
url: new URL('https://music.youtube.com/browse/MPREb_q16Gzaa1WK8'),
37+
id: { type: 'browse', id: 'MPREb_q16Gzaa1WK8' },
38+
isCanonical: true,
39+
serializedId: 'MPREb_q16Gzaa1WK8',
40+
},
41+
{
42+
description: 'track page',
43+
url: new URL('https://music.youtube.com/watch?v=-C_rvt0SwLE'),
44+
id: { type: 'watch', id: '-C_rvt0SwLE' },
45+
isCanonical: true,
46+
},
47+
],
48+
releaseLookup: [],
49+
});
50+
51+
afterAll(() => {
52+
stub.restore();
53+
});
54+
});

0 commit comments

Comments
 (0)