363 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			363 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| import AlbumParser from "./parsers/AlbumParser"
 | |
| import ArtistParser from "./parsers/ArtistParser"
 | |
| import axios, { AxiosInstance } from "axios"
 | |
| import fs from "fs"
 | |
| import PlaylistParser from "./parsers/PlaylistParser"
 | |
| import SearchParser from "./parsers/SearchParser"
 | |
| import SongParser from "./parsers/SongParser"
 | |
| import traverse from "./utils/traverse"
 | |
| import VideoParser from "./parsers/VideoParser"
 | |
| import { Cookie, CookieJar } from "tough-cookie"
 | |
| 
 | |
| export default class YTMusic {
 | |
| 	private cookiejar: CookieJar
 | |
| 	private config?: Record<string, string>
 | |
| 	private client: AxiosInstance
 | |
| 
 | |
| 	/**
 | |
| 	 * Creates an instance of YTMusic
 | |
| 	 * Make sure to call initialize()
 | |
| 	 */
 | |
| 	public constructor() {
 | |
| 		this.cookiejar = new CookieJar()
 | |
| 		this.config = {}
 | |
| 		this.client = axios.create({
 | |
| 			baseURL: "https://music.youtube.com/",
 | |
| 			headers: {
 | |
| 				"User-Agent":
 | |
| 					"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36",
 | |
| 				"Accept-Language": "en-US,en;q=0.5"
 | |
| 			},
 | |
| 			withCredentials: true
 | |
| 		})
 | |
| 
 | |
| 		this.client.interceptors.request.use(req => {
 | |
| 			if (!req.baseURL) return
 | |
| 
 | |
| 			const cookieString = this.cookiejar.getCookieStringSync(req.baseURL)
 | |
| 			if (cookieString) {
 | |
| 				if (!req.headers) {
 | |
| 					req.headers = {}
 | |
| 				}
 | |
| 				req.headers["Cookie"] = cookieString
 | |
| 			}
 | |
| 
 | |
| 			return req
 | |
| 		})
 | |
| 
 | |
| 		this.client.interceptors.response.use(res => {
 | |
| 			if ("set-cookie" in res.headers) {
 | |
| 				if (!res.config.baseURL) return
 | |
| 
 | |
| 				const setCookie = res.headers["set-cookie"] as Array<string> | string
 | |
| 				const cookieStrings: string[] = []
 | |
| 
 | |
| 				if (setCookie instanceof Array) {
 | |
| 					cookieStrings.push(...setCookie)
 | |
| 				} else {
 | |
| 					cookieStrings.push(setCookie)
 | |
| 				}
 | |
| 
 | |
| 				for (const cookieString of cookieStrings) {
 | |
| 					const cookie = Cookie.parse(cookieString)
 | |
| 					if (!cookie) return
 | |
| 
 | |
| 					this.cookiejar.setCookieSync(cookie, res.config.baseURL)
 | |
| 				}
 | |
| 			}
 | |
| 			return res
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Initializes the API
 | |
| 	 */
 | |
| 	public async initialize() {
 | |
| 		const html = (await this.client.get("/")).data as string
 | |
| 		const setConfigs = html.match(/ytcfg\.set\(.*\)/) || []
 | |
| 
 | |
| 		const configs = setConfigs
 | |
| 			.map(c => c.slice(10, -1))
 | |
| 			.map(s => {
 | |
| 				try {
 | |
| 					return JSON.parse(s)
 | |
| 				} catch {}
 | |
| 			})
 | |
| 			.filter(j => !!j)
 | |
| 
 | |
| 		for (const config of configs) {
 | |
| 			this.config = {
 | |
| 				...this.config,
 | |
| 				...config
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Constructs a basic YouTube Music API request with all essential headers
 | |
| 	 * and body parameters needed to make the API work
 | |
| 	 *
 | |
| 	 * @param endpoint Endpoint for the request
 | |
| 	 * @param body Body
 | |
| 	 * @param query Search params
 | |
| 	 * @returns Raw response from YouTube Music API which needs to be parsed
 | |
| 	 */
 | |
| 	private async constructRequest(
 | |
| 		endpoint: string,
 | |
| 		body: Record<string, any> = {},
 | |
| 		query: Record<string, string> = {}
 | |
| 	) {
 | |
| 		if (!this.config) {
 | |
| 			throw new Error("API not initialized. Make sure to call the initialize() method first")
 | |
| 		}
 | |
| 
 | |
| 		const headers: Record<string, any> = {
 | |
| 			...this.client.defaults.headers,
 | |
| 			"x-origin": this.client.defaults.baseURL,
 | |
| 			"X-Goog-Visitor-Id": this.config.VISITOR_DATA,
 | |
| 			"X-YouTube-Client-Name": this.config.INNERTUBE_CONTEXT_CLIENT_NAME,
 | |
| 			"X-YouTube-Client-Version": this.config.INNERTUBE_CLIENT_VERSION,
 | |
| 			"X-YouTube-Device": this.config.DEVICE,
 | |
| 			"X-YouTube-Page-CL": this.config.PAGE_CL,
 | |
| 			"X-YouTube-Page-Label": this.config.PAGE_BUILD_LABEL,
 | |
| 			"X-YouTube-Utc-Offset": String(-new Date().getTimezoneOffset()),
 | |
| 			"X-YouTube-Time-Zone": new Intl.DateTimeFormat().resolvedOptions().timeZone
 | |
| 		}
 | |
| 
 | |
| 		const searchParams = new URLSearchParams({
 | |
| 			...query,
 | |
| 			alt: "json",
 | |
| 			key: this.config.INNERTUBE_API_KEY
 | |
| 		})
 | |
| 
 | |
| 		const res = await this.client.post(
 | |
| 			`youtubei/${this.config.INNERTUBE_API_VERSION}/${endpoint}?${searchParams.toString()}`,
 | |
| 			{
 | |
| 				context: {
 | |
| 					capabilities: {},
 | |
| 					client: {
 | |
| 						clientName: this.config.INNERTUBE_CLIENT_NAME,
 | |
| 						clientVersion: this.config.INNERTUBE_CLIENT_VERSION,
 | |
| 						experimentIds: [],
 | |
| 						experimentsToken: "",
 | |
| 						gl: this.config.GL,
 | |
| 						hl: this.config.HL,
 | |
| 						locationInfo: {
 | |
| 							locationPermissionAuthorizationStatus:
 | |
| 								"LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED"
 | |
| 						},
 | |
| 						musicAppInfo: {
 | |
| 							musicActivityMasterSwitch: "MUSIC_ACTIVITY_MASTER_SWITCH_INDETERMINATE",
 | |
| 							musicLocationMasterSwitch: "MUSIC_LOCATION_MASTER_SWITCH_INDETERMINATE",
 | |
| 							pwaInstallabilityStatus: "PWA_INSTALLABILITY_STATUS_UNKNOWN"
 | |
| 						},
 | |
| 						utcOffsetMinutes: -new Date().getTimezoneOffset()
 | |
| 					},
 | |
| 					request: {
 | |
| 						internalExperimentFlags: [
 | |
| 							{
 | |
| 								key: "force_music_enable_outertube_tastebuilder_browse",
 | |
| 								value: "true"
 | |
| 							},
 | |
| 							{
 | |
| 								key: "force_music_enable_outertube_playlist_detail_browse",
 | |
| 								value: "true"
 | |
| 							},
 | |
| 							{
 | |
| 								key: "force_music_enable_outertube_search_suggestions",
 | |
| 								value: "true"
 | |
| 							}
 | |
| 						],
 | |
| 						sessionIndex: {}
 | |
| 					},
 | |
| 					user: {
 | |
| 						enableSafetyMode: false
 | |
| 					}
 | |
| 				},
 | |
| 				...body
 | |
| 			},
 | |
| 			{
 | |
| 				responseType: "json",
 | |
| 				headers
 | |
| 			}
 | |
| 		)
 | |
| 
 | |
| 		return "responseContext" in res.data ? res.data : res
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get a list of search suggestiong based on the query
 | |
| 	 *
 | |
| 	 * @param query Query string
 | |
| 	 * @returns Search suggestions
 | |
| 	 */
 | |
| 	public async getSearchSuggestions(query: string): Promise<string[]> {
 | |
| 		return traverse(
 | |
| 			await this.constructRequest("music/get_search_suggestions", {
 | |
| 				input: query
 | |
| 			}),
 | |
| 			"query"
 | |
| 		)
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Searches YouTube Music API for content
 | |
| 	 *
 | |
| 	 * @param query Query string
 | |
| 	 * @param category Type of search results to receive
 | |
| 	 */
 | |
| 	public async search(query: string, category: "SONG"): Promise<YTMusic.SongDetailed[]>
 | |
| 	public async search(query: string, category: "PLAYLIST"): Promise<YTMusic.PlaylistDetailed[]>
 | |
| 	public async search(query: string, category: "VIDEO"): Promise<YTMusic.VideoDetailed[]>
 | |
| 	public async search(query: string, category: "ARTIST"): Promise<YTMusic.ArtistDetailed[]>
 | |
| 	public async search(query: string, category: "ALBUM"): Promise<YTMusic.AlbumDetailed[]>
 | |
| 	public async search(query: string, category: "PLAYLIST"): Promise<YTMusic.PlaylistDetailed[]>
 | |
| 	public async search(query: string): Promise<YTMusic.SearchResult[]>
 | |
| 	public async search(query: string, category?: string) {
 | |
| 		const searchData = await this.constructRequest("search", {
 | |
| 			query,
 | |
| 			params:
 | |
| 				{
 | |
| 					SONG: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D",
 | |
| 					VIDEO: "Eg-KAQwIABABGAAgACgAMABqChAEEAMQCRAFEAo%3D",
 | |
| 					ALBUM: "Eg-KAQwIABAAGAEgACgAMABqChAEEAMQCRAFEAo%3D",
 | |
| 					ARTIST: "Eg-KAQwIABAAGAAgASgAMABqChAEEAMQCRAFEAo%3D",
 | |
| 					PLAYLIST: "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D"
 | |
| 				}[category!] || null
 | |
| 		})
 | |
| 
 | |
| 		return [traverse(searchData, "musicResponsiveListItemRenderer")].flat().map(
 | |
| 			{
 | |
| 				SONG: SongParser.parseSearchResult,
 | |
| 				VIDEO: VideoParser.parseSearchResult,
 | |
| 				ARTIST: ArtistParser.parseSearchResult,
 | |
| 				ALBUM: AlbumParser.parseSearchResult,
 | |
| 				PLAYLIST: PlaylistParser.parseSearchResult
 | |
| 			}[category!] || SearchParser.parse
 | |
| 		)
 | |
| 	}
 | |
| 
 | |
| 	public async getSong(videoId: string) {
 | |
| 		const data = await this.constructRequest("player", { videoId })
 | |
| 
 | |
| 		fs.writeFileSync("data.json", JSON.stringify(data))
 | |
| 	}
 | |
| 
 | |
| 	public async getVideo(videoId: string) {
 | |
| 		const data = await this.constructRequest("player", { videoId })
 | |
| 
 | |
| 		fs.writeFileSync("data.json", JSON.stringify(data))
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all possible information of an Artist
 | |
| 	 *
 | |
| 	 * @param artistId Artist ID
 | |
| 	 * @returns Artist Data
 | |
| 	 */
 | |
| 	public async getArtist(artistId: string): Promise<YTMusic.ArtistFull> {
 | |
| 		const data = await this.constructRequest("browse", { browseId: artistId })
 | |
| 
 | |
| 		return ArtistParser.parse(data, artistId)
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all of Artist's Songs
 | |
| 	 *
 | |
| 	 * @param artistId Artist ID
 | |
| 	 * @returns Artist's Songs
 | |
| 	 */
 | |
| 	public async getArtistSongs(artistId: string): Promise<YTMusic.SongDetailed[]> {
 | |
| 		const artistData = await this.constructRequest("browse", { browseId: artistId })
 | |
| 		const browseToken = traverse(artistData, "musicShelfRenderer", "title", "browseId")
 | |
| 
 | |
| 		const songsData = await this.constructRequest("browse", { browseId: browseToken })
 | |
| 		const continueToken = traverse(songsData, "continuation")
 | |
| 		const moreSongsData = await this.constructRequest(
 | |
| 			"browse",
 | |
| 			{},
 | |
| 			{ continuation: continueToken }
 | |
| 		)
 | |
| 
 | |
| 		return [
 | |
| 			...traverse(songsData, "musicResponsiveListItemRenderer"),
 | |
| 			...traverse(moreSongsData, "musicResponsiveListItemRenderer")
 | |
| 		].map(SongParser.parseArtistSong)
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all of Artist's Albums
 | |
| 	 *
 | |
| 	 * @param artistId Artist ID
 | |
| 	 * @returns Artist's Albums
 | |
| 	 */
 | |
| 	public async getArtistAlbums(artistId: string): Promise<YTMusic.AlbumDetailed[]> {
 | |
| 		const artistData = await this.constructRequest("browse", { browseId: artistId })
 | |
| 		const artistAlbumsData = traverse(artistData, "musicCarouselShelfRenderer")[0]
 | |
| 		const browseBody = traverse(artistAlbumsData, "moreContentButton", "browseEndpoint")
 | |
| 
 | |
| 		const albumsData = await this.constructRequest("browse", browseBody)
 | |
| 
 | |
| 		return traverse(albumsData, "musicTwoRowItemRenderer").map((item: any) =>
 | |
| 			AlbumParser.parseArtistAlbum(item, {
 | |
| 				artistId,
 | |
| 				name: traverse(albumsData, "header", "text").at(0)
 | |
| 			})
 | |
| 		)
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all possible information of an Album
 | |
| 	 *
 | |
| 	 * @param albumId Album ID
 | |
| 	 * @returns Album Data
 | |
| 	 */
 | |
| 	public async getAlbum(albumId: string): Promise<YTMusic.AlbumFull> {
 | |
| 		const data = await this.constructRequest("browse", { browseId: albumId })
 | |
| 
 | |
| 		return AlbumParser.parse(data, albumId)
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all possible information of a Playlist except the tracks
 | |
| 	 * 
 | |
| 	 * @param playlistId Playlist ID
 | |
| 	 * @returns Playlist Data
 | |
| 	 */
 | |
| 	public async getPlaylist(playlistId: string): Promise<YTMusic.PlaylistDetailed> {
 | |
| 		if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
 | |
| 		const data = await this.constructRequest("browse", { browseId: playlistId })
 | |
| 
 | |
| 		return PlaylistParser.parse(data, playlistId)
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all videos in a Playlist
 | |
| 	 * 
 | |
| 	 * @param playlistId Playlist ID
 | |
| 	 * @returns Playlist's Videos
 | |
| 	 */
 | |
| 	public async getPlaylistVideos(
 | |
| 		playlistId: string
 | |
| 	): Promise<Omit<YTMusic.VideoDetailed, "views">[]> {
 | |
| 		if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
 | |
| 		const playlistData = await this.constructRequest("browse", { browseId: playlistId })
 | |
| 
 | |
| 		const songs = traverse(
 | |
| 			playlistData,
 | |
| 			"musicPlaylistShelfRenderer",
 | |
| 			"musicResponsiveListItemRenderer"
 | |
| 		)
 | |
| 		let continuation = traverse(playlistData, "musicPlaylistShelfRenderer", "continuation")
 | |
| 		while (true) {
 | |
| 			if (continuation instanceof Array) break
 | |
| 
 | |
| 			const songsData = await this.constructRequest("browse", {}, { continuation })
 | |
| 			songs.push(...traverse(songsData, "musicResponsiveListItemRenderer"))
 | |
| 			continuation = traverse(songsData, "continuation")
 | |
| 		}
 | |
| 
 | |
| 		return songs.map(VideoParser.parsePlaylistVideo)
 | |
| 	}
 | |
| }
 |