Merge branch 'dev' into main

This commit is contained in:
Zechariah 2021-12-26 14:59:23 +08:00
commit 9e5dd71b90
21 changed files with 1429 additions and 242 deletions

7
.npmignore Normal file
View File

@ -0,0 +1,7 @@
/src
.vscode
.gitignore
.prettierrc
.editorconfig
nodemon.json
tsconfig.json

8
.vscode/launch.json vendored
View File

@ -7,8 +7,8 @@
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"name": "index.ts", "name": "testing/run.ts",
"program": "${workspaceFolder}/src/index.ts", "program": "${workspaceFolder}/src/testing/run.ts",
"sourceMaps": true, "sourceMaps": true,
"skipFiles": ["<node_internals>/**"], "skipFiles": ["<node_internals>/**"],
"runtimeExecutable": "node", "runtimeExecutable": "node",
@ -17,8 +17,8 @@
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"name": "YTMusic.ts", "name": "tests/all.ts",
"program": "${workspaceFolder}/src/YTMusic.ts", "program": "${workspaceFolder}/src/tests/all.ts",
"sourceMaps": true, "sourceMaps": true,
"skipFiles": ["<node_internals>/**"], "skipFiles": ["<node_internals>/**"],
"runtimeExecutable": "node", "runtimeExecutable": "node",

369
README.md Normal file
View File

@ -0,0 +1,369 @@
# yt-music
YouTube Music API which comes with TypeScript support
## Initialization
Import YTMusic from the npm package
```ts
// TypeScript
import YTMusic from "yt-music"
// JavaScript
const YTMusic = require("yt-music")
```
Create an instance of the class `YTMusic`.
Then, call the `initialize()` to initialize the API before using the API anywhere
```ts
const ytmusic = new YTMusic()
ytmusic.initialize().then(() => {
// Use API here
})
```
## Class Methods
### `getSearchSuggestions`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| query | `string` | Search query you want suggestions for |
The function returns a `Promise<string[]>` which are the suggestion results
```ts
ytmusic.getSearchSuggestions("Lilac").then(res => {
console.log(res)
})
```
### `search`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| query | `string` | Search query |
| category | `"SONG" \| "VIDEO" \| "ARTIST" \| "ALBUM" \| "PLAYLIST" \| undefined` | Type of results to search for. If not specified, returns all types of search result |
The function **when nothing is passed as the category** returns a `Promise<`[SearchResult](#SearchResult)`[]>` which are the search results of all categories
```ts
ytmusic.search("Lilac").then(results => {
console.log(results)
})
```
#### `search (category = "SONG")`
When you pass in `"SONG"` as the category,
The function returns a `Promise<`[SongDetailed](#SongDetailed)`[]>` which are the song results
```ts
ytmusic.search("Lilac", "SONG").then(songs => {
console.log(songs)
})
```
#### `search (category = "VIDEO")`
When you pass in `"VIDEO"` as the category,
The function returns a `Promise<`[VideoDetailed](#VideoDetailed)`[]>` which are the video results
```ts
ytmusic.search("Lilac", "VIDEO").then(videos => {
console.log(videos)
})
```
#### `search (category = "ARTIST")`
When you pass in `"ARTIST"` as the category
The function returns a `Promise<`[ArtistDetailed](#ArtistDetailed)`[]>` which are the artist results
```ts
ytmusic.search("Lilac", "ARTIST").then(artists => {
console.log(artists)
})
```
#### `search (category = "ALBUM")`
When you pass in `"ALBUM"` as the category,
The function returns a `Promise<`[AlbumDetailed](#AlbumDetailed)`[]>` which are the album results
```ts
ytmusic.search("Lilac", "ALBUM").then(albums => {
console.log(albums)
})
```
#### `search (category = "PLAYLIST")`
When you pass in `"PLAYLIST"` as the category,
The function returns a `Promise<`[PlaylistFull](#PlaylistFull)`[]>` which are the playlist results
```ts
ytmusic.search("Lilac", "PLAYLIST").then(playlists => {
console.log(playlists)
})
```
### `getSong`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| videoId | `string` | Video ID |
The function returns a `Promise<`[SongFull](#SongFull)`>` which is the information about the song
```ts
ytmusic.getSong("v7bnOxV4jAc").then(song => {
console.log(song)
})
```
### `getVideo`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| videoId | `string` | Video ID |
The function returns a `Promise<`[VideoFull](#VideoFull)`>` which is the information about the video
```ts
ytmusic.getVideo("v7bnOxV4jAc").then(video => {
console.log(video)
})
```
### `getArtist`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| artistId | `string` | Artist ID |
The function returns a `Promise<`[ArtistFull](#ArtistFull)`>` which is the information about the artist
```ts
ytmusic.getArtist("UCTUR0sVEkD8T5MlSHqgaI_Q").then(artist => {
console.log(artist)
})
```
### `getArtistSongs`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| artistId | `string` | Artist ID |
The function returns a `Promise<`[SongDetailed](#SongDetailed)`[]>` which is the information about all the artist's songs
```ts
ytmusic.getArtistSongs("UCTUR0sVEkD8T5MlSHqgaI_Q").then(artistSongs => {
console.log(artistSongs)
})
```
### `getArtistAlbums`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| artistId | `string` | Artist ID |
The function returns a `Promise<`[AlbumDetailed](#AlbumDetailed)`[]>` which is the information about all the artist's albums
```ts
ytmusic.getArtistAlbums("UCTUR0sVEkD8T5MlSHqgaI_Q").then(artistAlbums => {
console.log(artistAlbums)
})
```
### `getAlbum`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| albumId | `string` | Album ID |
The function returns a `Promise<`[AlbumFull](#AlbumFull)`>` which is the information about the album
```ts
ytmusic.getAlbum("MPREb_iG5q5DIdhdA").then(album => {
console.log(album)
})
```
### `getPlaylist`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| playlistId | `string` | Playlist ID |
The function returns a `Promise<`[PlaylistFull](#PlaylistFull)`>` which is the information about the playlist (without the videos)
```ts
ytmusic.getPlaylist("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(playlist => {
console.log(playlist)
})
```
### `getPlaylistVideos`
This function takes in the following parameters
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| playlistId | `string` | Playlist ID |
The function returns a `Promise<Omit<`[VideoDetailed](#VideoDetailed)`, "views">[]>` which is the information about the videos without the view count
```ts
ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(playlistVideos => {
console.log(playlistVideos)
})
```
## Data Types
### `ThumbnailFull`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| url | `string` | Link |
| width | `number` | Width of the image |
| height | `number` | Height of the image |
### `SongDetailed`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"SONG"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Artists |
| album | [AlbumBasic](#AlbumBasic) | Album |
| duration | `number` | Duration in seconds |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
### `SongFull`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"SONG"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Artists |
| duration | `number` | Duration in seconds |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string` | Description |
| formats | `any[]` | Video Formats |
| adaptiveFormats | `any[]` | Adaptive Video Formats |
### `VideoDetailed`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"VIDEO"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video |
| views | `number` | View count |
| duration | `number` | Duration in seconds |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
### `VideoFull`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"VIDEO"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video |
| views | `number` | View count |
| duration | `number` | Duration in seconds |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string` | Description |
| unlisted | `boolean` | If the video is unlisted on YouTube |
| familySafe | `boolean` | If the video is family safe on YouTube |
| paid | `boolean` | If the video is paid on YouTube |
| tags | `string[]` | Tags |
### `ArtistBasic`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| artistId | `string \| null` | Artist ID |
| name | `string` | Name |
### `ArtistDetailed`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"ARTIST"` | Type of data |
| artistId | `string` | Artist ID |
| name | `string` | Name |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
### `ArtistFull`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"ARTIST"` | Type of data |
| artistId | `string` | Artist ID |
| name | `string` | Name |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string \| null` | Description |
| subscribers | `number` | Number of subscribers the Artist has|
| topSongs | `Omit<`[SongDetailed](#SongDetailed)`, "duration">[]` | Top Songs from Artist |
| topAlbums | [AlbumDetailed](#AlbumDetailed)`[]` | Top Albums from Artist |
### `AlbumBasic`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| albumId | `string` | Album ID |
| name | `string` | Name |
### `AlbumDetailed`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"ALBUM"` | Type of data |
| albumId | `string` | Album ID |
| playlistId | `string` | Playlist ID for Album |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Creators of the Album |
| year | `number` | Publication Year |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
### `AlbumFull`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"ALBUM"` | Type of data |
| albumId | `string` | Album ID |
| playlistId | `string` | Playlist ID for Album |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Creators of the Album |
| year | `number` | Publication Year |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string \| null` | Description |
| songs | [SongDetailed](#SongDetailed)`[]` | Songs in the Album
### `PlaylistFull`
| Name | Data Type | Description |
| :--- | :-------- | :---------- |
| type | `"PLAYLIST"` | Type of data |
| playlistId | `string` | Playlist ID |
| name | `string` | Name |
| artist | [ArtistBasic](#ArtistBasic) | Creator of the Playlist |
| videoCount | `number` | Number of videos in the Playlist |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
### `SearchResult`
[SongDetailed](#SongDetailed) or [VideoDetailed](#VideoDetailed) or [ArtistDetailed](#ArtistDetailed) or [AlbumDetailed](#AlbumDetailed) or [PlaylistFull](#PlaylistFull)

View File

@ -20,6 +20,7 @@
"http-debug": "^0.1.2", "http-debug": "^0.1.2",
"ts-node": "^10.2.1", "ts-node": "^10.2.1",
"typescript": "^4.4.3", "typescript": "^4.4.3",
"validate-any": "^1.1.1",
"youtube-music-api": "^1.0.6" "youtube-music-api": "^1.0.6"
}, },
"keywords": [ "keywords": [

View File

@ -1,6 +1,11 @@
import AlbumParser from "./parsers/AlbumParser"
import ArtistParser from "./parsers/ArtistParser"
import axios, { AxiosInstance } from "axios" import axios, { AxiosInstance } from "axios"
import Parser from "./utils/Parser" import PlaylistParser from "./parsers/PlaylistParser"
import SearchParser from "./parsers/SearchParser"
import SongParser from "./parsers/SongParser"
import traverse from "./utils/traverse" import traverse from "./utils/traverse"
import VideoParser from "./parsers/VideoParser"
import { Cookie, CookieJar } from "tough-cookie" import { Cookie, CookieJar } from "tough-cookie"
export default class YTMusic { export default class YTMusic {
@ -87,19 +92,6 @@ export default class YTMusic {
} }
} }
/**
* Asserts that the API has been initialized
*
* @returns Non-null config
*/
private assertInitialized() {
if (!this.config) {
throw new Error("API not initialized. Make sure to call the initialize() method first")
}
return this.config
}
/** /**
* Constructs a basic YouTube Music API request with all essential headers * Constructs a basic YouTube Music API request with all essential headers
* and body parameters needed to make the API work * and body parameters needed to make the API work
@ -114,17 +106,19 @@ export default class YTMusic {
body: Record<string, any> = {}, body: Record<string, any> = {},
query: Record<string, string> = {} query: Record<string, string> = {}
) { ) {
const config = this.assertInitialized() if (!this.config) {
throw new Error("API not initialized. Make sure to call the initialize() method first")
}
const headers: Record<string, any> = { const headers: Record<string, any> = {
...this.client.defaults.headers, ...this.client.defaults.headers,
"x-origin": this.client.defaults.baseURL, "x-origin": this.client.defaults.baseURL,
"X-Goog-Visitor-Id": config.VISITOR_DATA, "X-Goog-Visitor-Id": this.config.VISITOR_DATA,
"X-YouTube-Client-Name": config.INNERTUBE_CONTEXT_CLIENT_NAME, "X-YouTube-Client-Name": this.config.INNERTUBE_CONTEXT_CLIENT_NAME,
"X-YouTube-Client-Version": config.INNERTUBE_CLIENT_VERSION, "X-YouTube-Client-Version": this.config.INNERTUBE_CLIENT_VERSION,
"X-YouTube-Device": config.DEVICE, "X-YouTube-Device": this.config.DEVICE,
"X-YouTube-Page-CL": config.PAGE_CL, "X-YouTube-Page-CL": this.config.PAGE_CL,
"X-YouTube-Page-Label": config.PAGE_BUILD_LABEL, "X-YouTube-Page-Label": this.config.PAGE_BUILD_LABEL,
"X-YouTube-Utc-Offset": String(-new Date().getTimezoneOffset()), "X-YouTube-Utc-Offset": String(-new Date().getTimezoneOffset()),
"X-YouTube-Time-Zone": new Intl.DateTimeFormat().resolvedOptions().timeZone "X-YouTube-Time-Zone": new Intl.DateTimeFormat().resolvedOptions().timeZone
} }
@ -132,22 +126,21 @@ export default class YTMusic {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
...query, ...query,
alt: "json", alt: "json",
key: config.INNERTUBE_API_KEY key: this.config.INNERTUBE_API_KEY
}) })
// prettier-ignore
const res = await this.client.post( const res = await this.client.post(
`youtubei/${config.INNERTUBE_API_VERSION}/${endpoint}?${searchParams.toString()}`, `youtubei/${this.config.INNERTUBE_API_VERSION}/${endpoint}?${searchParams.toString()}`,
{ {
context: { context: {
capabilities: {}, capabilities: {},
client: { client: {
clientName: config.INNERTUBE_CLIENT_NAME, clientName: this.config.INNERTUBE_CLIENT_NAME,
clientVersion: config.INNERTUBE_CLIENT_VERSION, clientVersion: this.config.INNERTUBE_CLIENT_VERSION,
experimentIds: [], experimentIds: [],
experimentsToken: "", experimentsToken: "",
gl: config.GL, gl: this.config.GL,
hl: config.HL, hl: this.config.HL,
locationInfo: { locationInfo: {
locationPermissionAuthorizationStatus: locationPermissionAuthorizationStatus:
"LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED" "LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED"
@ -198,11 +191,12 @@ export default class YTMusic {
* @returns Search suggestions * @returns Search suggestions
*/ */
public async getSearchSuggestions(query: string): Promise<string[]> { public async getSearchSuggestions(query: string): Promise<string[]> {
const res = await this.constructRequest("music/get_search_suggestions", { return traverse(
input: query await this.constructRequest("music/get_search_suggestions", {
}) input: query
}),
return traverse(res, "query") "query"
)
} }
/** /**
@ -212,15 +206,14 @@ export default class YTMusic {
* @param category Type of search results to receive * @param category Type of search results to receive
*/ */
public async search(query: string, category: "SONG"): Promise<YTMusic.SongDetailed[]> 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: "VIDEO"): Promise<YTMusic.VideoDetailed[]>
public async search(query: string, category: "ARTIST"): Promise<YTMusic.ArtistDetailed[]> 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: "ALBUM"): Promise<YTMusic.AlbumDetailed[]>
public async search(query: string, category: "PLAYLIST"): Promise<YTMusic.PlaylistDetailed[]> public async search(query: string, category: "PLAYLIST"): Promise<YTMusic.PlaylistFull[]>
public async search(query: string): Promise<YTMusic.SearchResult[]> public async search(query: string): Promise<YTMusic.SearchResult[]>
public async search(query: string, category?: string) { public async search(query: string, category?: string) {
const data = await this.constructRequest("search", { const searchData = await this.constructRequest("search", {
query: query, query,
params: params:
{ {
SONG: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D", SONG: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D",
@ -231,20 +224,149 @@ export default class YTMusic {
}[category!] || null }[category!] || null
}) })
const parser = new Parser(data) return [traverse(searchData, "musicResponsiveListItemRenderer")].flat().map(
return (
{ {
SONG: parser.parseSongsSearchResults, SONG: SongParser.parseSearchResult,
VIDEO: parser.parseVideosSearchResults, VIDEO: VideoParser.parseSearchResult,
ARTIST: parser.parseArtistsSearchResults, ARTIST: ArtistParser.parseSearchResult,
ALBUM: parser.parseAlbumsSearchResults, ALBUM: AlbumParser.parseSearchResult,
PLAYLIST: parser.parsePlaylistsSearchResults PLAYLIST: PlaylistParser.parseSearchResult
}[category!] || parser.parseSearchResult }[category!] || SearchParser.parse
).call(parser) )
}
/**
* Get all possible information of a Song
*
* @param videoId Video ID
* @returns Song Data
*/
public async getSong(videoId: string): Promise<YTMusic.SongFull> {
const data = await this.constructRequest("player", { videoId })
return SongParser.parse(data)
}
/**
* Get all possible information of a Video
*
* @param videoId Video ID
* @returns Video Data
*/
public async getVideo(videoId: string): Promise<YTMusic.VideoFull> {
const data = await this.constructRequest("player", { videoId })
return VideoParser.parse(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.PlaylistFull> {
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)
} }
} }
const ytmusicapi = new YTMusic()
ytmusicapi.initialize().then(async () => {
console.log("Initialized")
})

View File

@ -0,0 +1,77 @@
import SongParser from "./SongParser"
import traverse from "../utils/traverse"
export default class AlbumParser {
public static parse(data: any, albumId: string): YTMusic.AlbumFull {
const albumBasic = {
albumId,
name: traverse(data, "header", "title", "text").at(0)
}
const artists = traverse(data, "header", "subtitle", "runs")
.filter((run: any) => "navigationEndpoint" in run)
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text }))
const thumbnails = [traverse(data, "header", "thumbnails")].flat()
const description = traverse(data, "description", "text")
return {
type: "ALBUM",
...albumBasic,
playlistId: traverse(data, "buttonRenderer", "playlistId"),
artists,
year: +traverse(data, "header", "subtitle", "text").at(-1),
thumbnails,
description: description instanceof Array ? null : description,
songs: [traverse(data, "musicResponsiveListItemRenderer")]
.flat()
.map((item: any) =>
SongParser.parseAlbumSong(item, artists, albumBasic, thumbnails)
)
}
}
public static parseSearchResult(item: any): YTMusic.AlbumDetailed {
const flexColumns = traverse(item, "flexColumns")
return {
type: "ALBUM",
albumId: [traverse(item, "browseId")].flat().at(-1),
playlistId: traverse(item, "overlay", "playlistId"),
artists: traverse(flexColumns[1], "runs")
.filter((run: any) => "navigationEndpoint" in run)
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })),
name: traverse(flexColumns[0], "runs", "text"),
year: +traverse(flexColumns[1], "runs", "text").at(-1),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
public static parseArtistAlbum(
item: any,
artistBasic: YTMusic.ArtistBasic
): YTMusic.AlbumDetailed {
return {
type: "ALBUM",
albumId: [traverse(item, "browseId")].flat().at(-1),
playlistId: traverse(item, "thumbnailOverlay", "playlistId"),
name: traverse(item, "title", "text").at(0),
artists: [artistBasic],
year: +traverse(item, "subtitle", "text").at(-1),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
public static parseArtistTopAlbums(
item: any,
artistBasic: YTMusic.ArtistBasic
): YTMusic.AlbumDetailed {
return {
type: "ALBUM",
albumId: traverse(item, "browseId").at(-1),
playlistId: traverse(item, "musicPlayButtonRenderer", "playlistId"),
name: traverse(item, "title", "text").at(0),
artists: [artistBasic],
year: +traverse(item, "subtitle", "text").at(-1),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
}

View File

@ -0,0 +1,41 @@
import AlbumParser from "./AlbumParser"
import Parse from "./Parser"
import SongParser from "./SongParser"
import traverse from "../utils/traverse"
export default class ArtistParser {
public static parse(data: any, artistId: string): YTMusic.ArtistFull {
const artistBasic = {
artistId,
name: traverse(data, "header", "title", "text").at(0)
}
const description = traverse(data, "header", "description", "text")
return {
type: "ARTIST",
...artistBasic,
thumbnails: [traverse(data, "header", "thumbnails")].flat(),
description: description instanceof Array ? null : description,
subscribers: Parse.parseNumber(traverse(data, "subscriberCountText", "text")),
topSongs: traverse(data, "musicShelfRenderer", "contents").map((item: any) =>
SongParser.parseArtistTopSong(item, artistBasic)
),
topAlbums: [traverse(data, "musicCarouselShelfRenderer")]
.flat()
.at(0)
.contents.map((item: any) => AlbumParser.parseArtistTopAlbums(item, artistBasic))
}
}
public static parseSearchResult(item: any): YTMusic.ArtistDetailed {
const flexColumns = traverse(item, "flexColumns")
return {
type: "ARTIST",
artistId: traverse(item, "browseId"),
name: traverse(flexColumns[0], "runs", "text"),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
}

28
src/parsers/Parser.ts Normal file
View File

@ -0,0 +1,28 @@
export default class Parse {
public static parseDuration(time: string) {
const [seconds, minutes, hours] = time
.split(":")
.reverse()
.map(n => +n) as (number | undefined)[]
return (seconds || 0) + (minutes || 0) * 60 + (hours || 0) * 60 * 60
}
public static parseNumber(string: string): number {
if (string.at(-1)!.match(/^[A-Z]+$/)) {
const number = +string.slice(0, -1)
const multiplier = string.at(-1)
return (
{
K: number * 1000,
M: number * 1000 * 1000,
B: number * 1000 * 1000 * 1000,
T: number * 1000 * 1000 * 1000 * 1000
}[multiplier!] || NaN
)
} else {
return +string
}
}
}

View File

@ -0,0 +1,42 @@
import traverse from "../utils/traverse"
export default class PlaylistParser {
public static parse(data: any, playlistId: string): YTMusic.PlaylistFull {
return {
type: "PLAYLIST",
playlistId,
name: traverse(data, "header", "title", "text").at(0),
artist: {
artistId: traverse(data, "header", "subtitle", "browseId"),
name: traverse(data, "header", "subtitle", "text").at(2)
},
videoCount: +traverse(data, "header", "secondSubtitle", "text")
.at(0)
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: traverse(data, "header", "thumbnails")
}
}
public static parseSearchResult(item: any): YTMusic.PlaylistFull {
const flexColumns = traverse(item, "flexColumns")
const artistId = traverse(flexColumns[1], "browseId")
return {
type: "PLAYLIST",
playlistId: traverse(item, "overlay", "playlistId"),
name: traverse(flexColumns[0], "runs", "text"),
artist: {
artistId: artistId instanceof Array ? null : artistId,
name: traverse(flexColumns[1], "runs", "text").at(-2)
},
videoCount: +traverse(flexColumns[1], "runs", "text")
.at(-1)
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
}

View File

@ -0,0 +1,30 @@
import AlbumParser from "./AlbumParser"
import ArtistParser from "./ArtistParser"
import PlaylistParser from "./PlaylistParser"
import SongParser from "./SongParser"
import traverse from "../utils/traverse"
import VideoParser from "./VideoParser"
export default class SearchParser {
public static parse(item: any): YTMusic.SearchResult {
const flexColumns = traverse(item, "flexColumns")
const type = traverse(flexColumns[1], "runs", "text").at(0) as
| "Song"
| "Video"
| "Artist"
| "EP"
| "Single"
| "Album"
| "Playlist"
return {
Song: SongParser.parseSearchResult,
Video: VideoParser.parseSearchResult,
Artist: ArtistParser.parseSearchResult,
EP: AlbumParser.parseSearchResult,
Single: AlbumParser.parseSearchResult,
Album: AlbumParser.parseSearchResult,
Playlist: PlaylistParser.parseSearchResult
}[type](item)
}
}

108
src/parsers/SongParser.ts Normal file
View File

@ -0,0 +1,108 @@
import Parser from "./Parser"
import traverse from "../utils/traverse"
export default class SongParser {
public static parse(data: any): YTMusic.SongFull {
return {
type: "SONG",
videoId: traverse(data, "videoDetails", "videoId"),
name: traverse(data, "videoDetails", "title"),
artists: [
{
artistId: traverse(data, "videoDetails", "channelId"),
name: traverse(data, "author")
}
],
duration: +traverse(data, "videoDetails", "lengthSeconds"),
thumbnails: [traverse(data, "videoDetails", "thumbnails")].flat(),
description: traverse(data, "description"),
formats: traverse(data, "streamingData", "formats"),
adaptiveFormats: traverse(data, "streamingData", "adaptiveFormats")
}
}
public static parseSearchResult(item: any): YTMusic.SongDetailed {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
artists: traverse(flexColumns[1], "runs")
.filter((run: any) => "navigationEndpoint" in run)
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text }))
.slice(0, -1),
album: {
albumId: traverse(item, "browseId").at(-1),
name: traverse(flexColumns[1], "runs", "text").at(-3)
},
duration: Parser.parseDuration(traverse(flexColumns[1], "runs", "text").at(-1)),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
public static parseArtistSong(item: any): YTMusic.SongDetailed {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
artists: [traverse(flexColumns[1], "runs")]
.flat()
.filter((item: any) => "navigationEndpoint" in item)
.map((run: any) => ({
artistId: traverse(run, "browseId"),
name: run.text
})),
album: {
albumId: traverse(flexColumns[2], "browseId"),
name: traverse(flexColumns[2], "runs", "text")
},
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
public static parseArtistTopSong(
item: any,
artistBasic: YTMusic.ArtistBasic
): Omit<YTMusic.SongDetailed, "duration"> {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
artists: [artistBasic],
album: {
albumId: traverse(flexColumns[2], "runs", "text"),
name: traverse(flexColumns[2], "browseId")
},
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
public static parseAlbumSong(
item: any,
artists: YTMusic.ArtistBasic[],
albumBasic: YTMusic.AlbumBasic,
thumbnails: YTMusic.ThumbnailFull[]
): YTMusic.SongDetailed {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
artists,
album: albumBasic,
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails
}
}
}

View File

@ -0,0 +1,61 @@
import Parser from "./Parser"
import traverse from "../utils/traverse"
export default class VideoParser {
public static parse(data: any): YTMusic.VideoFull {
return {
type: "VIDEO",
videoId: traverse(data, "videoDetails", "videoId"),
name: traverse(data, "videoDetails", "title"),
artists: [
{
artistId: traverse(data, "videoDetails", "channelId"),
name: traverse(data, "author")
}
],
views: +traverse(data, "videoDetails", "viewCount"),
duration: +traverse(data, "videoDetails", "lengthSeconds"),
thumbnails: [traverse(data, "videoDetails", "thumbnails")].flat(),
description: traverse(data, "description"),
unlisted: traverse(data, "unlisted"),
familySafe: traverse(data, "familySafe"),
paid: traverse(data, "paid"),
tags: traverse(data, "tags")
}
}
public static parseSearchResult(item: any): YTMusic.VideoDetailed {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playNavigationEndpoint", "videoId")
return {
type: "VIDEO",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
artists: [traverse(flexColumns[1], "runs")]
.flat()
.filter((run: any) => "navigationEndpoint" in run)
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })),
views: Parser.parseNumber(traverse(flexColumns[1], "runs", "text").at(-3).slice(0, -6)),
duration: Parser.parseDuration(traverse(flexColumns[1], "text").at(-1)),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
public static parsePlaylistVideo(item: any): Omit<YTMusic.VideoDetailed, "views"> {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playNavigationEndpoint", "videoId")
return {
type: "VIDEO",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
artists: [traverse(flexColumns[1], "runs")]
.flat()
.filter((run: any) => "navigationEndpoint" in run)
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })),
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails: [traverse(item, "thumbnails")].flat()
}
}
}

81
src/tests/all.ts Normal file
View File

@ -0,0 +1,81 @@
import Validator from "validate-any/build/classes/Validator"
import YTMusic from "../YTMusic"
import {
ALBUM_DETAILED,
ALBUM_FULL,
ARTIST_DETAILED,
ARTIST_FULL,
PLAYLIST_DETAILED,
PLAYLIST_VIDEO,
SONG_DETAILED,
SONG_FULL,
VIDEO_DETAILED,
VIDEO_FULL
} from "./interfaces"
import { LIST, validate } from "validate-any"
const queries = ["Lilac", "Weekend", "Yours Raiden", "Eminem", "Lisa Hannigan"]
const ytmusic = new YTMusic()
ytmusic.initialize().then(() =>
queries.forEach(async query => {
const [songs, videos, artists, albums, playlists, results] = await Promise.all([
ytmusic.search(query, "SONG"),
ytmusic.search(query, "VIDEO"),
ytmusic.search(query, "ARTIST"),
ytmusic.search(query, "ALBUM"),
ytmusic.search(query, "PLAYLIST"),
ytmusic.search(query)
])
const [song, video, artist, artistSongs, artistAlbums, album, playlist, playlistVideos] =
await Promise.all([
ytmusic.getSong(songs[0].videoId!),
ytmusic.getVideo(videos[0].videoId!),
ytmusic.getArtist(artists[0].artistId),
ytmusic.getArtistSongs(artists[0].artistId),
ytmusic.getArtistAlbums(artists[0].artistId),
ytmusic.getAlbum(albums[0].albumId),
ytmusic.getPlaylist(playlists[0].playlistId),
ytmusic.getPlaylistVideos(playlists[0].playlistId)
])
const tests: [any, Validator<any>][] = [
[songs, LIST(SONG_DETAILED)],
[videos, LIST(VIDEO_DETAILED)],
[artists, LIST(ARTIST_DETAILED)],
[albums, LIST(ALBUM_DETAILED)],
[playlists, LIST(PLAYLIST_DETAILED)],
[
results,
LIST(
ALBUM_DETAILED,
ARTIST_DETAILED,
PLAYLIST_DETAILED,
SONG_DETAILED,
VIDEO_DETAILED
)
],
[song, SONG_FULL],
[video, VIDEO_FULL],
[artist, ARTIST_FULL],
[artistSongs, LIST(SONG_DETAILED)],
[artistAlbums, LIST(ALBUM_DETAILED)],
[album, ALBUM_FULL],
[playlist, PLAYLIST_DETAILED],
[playlistVideos, LIST(PLAYLIST_VIDEO)]
]
for (const [value, validator] of tests) {
const result = validate(value, validator)
if (!result.success) {
console.log(JSON.stringify(value))
console.log(validator.formatSchema())
console.log(result.errors)
process.exit(0)
}
}
console.log(`Valid 🎉 - ${query}`)
})
)

132
src/tests/interfaces.ts Normal file
View File

@ -0,0 +1,132 @@
import ObjectValidator from "validate-any/build/validators/ObjectValidator"
import { BOOLEAN, LIST, NULL, NUMBER, OBJECT, OR, STRING } from "validate-any"
export const THUMBNAIL_FULL: ObjectValidator<YTMusic.ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
export const ARTIST_BASIC: ObjectValidator<YTMusic.ArtistBasic> = OBJECT({
artistId: OR(STRING(), NULL()),
name: STRING()
})
export const ALBUM_BASIC: ObjectValidator<YTMusic.AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
export const SONG_DETAILED: ObjectValidator<YTMusic.SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const VIDEO_DETAILED: ObjectValidator<YTMusic.VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ARTIST_DETAILED: ObjectValidator<YTMusic.ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ALBUM_DETAILED: ObjectValidator<YTMusic.AlbumDetailed> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const SONG_FULL: ObjectValidator<YTMusic.SongFull> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
formats: LIST(OBJECT()),
adaptiveFormats: LIST(OBJECT())
})
export const VIDEO_FULL: ObjectValidator<YTMusic.VideoFull> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
unlisted: BOOLEAN(),
familySafe: BOOLEAN(),
paid: BOOLEAN(),
tags: LIST(STRING())
})
export const ARTIST_FULL: ObjectValidator<YTMusic.ArtistFull> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
subscribers: NUMBER(),
topSongs: LIST(
OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
),
topAlbums: LIST(ALBUM_DETAILED)
})
export const ALBUM_FULL: ObjectValidator<YTMusic.AlbumFull> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
songs: LIST(SONG_DETAILED)
})
export const PLAYLIST_DETAILED: ObjectValidator<YTMusic.PlaylistDetailed> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const PLAYLIST_VIDEO: ObjectValidator<Omit<YTMusic.VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})

10
src/tests/run.ts Normal file
View File

@ -0,0 +1,10 @@
import YTMusic from "../YTMusic"
const ytmusic = new YTMusic()
ytmusic.initialize().then(() => {
ytmusic.search("Lilac", "SONG").then(res => {
ytmusic.getSong(res.find(r => !!r.videoId)!.videoId!).then(res => {
console.log(res)
})
})
})

35
src/tests/traverse/his.ts Normal file
View File

@ -0,0 +1,35 @@
const traverse = (data: any, keys: string, single = false) => {
const again = (data: any, key: string): any => {
var res = []
data.hasOwnProperty(key) && res.push(data[key])
if (single && data.hasOwnProperty(key)) {
return res.shift()
}
if (data instanceof Array) {
for (let i = 0; i < data.length; i++) {
res = res.concat(again(data[i], key))
}
} else if (data instanceof Object) {
const c = Object.keys(data)
if (c.length > 0) {
for (let i = 0; i < c.length; i++) {
res = res.concat(again(data[c[i]], key))
}
}
}
return res.length == 1 ? res.shift() : res
}
let z = keys.split(":"),
value = data
for (let i = 0; i < z.length; i++) {
value = again(value, z[i])
}
console.log(value)
}
traverse(require("./data.json"), "playNavigationEndpoint:videoId")
export default {}

View File

@ -0,0 +1,32 @@
const traverse = (data: any, keys: string[], single: boolean = false) => {
const again = (data: any, key: string): any => {
let res = []
if (data instanceof Object && key in data) {
res.push(data[key])
}
if (data instanceof Array) {
res.push(...data.map(v => again(v, key)).flat())
} else if (data instanceof Object) {
res.push(
...Object.keys(data)
.map(k => again(data[k], key))
.flat()
)
}
return res.length === 1 ? res[0] : res
}
let value = data
for (const key of keys) {
value = again(value, key)
}
return value
}
traverse(require("./data.json")[0], ["playNavigationEndpoint", "videoId"], true)
export default {}

42
src/types.d.ts vendored
View File

@ -7,7 +7,7 @@ declare namespace YTMusic {
interface SongDetailed { interface SongDetailed {
type: "SONG" type: "SONG"
videoId: string videoId: string | null
name: string name: string
artists: ArtistBasic[] artists: ArtistBasic[]
album: AlbumBasic album: AlbumBasic
@ -15,26 +15,48 @@ declare namespace YTMusic {
thumbnails: ThumbnailFull[] thumbnails: ThumbnailFull[]
} }
interface SongFull extends Omit<SongDetailed, "album"> {
description: string
formats: any[]
adaptiveFormats: any[]
}
interface VideoDetailed { interface VideoDetailed {
type: "VIDEO" type: "VIDEO"
videoId: string videoId: string | null
name: string name: string
artist: ArtistBasic artists: ArtistBasic[]
views: number views: number
duration: number duration: number
thumbnails: ThumbnailFull[] thumbnails: ThumbnailFull[]
} }
interface VideoFull extends VideoDetailed {
description: string
unlisted: boolean
familySafe: boolean
paid: boolean
tags: string[]
}
interface ArtistBasic { interface ArtistBasic {
artistId: string artistId: string | null
name: string name: string
} }
interface ArtistDetailed extends ArtistBasic { interface ArtistDetailed extends ArtistBasic {
type: "ARTIST" type: "ARTIST"
artistId: string
thumbnails: ThumbnailFull[] thumbnails: ThumbnailFull[]
} }
interface ArtistFull extends ArtistDetailed {
description: string | null
subscribers: number
topSongs: Omit<SongDetailed, "duration">[]
topAlbums: AlbumDetailed[]
}
interface AlbumBasic { interface AlbumBasic {
albumId: string albumId: string
name: string name: string
@ -48,20 +70,24 @@ declare namespace YTMusic {
thumbnails: ThumbnailFull[] thumbnails: ThumbnailFull[]
} }
interface PlaylistDetailed { interface AlbumFull extends AlbumDetailed {
description: string | null
songs: SongDetailed[]
}
interface PlaylistFull {
type: "PLAYLIST" type: "PLAYLIST"
playlistId: string playlistId: string
name: string name: string
artist: ArtistBasic artist: ArtistBasic
trackCount: number videoCount: number
thumbnails: ThumbnailFull[] thumbnails: ThumbnailFull[]
} }
type SearchResult = type SearchResult =
| SongDetailed | SongDetailed
| PlaylistDetailed
| VideoDetailed | VideoDetailed
| AlbumDetailed | AlbumDetailed
| ArtistDetailed | ArtistDetailed
| PlaylistDetailed | PlaylistFull
} }

View File

@ -1,175 +0,0 @@
import traverse from "./traverse"
const parseDuration = (time: string) => {
const [seconds, minutes, hours] = time
.split(":")
.reverse()
.map(n => +n) as (number | undefined)[]
return (seconds || 0) + (minutes || 0) * 60 + (hours || 0) * 60 * 60
}
const parseViews = (views: string): number => {
views = views.slice(0, -6)
if (views.at(-1)!.match(/^[A-Z]+$/)) {
const number = +views.slice(0, -1)
const multiplier = views.at(-1)
return (
{
K: number * 1000,
M: number * 1000 * 1000,
B: number * 1000 * 1000 * 1000
}[multiplier!] || NaN
)
} else {
return +views
}
}
export default class Parser {
private items: any[]
constructor(data: any) {
this.items = [traverse(data, "musicResponsiveListItemRenderer")].flat()
}
public parseSongsSearchResults(): YTMusic.SongDetailed[] {
return this.items.map(item => this.parseSongSearchResult(item))
}
private parseSongSearchResult(item: any): YTMusic.SongDetailed {
const flexColumns = traverse(item, "flexColumns")
const thumbnails = traverse(item, "thumbnails")
return {
type: "SONG",
videoId: traverse(item, "playlistItemData", "videoId"),
name: traverse(flexColumns[0], "runs", "text"),
artists: traverse(flexColumns[1], "runs")
.map((run: any) =>
"navigationEndpoint" in run
? { name: run.text, artistId: traverse(run, "browseId") }
: null
)
.slice(0, -3)
.filter(Boolean),
album: {
albumId: traverse(item, "browseId").at(-1),
name: traverse(flexColumns[1], "runs", "text").at(-3)
},
duration: parseDuration(traverse(flexColumns[1], "runs", "text").at(-1)),
thumbnails: [thumbnails].flat()
}
}
public parseVideosSearchResults(): YTMusic.VideoDetailed[] {
return this.items.map(item => this.parseVideoSearchResult(item, true))
}
private parseVideoSearchResult(item: any, specific: boolean): YTMusic.VideoDetailed {
const flexColumns = traverse(item, "flexColumns")
const thumbnails = traverse(item, "thumbnails")
return {
type: "VIDEO",
videoId: traverse(item, "playNavigationEndpoint", "videoId"),
name: traverse(flexColumns[0], "runs", "text"),
artist: {
artistId: traverse(flexColumns[1], "browseId"),
name: traverse(flexColumns[1], "runs", "text").at(specific ? 0 : 2)
},
views: parseViews(traverse(flexColumns[1], "runs", "text").at(-3)),
duration: parseDuration(traverse(flexColumns[1], "text").at(-1)),
thumbnails: [thumbnails].flat()
}
}
public parseArtistsSearchResults(): YTMusic.ArtistDetailed[] {
return this.items.map(item => this.parseArtistSearchResult(item))
}
private parseArtistSearchResult(item: any): YTMusic.ArtistDetailed {
const flexColumns = traverse(item, "flexColumns")
const thumbnails = traverse(item, "thumbnails")
return {
type: "ARTIST",
artistId: traverse(item, "browseId"),
name: traverse(flexColumns[0], "runs", "text"),
thumbnails: [thumbnails].flat()
}
}
public parseAlbumsSearchResults(): YTMusic.AlbumDetailed[] {
return this.items.map(item => this.parseAlbumSearchResult(item))
}
private parseAlbumSearchResult(item: any): YTMusic.AlbumDetailed {
const flexColumns = traverse(item, "flexColumns")
const thumbnails = traverse(item, "thumbnails")
return {
type: "ALBUM",
albumId: traverse(item, "browseId").at(-1),
playlistId: traverse(item, "overlay", "playlistId"),
artists: traverse(flexColumns[1], "runs")
.map((run: any) =>
"navigationEndpoint" in run
? { name: run.text, artistId: traverse(run, "browseId") }
: null
)
.slice(0, -1)
.filter(Boolean),
name: traverse(flexColumns[0], "runs", "text"),
year: +traverse(flexColumns[1], "runs", "text").at(-1),
thumbnails: [thumbnails].flat()
}
}
public parsePlaylistsSearchResults(): YTMusic.PlaylistDetailed[] {
return this.items.map(item => this.parsePlaylistSearchResult(item, true))
}
private parsePlaylistSearchResult(item: any, specific: boolean): YTMusic.PlaylistDetailed {
const flexColumns = traverse(item, "flexColumns")
const thumbnails = traverse(item, "thumbnails")
return {
type: "PLAYLIST",
playlistId: traverse(item, "overlay", "playlistId"),
name: traverse(flexColumns[0], "runs", "text"),
artist: {
artistId: traverse(flexColumns[1], "browseId"),
name: traverse(flexColumns[1], "runs", "text").at(specific ? 0 : 2)
},
trackCount: +traverse(flexColumns[1], "runs", "text").at(-1).split(" ").at(0),
thumbnails: [thumbnails].flat()
}
}
public parseSearchResult(): YTMusic.SearchResult[] {
return this.items.map(item => {
const flexColumns = traverse(item, "flexColumns")
const type = traverse(flexColumns[1], "runs", "text").at(0) as
| "Song"
| "Video"
| "Artist"
| "EP"
| "Single"
| "Album"
| "Playlist"
return {
Song: () => this.parseSongSearchResult(item),
Video: () => this.parseVideoSearchResult(item, true),
Artist: () => this.parseArtistSearchResult(item),
EP: () => this.parseAlbumSearchResult(item),
Single: () => this.parseAlbumSearchResult(item),
Album: () => this.parseAlbumSearchResult(item),
Playlist: () => this.parsePlaylistSearchResult(item, true)
}[type]()
})
}
}

View File

@ -17,7 +17,5 @@
"outDir": "build", "outDir": "build",
"rootDir": "src" "rootDir": "src"
}, },
"exclude": [ "exclude": ["**/*.test.*", "**/tests"]
"**/*.test.*"
]
} }

162
yarn.lock
View File

@ -44,6 +44,11 @@
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40"
integrity sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg== integrity sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
acorn-walk@^8.1.1: acorn-walk@^8.1.1:
version "8.2.0" version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
@ -73,6 +78,37 @@ axios@^0.24.0:
dependencies: dependencies:
follow-redirects "^1.14.4" follow-redirects "^1.14.4"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
commander@^2.19.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
config-chain@^1.1.12:
version "1.1.13"
resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4"
integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==
dependencies:
ini "^1.3.4"
proto-list "~1.2.1"
create-require@^1.1.0: create-require@^1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
@ -90,6 +126,16 @@ diff@^4.0.1:
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
editorconfig@^0.15.3:
version "0.15.3"
resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5"
integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==
dependencies:
commander "^2.19.0"
lru-cache "^4.1.5"
semver "^5.6.0"
sigmund "^1.0.1"
follow-redirects@1.5.10: follow-redirects@1.5.10:
version "1.5.10" version "1.5.10"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
@ -102,26 +148,115 @@ follow-redirects@^1.14.4:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd"
integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
glob@^7.1.3:
version "7.2.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
http-debug@^0.1.2: http-debug@^0.1.2:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/http-debug/-/http-debug-0.1.2.tgz#aadbfef99bc39c206439ece14b99040c5a4b4d6e" resolved "https://registry.yarnpkg.com/http-debug/-/http-debug-0.1.2.tgz#aadbfef99bc39c206439ece14b99040c5a4b4d6e"
integrity sha1-qtv++ZvDnCBkOezhS5kEDFpLTW4= integrity sha1-qtv++ZvDnCBkOezhS5kEDFpLTW4=
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
ini@^1.3.4:
version "1.3.8"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
js-beautify@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.0.tgz#2ce790c555d53ce1e3d7363227acf5dc69024c2d"
integrity sha512-yuck9KirNSCAwyNJbqW+BxJqJ0NLJ4PwBUzQQACl5O3qHMBXVkXb/rD0ilh/Lat/tn88zSZ+CAHOlk0DsY7GuQ==
dependencies:
config-chain "^1.1.12"
editorconfig "^0.15.3"
glob "^7.1.3"
nopt "^5.0.0"
lodash@^4.17.15: lodash@^4.17.15:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lru-cache@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
dependencies:
pseudomap "^1.0.2"
yallist "^2.1.2"
make-error@^1.1.1: make-error@^1.1.1:
version "1.3.6" version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
dependencies:
brace-expansion "^1.1.7"
ms@2.0.0: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
nopt@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
dependencies:
abbrev "1"
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
dependencies:
wrappy "1"
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
pseudomap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
psl@^1.1.33: psl@^1.1.33:
version "1.8.0" version "1.8.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
@ -132,6 +267,16 @@ punycode@^2.1.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
semver@^5.6.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
sigmund@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590"
integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=
tough-cookie@^4.0.0: tough-cookie@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4"
@ -169,6 +314,23 @@ universalify@^0.1.2:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
validate-any@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/validate-any/-/validate-any-1.1.1.tgz#0845d9e8f2b44e342633e6a73ffa21973b71507f"
integrity sha512-L3bCSEn/eH/mdPp+hFQg4nTBeMr19V4kbKMxMSOwRlbMA6N6LWi52Duz0gMQK9gJkz8VbMcvx5Yka2u9J8epjQ==
dependencies:
js-beautify "^1.14.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
yallist@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
yn@3.1.1: yn@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"