diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..0fcd733 --- /dev/null +++ b/.npmignore @@ -0,0 +1,7 @@ +/src +.vscode +.gitignore +.prettierrc +.editorconfig +nodemon.json +tsconfig.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index e6c7276..7ab51bd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,8 +7,8 @@ { "type": "node", "request": "launch", - "name": "index.ts", - "program": "${workspaceFolder}/src/index.ts", + "name": "testing/run.ts", + "program": "${workspaceFolder}/src/testing/run.ts", "sourceMaps": true, "skipFiles": ["/**"], "runtimeExecutable": "node", @@ -17,8 +17,8 @@ { "type": "node", "request": "launch", - "name": "YTMusic.ts", - "program": "${workspaceFolder}/src/YTMusic.ts", + "name": "tests/all.ts", + "program": "${workspaceFolder}/src/tests/all.ts", "sourceMaps": true, "skipFiles": ["/**"], "runtimeExecutable": "node", diff --git a/README.md b/README.md new file mode 100644 index 0000000..de7fc8c --- /dev/null +++ b/README.md @@ -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` 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[]>` 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) \ No newline at end of file diff --git a/package.json b/package.json index b62694c..8ef8781 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "http-debug": "^0.1.2", "ts-node": "^10.2.1", "typescript": "^4.4.3", + "validate-any": "^1.1.1", "youtube-music-api": "^1.0.6" }, "keywords": [ diff --git a/src/YTMusic.ts b/src/YTMusic.ts index 595c751..93c25ec 100644 --- a/src/YTMusic.ts +++ b/src/YTMusic.ts @@ -1,6 +1,11 @@ +import AlbumParser from "./parsers/AlbumParser" +import ArtistParser from "./parsers/ArtistParser" 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 VideoParser from "./parsers/VideoParser" import { Cookie, CookieJar } from "tough-cookie" 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 * and body parameters needed to make the API work @@ -114,17 +106,19 @@ export default class YTMusic { body: Record = {}, query: Record = {} ) { - const config = this.assertInitialized() + if (!this.config) { + throw new Error("API not initialized. Make sure to call the initialize() method first") + } const headers: Record = { ...this.client.defaults.headers, "x-origin": this.client.defaults.baseURL, - "X-Goog-Visitor-Id": config.VISITOR_DATA, - "X-YouTube-Client-Name": config.INNERTUBE_CONTEXT_CLIENT_NAME, - "X-YouTube-Client-Version": config.INNERTUBE_CLIENT_VERSION, - "X-YouTube-Device": config.DEVICE, - "X-YouTube-Page-CL": config.PAGE_CL, - "X-YouTube-Page-Label": config.PAGE_BUILD_LABEL, + "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 } @@ -132,22 +126,21 @@ export default class YTMusic { const searchParams = new URLSearchParams({ ...query, alt: "json", - key: config.INNERTUBE_API_KEY + key: this.config.INNERTUBE_API_KEY }) - // prettier-ignore const res = await this.client.post( - `youtubei/${config.INNERTUBE_API_VERSION}/${endpoint}?${searchParams.toString()}`, + `youtubei/${this.config.INNERTUBE_API_VERSION}/${endpoint}?${searchParams.toString()}`, { context: { capabilities: {}, client: { - clientName: config.INNERTUBE_CLIENT_NAME, - clientVersion: config.INNERTUBE_CLIENT_VERSION, + clientName: this.config.INNERTUBE_CLIENT_NAME, + clientVersion: this.config.INNERTUBE_CLIENT_VERSION, experimentIds: [], experimentsToken: "", - gl: config.GL, - hl: config.HL, + gl: this.config.GL, + hl: this.config.HL, locationInfo: { locationPermissionAuthorizationStatus: "LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED" @@ -198,11 +191,12 @@ export default class YTMusic { * @returns Search suggestions */ public async getSearchSuggestions(query: string): Promise { - const res = await this.constructRequest("music/get_search_suggestions", { - input: query - }) - - return traverse(res, "query") + return traverse( + await this.constructRequest("music/get_search_suggestions", { + input: query + }), + "query" + ) } /** @@ -212,15 +206,14 @@ export default class YTMusic { * @param category Type of search results to receive */ public async search(query: string, category: "SONG"): Promise - public async search(query: string, category: "PLAYLIST"): Promise public async search(query: string, category: "VIDEO"): Promise public async search(query: string, category: "ARTIST"): Promise public async search(query: string, category: "ALBUM"): Promise - public async search(query: string, category: "PLAYLIST"): Promise + public async search(query: string, category: "PLAYLIST"): Promise public async search(query: string): Promise public async search(query: string, category?: string) { - const data = await this.constructRequest("search", { - query: query, + const searchData = await this.constructRequest("search", { + query, params: { SONG: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D", @@ -231,20 +224,149 @@ export default class YTMusic { }[category!] || null }) - const parser = new Parser(data) - return ( + return [traverse(searchData, "musicResponsiveListItemRenderer")].flat().map( { - SONG: parser.parseSongsSearchResults, - VIDEO: parser.parseVideosSearchResults, - ARTIST: parser.parseArtistsSearchResults, - ALBUM: parser.parseAlbumsSearchResults, - PLAYLIST: parser.parsePlaylistsSearchResults - }[category!] || parser.parseSearchResult - ).call(parser) + SONG: SongParser.parseSearchResult, + VIDEO: VideoParser.parseSearchResult, + ARTIST: ArtistParser.parseSearchResult, + ALBUM: AlbumParser.parseSearchResult, + PLAYLIST: PlaylistParser.parseSearchResult + }[category!] || SearchParser.parse + ) + } + + /** + * Get all possible information of a Song + * + * @param videoId Video ID + * @returns Song Data + */ + public async getSong(videoId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[]> { + 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") -}) diff --git a/src/parsers/AlbumParser.ts b/src/parsers/AlbumParser.ts new file mode 100644 index 0000000..12a51d9 --- /dev/null +++ b/src/parsers/AlbumParser.ts @@ -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() + } + } +} diff --git a/src/parsers/ArtistParser.ts b/src/parsers/ArtistParser.ts new file mode 100644 index 0000000..b320cc6 --- /dev/null +++ b/src/parsers/ArtistParser.ts @@ -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() + } + } +} diff --git a/src/parsers/Parser.ts b/src/parsers/Parser.ts new file mode 100644 index 0000000..9d7e119 --- /dev/null +++ b/src/parsers/Parser.ts @@ -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 + } + } +} diff --git a/src/parsers/PlaylistParser.ts b/src/parsers/PlaylistParser.ts new file mode 100644 index 0000000..c1feb61 --- /dev/null +++ b/src/parsers/PlaylistParser.ts @@ -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() + } + } +} diff --git a/src/parsers/SearchParser.ts b/src/parsers/SearchParser.ts new file mode 100644 index 0000000..3aa703a --- /dev/null +++ b/src/parsers/SearchParser.ts @@ -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) + } +} diff --git a/src/parsers/SongParser.ts b/src/parsers/SongParser.ts new file mode 100644 index 0000000..339a667 --- /dev/null +++ b/src/parsers/SongParser.ts @@ -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 { + 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 + } + } +} diff --git a/src/parsers/VideoParser.ts b/src/parsers/VideoParser.ts new file mode 100644 index 0000000..527fdda --- /dev/null +++ b/src/parsers/VideoParser.ts @@ -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 { + 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() + } + } +} diff --git a/src/tests/all.ts b/src/tests/all.ts new file mode 100644 index 0000000..1f3133b --- /dev/null +++ b/src/tests/all.ts @@ -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][] = [ + [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}`) + }) +) diff --git a/src/tests/interfaces.ts b/src/tests/interfaces.ts new file mode 100644 index 0000000..0a69542 --- /dev/null +++ b/src/tests/interfaces.ts @@ -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 = OBJECT({ + url: STRING(), + width: NUMBER(), + height: NUMBER() +}) + +export const ARTIST_BASIC: ObjectValidator = OBJECT({ + artistId: OR(STRING(), NULL()), + name: STRING() +}) + +export const ALBUM_BASIC: ObjectValidator = OBJECT({ + albumId: STRING(), + name: STRING() +}) + +export const SONG_DETAILED: ObjectValidator = 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 = 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 = OBJECT({ + artistId: STRING(), + name: STRING(), + type: STRING("ARTIST"), + thumbnails: LIST(THUMBNAIL_FULL) +}) + +export const ALBUM_DETAILED: ObjectValidator = 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 = 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 = 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 = 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 = 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 = OBJECT({ + type: STRING("PLAYLIST"), + playlistId: STRING(), + name: STRING(), + artist: ARTIST_BASIC, + videoCount: NUMBER(), + thumbnails: LIST(THUMBNAIL_FULL) +}) + +export const PLAYLIST_VIDEO: ObjectValidator> = OBJECT({ + type: STRING("VIDEO"), + videoId: OR(STRING(), NULL()), + name: STRING(), + artists: LIST(ARTIST_BASIC), + duration: NUMBER(), + thumbnails: LIST(THUMBNAIL_FULL) +}) diff --git a/src/tests/run.ts b/src/tests/run.ts new file mode 100644 index 0000000..9c016b1 --- /dev/null +++ b/src/tests/run.ts @@ -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) + }) + }) +}) diff --git a/src/tests/traverse/his.ts b/src/tests/traverse/his.ts new file mode 100644 index 0000000..0906469 --- /dev/null +++ b/src/tests/traverse/his.ts @@ -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 {} diff --git a/src/tests/traverse/mine.ts b/src/tests/traverse/mine.ts new file mode 100644 index 0000000..aa1c993 --- /dev/null +++ b/src/tests/traverse/mine.ts @@ -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 {} diff --git a/src/types.d.ts b/src/types.d.ts index 8dad2ba..beadf25 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -7,7 +7,7 @@ declare namespace YTMusic { interface SongDetailed { type: "SONG" - videoId: string + videoId: string | null name: string artists: ArtistBasic[] album: AlbumBasic @@ -15,26 +15,48 @@ declare namespace YTMusic { thumbnails: ThumbnailFull[] } + interface SongFull extends Omit { + description: string + formats: any[] + adaptiveFormats: any[] + } + interface VideoDetailed { type: "VIDEO" - videoId: string + videoId: string | null name: string - artist: ArtistBasic + artists: ArtistBasic[] views: number duration: number thumbnails: ThumbnailFull[] } + interface VideoFull extends VideoDetailed { + description: string + unlisted: boolean + familySafe: boolean + paid: boolean + tags: string[] + } + interface ArtistBasic { - artistId: string + artistId: string | null name: string } interface ArtistDetailed extends ArtistBasic { type: "ARTIST" + artistId: string thumbnails: ThumbnailFull[] } + interface ArtistFull extends ArtistDetailed { + description: string | null + subscribers: number + topSongs: Omit[] + topAlbums: AlbumDetailed[] + } + interface AlbumBasic { albumId: string name: string @@ -48,20 +70,24 @@ declare namespace YTMusic { thumbnails: ThumbnailFull[] } - interface PlaylistDetailed { + interface AlbumFull extends AlbumDetailed { + description: string | null + songs: SongDetailed[] + } + + interface PlaylistFull { type: "PLAYLIST" playlistId: string name: string artist: ArtistBasic - trackCount: number + videoCount: number thumbnails: ThumbnailFull[] } type SearchResult = | SongDetailed - | PlaylistDetailed | VideoDetailed | AlbumDetailed | ArtistDetailed - | PlaylistDetailed + | PlaylistFull } diff --git a/src/utils/Parser.ts b/src/utils/Parser.ts deleted file mode 100644 index 23f678d..0000000 --- a/src/utils/Parser.ts +++ /dev/null @@ -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]() - }) - } -} diff --git a/tsconfig.json b/tsconfig.json index 518e7aa..63843d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,5 @@ "outDir": "build", "rootDir": "src" }, - "exclude": [ - "**/*.test.*" - ] -} \ No newline at end of file + "exclude": ["**/*.test.*", "**/tests"] +} diff --git a/yarn.lock b/yarn.lock index 1140d71..59a3cd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -44,6 +44,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40" 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: version "8.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" @@ -73,6 +78,37 @@ axios@^0.24.0: dependencies: 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: version "1.1.1" 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" 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: version "1.5.10" 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" 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: version "0.1.2" resolved "https://registry.yarnpkg.com/http-debug/-/http-debug-0.1.2.tgz#aadbfef99bc39c206439ece14b99040c5a4b4d6e" 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: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 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: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" 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: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 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: version "1.8.0" 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" 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: version "4.0.0" 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" 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: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"