Merge branch 'type-safety' into main

This commit is contained in:
Zechariah 2022-03-28 03:29:42 +08:00
commit ec98127f62
22 changed files with 878 additions and 3867 deletions

View File

@ -3,5 +3,4 @@
.gitignore .gitignore
.prettierrc .prettierrc
.editorconfig .editorconfig
tsconfig.json tsconfig.json
babel.config.js

28
.vscode/launch.json vendored
View File

@ -1,28 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "testing/run.ts",
"program": "${workspaceFolder}/src/testing/run.ts",
"sourceMaps": true,
"skipFiles": ["<node_internals>/**"],
"runtimeExecutable": "node",
"runtimeArgs": ["--require", "ts-node/register/files"]
},
{
"type": "node",
"request": "launch",
"name": "tests/all.ts",
"program": "${workspaceFolder}/src/tests/all.ts",
"sourceMaps": true,
"skipFiles": ["<node_internals>/**"],
"runtimeExecutable": "node",
"runtimeArgs": ["--require", "ts-node/register/files"]
}
]
}

22
.vscode/settings.json vendored
View File

@ -1,22 +0,0 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/.project": true,
"**/.vscode": true,
"**/*.cs.meta": true,
"**/android": true,
"**/ios": true,
"**/node_modules": false,
"**/__pycache__": true,
"**/babel.config.js": true,
"**/metro.config.js": true,
"**/.prettierrc": true,
"**/.editorconfig": true
},
"explorerExclude.backup": null
}

View File

@ -22,14 +22,6 @@ Because of this, I decided to build my own version of a youtube music api with T
## Installation ## Installation
With `yarn`
```
$ yarn add ytmusic-api
```
With `npm`
``` ```
$ npm i ytmusic-api $ npm i ytmusic-api
``` ```
@ -294,7 +286,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description | | Name | Data Type | Description |
| :--------- | :---------------------------------- | :------------------ | | :--------- | :---------------------------------- | :------------------ |
| type | `"SONG"` | Type of data | | type | `"SONG"` | Type of data |
| videoId | `string \| null` | YouTube Video ID | | videoId | `string` | YouTube Video ID |
| name | `string` | Name | | name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Artists | | artists | [ArtistBasic](#ArtistBasic)`[]` | Artists |
| album | [AlbumBasic](#AlbumBasic) | Album | | album | [AlbumBasic](#AlbumBasic) | Album |
@ -306,7 +298,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description | | Name | Data Type | Description |
| :-------------- | :---------------------------------- | :--------------------- | | :-------------- | :---------------------------------- | :--------------------- |
| type | `"SONG"` | Type of data | | type | `"SONG"` | Type of data |
| videoId | `string \| null` | YouTube Video ID | | videoId | `string` | YouTube Video ID |
| name | `string` | Name | | name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Artists | | artists | [ArtistBasic](#ArtistBasic)`[]` | Artists |
| duration | `number` | Duration in seconds | | duration | `number` | Duration in seconds |
@ -320,7 +312,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description | | Name | Data Type | Description |
| :--------- | :---------------------------------- | :------------------------------ | | :--------- | :---------------------------------- | :------------------------------ |
| type | `"VIDEO"` | Type of data | | type | `"VIDEO"` | Type of data |
| videoId | `string \| null` | YouTube Video ID | | videoId | `string` | YouTube Video ID |
| name | `string` | Name | | name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video | | artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video |
| views | `number` | View count | | views | `number` | View count |
@ -332,7 +324,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description | | Name | Data Type | Description |
| :---------- | :---------------------------------- | :------------------------------------- | | :---------- | :---------------------------------- | :------------------------------------- |
| type | `"VIDEO"` | Type of data | | type | `"VIDEO"` | Type of data |
| videoId | `string \| null` | YouTube Video ID | | videoId | `string` | YouTube Video ID |
| name | `string` | Name | | name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video | | artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video |
| views | `number` | View count | | views | `number` | View count |
@ -348,7 +340,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description | | Name | Data Type | Description |
| :------- | :--------------- | :---------- | | :------- | :--------------- | :---------- |
| artistId | `string \| null` | Artist ID | | artistId | `string` | Artist ID |
| name | `string` | Name | | name | `string` | Name |
#### `ArtistDetailed` #### `ArtistDetailed`
@ -368,7 +360,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| artistId | `string` | Artist ID | | artistId | `string` | Artist ID |
| name | `string` | Name | | name | `string` | Name |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails | | thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string \| null` | Description | | description | `string` | Description |
| subscribers | `number` | Number of subscribers the Artist has | | subscribers | `number` | Number of subscribers the Artist has |
| topSongs | `Omit<`[SongDetailed](#SongDetailed)`, "duration">[]` | Top Songs from Artist | | topSongs | `Omit<`[SongDetailed](#SongDetailed)`, "duration">[]` | Top Songs from Artist |
| topAlbums | [AlbumDetailed](#AlbumDetailed)`[]` | Top Albums from Artist | | topAlbums | [AlbumDetailed](#AlbumDetailed)`[]` | Top Albums from Artist |
@ -403,7 +395,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| artists | [ArtistBasic](#ArtistBasic)`[]` | Creators of the Album | | artists | [ArtistBasic](#ArtistBasic)`[]` | Creators of the Album |
| year | `number` | Publication Year | | year | `number` | Publication Year |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails | | thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string \| null` | Description | | description | `string` | Description |
| songs | [SongDetailed](#SongDetailed)`[]` | Songs in the Album | | songs | [SongDetailed](#SongDetailed)`[]` | Songs in the Album |
#### `PlaylistFull` #### `PlaylistFull`
@ -426,7 +418,7 @@ A lot of the credit should go to [youtube-music-api](https://npmjs.com/package/y
## Testing ## Testing
YTMusic API's data return types are tested with Jest. To run the tests, run the command YTMusic API's data return types are tested with Mocha. To run the tests, run the command
``` ```
$ npm run test $ npm run test
@ -435,18 +427,17 @@ $ npm run test
## Built with ## Built with
- TypeScript - TypeScript
- [![@types/jest](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@types/jest?style=flat-square)](https://npmjs.com/package/@types/jest) - [![@types/mocha](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@types/mocha?style=flat-square)](https://npmjs.com/package/@types/mocha)
- [![@types/node](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@types/node?style=flat-square)](https://npmjs.com/package/@types/node)
- [![@types/tough-cookie](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@types/tough-cookie?style=flat-square)](https://npmjs.com/package/@types/tough-cookie) - [![@types/tough-cookie](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@types/tough-cookie?style=flat-square)](https://npmjs.com/package/@types/tough-cookie)
- [![typescript](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/typescript?style=flat-square)](https://npmjs.com/package/typescript) - [![typescript](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/typescript?style=flat-square)](https://npmjs.com/package/typescript)
- Axios - Axios
- [![axios](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/axios?style=flat-square)](https://npmjs.com/package/axios) - [![axios](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/axios?style=flat-square)](https://npmjs.com/package/axios)
- Tough Cookie - Tough Cookie
- [![tough-cookie](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/tough-cookie?style=flat-square)](https://npmjs.com/package/tough-cookie) - [![tough-cookie](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/tough-cookie?style=flat-square)](https://npmjs.com/package/tough-cookie)
- Jest - Mocha
- [![@babel/core](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@babel/core?style=flat-square)](https://npmjs.com/package/@babel/core) - [![mocha](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/mocha?style=flat-square)](https://npmjs.com/package/mocha)
- [![@babel/preset-env](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@babel/preset-env?style=flat-square)](https://npmjs.com/package/@babel/preset-env) - [![mocha.parallel](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/mocha.parallel?style=flat-square)](https://npmjs.com/package/mocha.parallel)
- [![@babel/preset-typescript](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/@babel/preset-typescript?style=flat-square)](https://npmjs.com/package/@babel/preset-typescript) - [![ts-mocha](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/ts-mocha?style=flat-square)](https://npmjs.com/package/ts-mocha)
- [![babel-jest](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/babel-jest?style=flat-square)](https://npmjs.com/package/babel-jest)
- [![jest](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/jest?style=flat-square)](https://npmjs.com/package/jest)
- Miscellaneous - Miscellaneous
- [![validate-any](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/validate-any?style=flat-square)](https://npmjs.com/package/validate-any) - [![validate-any](https://img.shields.io/github/package-json/dependency-version/zS1L3NT/ts-npm-ytmusic-api/dev/validate-any?style=flat-square)](https://npmjs.com/package/validate-any)

View File

@ -1,6 +0,0 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript"
]
}

View File

@ -1,6 +1,6 @@
{ {
"name": "ytmusic-api", "name": "ytmusic-api",
"version": "1.0.4", "version": "1.0.5",
"description": "YouTube Music API", "description": "YouTube Music API",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@ -11,22 +11,21 @@
"url": "https://github.com/zS1L3NT/ts-npm-ytmusic-api" "url": "https://github.com/zS1L3NT/ts-npm-ytmusic-api"
}, },
"scripts": { "scripts": {
"test": "jest" "test": "ts-mocha --timeout 10000 src/__tests__/**/*.spec.ts"
}, },
"dependencies": { "dependencies": {
"axios": "^0.25.0", "axios": "^0.25.0",
"tough-cookie": "^4.0.0" "tough-cookie": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.0", "@types/mocha": "^9.1.0",
"@babel/preset-env": "^7.16.11", "@types/node": "^17.0.23",
"@babel/preset-typescript": "^7.16.7",
"@types/jest": "^27.4.0",
"@types/tough-cookie": "^4.0.1", "@types/tough-cookie": "^4.0.1",
"babel-jest": "^27.4.6", "mocha": "^9.2.2",
"jest": "^27.4.7", "mocha.parallel": "^0.15.6",
"ts-mocha": "^9.0.2",
"typescript": "^4.5.5", "typescript": "^4.5.5",
"validate-any": "^1.2.0" "validate-any": "1.3.1"
}, },
"keywords": [ "keywords": [
"youtube", "youtube",

File diff suppressed because it is too large Load Diff

1
src/@types/mocha.parallel.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "mocha.parallel"

View File

@ -4,7 +4,9 @@ import axios, { AxiosInstance } from "axios"
import PlaylistParser from "./parsers/PlaylistParser" import PlaylistParser from "./parsers/PlaylistParser"
import SearchParser from "./parsers/SearchParser" import SearchParser from "./parsers/SearchParser"
import SongParser from "./parsers/SongParser" import SongParser from "./parsers/SongParser"
import traverse from "./traverse" import traverse from "./utils/traverse"
import traverseList from "./utils/traverseList"
import traverseString from "./utils/traverseString"
import VideoParser from "./parsers/VideoParser" import VideoParser from "./parsers/VideoParser"
import { import {
AlbumDetailed, AlbumDetailed,
@ -203,7 +205,7 @@ export default class YTMusic {
* @returns Search suggestions * @returns Search suggestions
*/ */
public async getSearchSuggestions(query: string): Promise<string[]> { public async getSearchSuggestions(query: string): Promise<string[]> {
return traverse( return traverseList(
await this.constructRequest("music/get_search_suggestions", { await this.constructRequest("music/get_search_suggestions", {
input: query input: query
}), }),
@ -236,7 +238,7 @@ export default class YTMusic {
}[category!] || null }[category!] || null
}) })
return [traverse(searchData, "musicResponsiveListItemRenderer")].flat().map( return traverseList(searchData, "musicResponsiveListItemRenderer").map(
{ {
SONG: SongParser.parseSearchResult, SONG: SongParser.parseSearchResult,
VIDEO: VideoParser.parseSearchResult, VIDEO: VideoParser.parseSearchResult,
@ -308,8 +310,8 @@ export default class YTMusic {
) )
return [ return [
...traverse(songsData, "musicResponsiveListItemRenderer"), ...traverseList(songsData, "musicResponsiveListItemRenderer"),
...traverse(moreSongsData, "musicResponsiveListItemRenderer") ...traverseList(moreSongsData, "musicResponsiveListItemRenderer")
].map(SongParser.parseArtistSong) ].map(SongParser.parseArtistSong)
} }
@ -321,15 +323,15 @@ export default class YTMusic {
*/ */
public async getArtistAlbums(artistId: string): Promise<AlbumDetailed[]> { public async getArtistAlbums(artistId: string): Promise<AlbumDetailed[]> {
const artistData = await this.constructRequest("browse", { browseId: artistId }) const artistData = await this.constructRequest("browse", { browseId: artistId })
const artistAlbumsData = traverse(artistData, "musicCarouselShelfRenderer")[0] const artistAlbumsData = traverseList(artistData, "musicCarouselShelfRenderer")[0]
const browseBody = traverse(artistAlbumsData, "moreContentButton", "browseEndpoint") const browseBody = traverse(artistAlbumsData, "moreContentButton", "browseEndpoint")
const albumsData = await this.constructRequest("browse", browseBody) const albumsData = await this.constructRequest("browse", browseBody)
return traverse(albumsData, "musicTwoRowItemRenderer").map((item: any) => return traverseList(albumsData, "musicTwoRowItemRenderer").map(item =>
AlbumParser.parseArtistAlbum(item, { AlbumParser.parseArtistAlbum(item, {
artistId, artistId,
name: traverse(albumsData, "header", "text").at(0) name: traverseString(albumsData, "header", "text")()
}) })
) )
} }
@ -369,7 +371,7 @@ export default class YTMusic {
if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
const playlistData = await this.constructRequest("browse", { browseId: playlistId }) const playlistData = await this.constructRequest("browse", { browseId: playlistId })
const songs = traverse( const songs = traverseList(
playlistData, playlistData,
"musicPlaylistShelfRenderer", "musicPlaylistShelfRenderer",
"musicResponsiveListItemRenderer" "musicResponsiveListItemRenderer"
@ -379,7 +381,7 @@ export default class YTMusic {
if (continuation instanceof Array) break if (continuation instanceof Array) break
const songsData = await this.constructRequest("browse", {}, { continuation }) const songsData = await this.constructRequest("browse", {}, { continuation })
songs.push(...traverse(songsData, "musicResponsiveListItemRenderer")) songs.push(...traverseList(songsData, "musicResponsiveListItemRenderer"))
continuation = traverse(songsData, "continuation") continuation = traverse(songsData, "continuation")
} }

View File

@ -1,349 +1,122 @@
import ObjectValidator from "validate-any/dist/validators/ObjectValidator" import assert from "assert"
import describeParallel from "mocha.parallel"
import Validator from "validate-any/dist/classes/Validator" import Validator from "validate-any/dist/classes/Validator"
import YTMusic, { import YTMusic from ".."
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from ".."
import { import {
BOOLEAN, ALBUM_DETAILED,
iValidationError, ALBUM_FULL,
LIST, ARTIST_DETAILED,
NULL, ARTIST_FULL,
NUMBER, PLAYLIST_FULL,
OBJECT, PLAYLIST_VIDEO,
OR, SONG_DETAILED,
STRING, SONG_FULL,
validate VIDEO_DETAILED,
} from "validate-any" VIDEO_FULL
} from "../interfaces"
//#region Interfaces import { iValidationError, LIST, STRING, validate } from "validate-any"
const THUMBNAIL_FULL: ObjectValidator<ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
const ARTIST_BASIC: ObjectValidator<ArtistBasic> = OBJECT({
artistId: OR(STRING(), NULL()),
name: STRING()
})
const ALBUM_BASIC: ObjectValidator<AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
const SONG_DETAILED: ObjectValidator<SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const VIDEO_DETAILED: ObjectValidator<VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const ARTIST_DETAILED: ObjectValidator<ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
const ALBUM_DETAILED: ObjectValidator<AlbumDetailed> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const SONG_FULL: ObjectValidator<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())
})
const VIDEO_FULL: ObjectValidator<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())
})
const ARTIST_FULL: ObjectValidator<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)
})
const ALBUM_FULL: ObjectValidator<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)
})
const PLAYLIST_DETAILED: ObjectValidator<PlaylistFull> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const PLAYLIST_VIDEO: ObjectValidator<Omit<VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
//#endregion
const issues: iValidationError[][] = [] const issues: iValidationError[][] = []
const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"] const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"]
const _expect = (data: any, validator: Validator<any>) => { const expect = (data: any, validator: Validator<any>) => {
const { errors } = validate(data, validator) const { errors } = validate(data, validator)
if (errors.length > 0) { if (errors.length > 0) {
issues.push(errors) issues.push(errors)
} }
assert.equal(errors.length, 0)
expect(errors.length).toBe(0)
} }
const ytmusic = new YTMusic() const ytmusic = new YTMusic()
before(() => ytmusic.initialize())
beforeAll(() => ytmusic.initialize())
beforeEach(() => jest.setTimeout(10_000))
queries.forEach(query => { queries.forEach(query => {
describe("Query: " + query, () => { describeParallel("Query: " + query, () => {
test("Search suggestions", done => { it("Search suggestions", async () => {
ytmusic const suggestions = await ytmusic.getSearchSuggestions(query)
.getSearchSuggestions(query) expect(suggestions, LIST(STRING()))
.then(suggestions => {
_expect(suggestions, LIST(STRING()))
done()
})
.catch(done)
}) })
test("Search Songs", done => { it("Search Songs", async () => {
ytmusic const songs = await ytmusic.search(query, "SONG")
.search(query, "SONG") expect(songs, LIST(SONG_DETAILED))
.then(songs => {
_expect(songs, LIST(SONG_DETAILED))
done()
})
.catch(done)
}) })
test("Search Videos", done => { it("Search Videos", async () => {
ytmusic const videos = await ytmusic.search(query, "VIDEO")
.search(query, "VIDEO") expect(videos, LIST(VIDEO_DETAILED))
.then(videos => {
_expect(videos, LIST(VIDEO_DETAILED))
done()
})
.catch(done)
}) })
test("Search Artists", done => { it("Search Artists", async () => {
ytmusic const artists = await ytmusic.search(query, "ARTIST")
.search(query, "ARTIST") expect(artists, LIST(ARTIST_DETAILED))
.then(artists => {
_expect(artists, LIST(ARTIST_DETAILED))
done()
})
.catch(done)
}) })
test("Search Albums", done => { it("Search Albums", async () => {
ytmusic const albums = await ytmusic.search(query, "ALBUM")
.search(query, "ALBUM") expect(albums, LIST(ALBUM_DETAILED))
.then(albums => {
_expect(albums, LIST(ALBUM_DETAILED))
done()
})
.catch(done)
}) })
test("Search Playlists", done => { it("Search Playlists", async () => {
ytmusic const playlists = await ytmusic.search(query, "PLAYLIST")
.search(query, "PLAYLIST") expect(playlists, LIST(PLAYLIST_FULL))
.then(playlists => {
_expect(playlists, LIST(PLAYLIST_DETAILED))
done()
})
.catch(done)
}) })
test("Search All", done => { it("Search All", async () => {
ytmusic const results = await ytmusic.search(query)
.search(query) expect(
.then(results => { results,
_expect( LIST(ALBUM_DETAILED, ARTIST_DETAILED, PLAYLIST_FULL, SONG_DETAILED, VIDEO_DETAILED)
results, )
LIST(
ALBUM_DETAILED,
ARTIST_DETAILED,
PLAYLIST_DETAILED,
SONG_DETAILED,
VIDEO_DETAILED
)
)
done()
})
.catch(done)
}) })
test("Get details of the first song result", done => { it("Get details of the first song result", async () => {
ytmusic const songs = await ytmusic.search(query, "SONG")
.search(query, "SONG") const song = await ytmusic.getSong(songs[0]!.videoId)
.then(songs => ytmusic.getSong(songs[0]!.videoId!)) expect(song, SONG_FULL)
.then(song => {
_expect(song, SONG_FULL)
done()
})
.catch(done)
}) })
test("Get details of the first video result", done => { it("Get details of the first video result", async () => {
ytmusic const videos = await ytmusic.search(query, "VIDEO")
.search(query, "VIDEO") const video = await ytmusic.getVideo(videos[0]!.videoId)
.then(videos => ytmusic.getVideo(videos[0]!.videoId!)) expect(video, VIDEO_FULL)
.then(video => {
_expect(video, VIDEO_FULL)
done()
})
.catch(done)
}) })
test("Get details of the first artist result", done => { it("Get details of the first artist result", async () => {
ytmusic const artists = await ytmusic.search(query, "ARTIST")
.search(query, "ARTIST") const artist = await ytmusic.getArtist(artists[0]!.artistId)
.then(artists => ytmusic.getArtist(artists[0]!.artistId!)) expect(artist, ARTIST_FULL)
.then(artist => {
_expect(artist, ARTIST_FULL)
done()
})
.catch(done)
}) })
test("Get the songs of the first artist result", done => { it("Get the songs of the first artist result", async () => {
ytmusic const artists = await ytmusic.search(query, "ARTIST")
.search(query, "ARTIST") const songs = await ytmusic.getArtistSongs(artists[0]!.artistId)
.then(artists => ytmusic.getArtistSongs(artists[0]!.artistId!)) expect(songs, LIST(SONG_DETAILED))
.then(songs => {
_expect(songs, LIST(SONG_DETAILED))
done()
})
.catch(done)
}) })
test("Get the albums of the first artist result", done => { it("Get the albums of the first artist result", async () => {
ytmusic const artists = await ytmusic.search(query, "ARTIST")
.search(query, "ARTIST") const albums = await ytmusic.getArtistAlbums(artists[0]!.artistId)
.then(artists => ytmusic.getArtistAlbums(artists[0]!.artistId!)) expect(albums, LIST(ALBUM_DETAILED))
.then(albums => {
_expect(albums, LIST(ALBUM_DETAILED))
done()
})
.catch(done)
}) })
test("Get details of the first album result", done => { it("Get details of the first album result", async () => {
ytmusic const albums = await ytmusic.search(query, "ALBUM")
.search(query, "ALBUM") const album = await ytmusic.getAlbum(albums[0]!.albumId)
.then(albums => ytmusic.getAlbum(albums[0]!.albumId!)) expect(album, ALBUM_FULL)
.then(album => {
_expect(album, ALBUM_FULL)
done()
})
.catch(done)
}) })
test("Get details of the first playlist result", done => { it("Get details of the first playlist result", async () => {
ytmusic const playlists = await ytmusic.search(query, "PLAYLIST")
.search(query, "PLAYLIST") const playlist = await ytmusic.getPlaylist(playlists[0]!.playlistId)
.then(playlists => ytmusic.getPlaylist(playlists[0]!.playlistId!)) expect(playlist, PLAYLIST_FULL)
.then(playlist => {
_expect(playlist, PLAYLIST_DETAILED)
done()
})
.catch(done)
}) })
test("Get the videos of the first playlist result", done => { it("Get the videos of the first playlist result", async () => {
ytmusic const playlists = await ytmusic.search(query, "PLAYLIST")
.search(query, "PLAYLIST") const videos = await ytmusic.getPlaylistVideos(playlists[0]!.playlistId)
.then(playlists => ytmusic.getPlaylistVideos(playlists[0]!.playlistId!)) expect(videos, LIST(PLAYLIST_VIDEO))
.then(videos => {
_expect(videos, LIST(PLAYLIST_VIDEO))
done()
})
.catch(done)
}) })
}) })
}) })
afterAll(() => console.log("Issues:", issues)) after(() => console.log("Issues:", issues))

View File

@ -8,7 +8,7 @@ export interface ThumbnailFull {
export interface SongDetailed { export interface SongDetailed {
type: "SONG" type: "SONG"
videoId: string | null videoId: string
name: string name: string
artists: ArtistBasic[] artists: ArtistBasic[]
album: AlbumBasic album: AlbumBasic
@ -24,7 +24,7 @@ export interface SongFull extends Omit<SongDetailed, "album"> {
export interface VideoDetailed { export interface VideoDetailed {
type: "VIDEO" type: "VIDEO"
videoId: string | null videoId: string
name: string name: string
artists: ArtistBasic[] artists: ArtistBasic[]
views: number views: number
@ -41,7 +41,7 @@ export interface VideoFull extends VideoDetailed {
} }
export interface ArtistBasic { export interface ArtistBasic {
artistId: string | null artistId: string
name: string name: string
} }
@ -52,7 +52,7 @@ export interface ArtistDetailed extends ArtistBasic {
} }
export interface ArtistFull extends ArtistDetailed { export interface ArtistFull extends ArtistDetailed {
description: string | null description: string
subscribers: number subscribers: number
topSongs: Omit<SongDetailed, "duration">[] topSongs: Omit<SongDetailed, "duration">[]
topAlbums: AlbumDetailed[] topAlbums: AlbumDetailed[]
@ -72,7 +72,7 @@ export interface AlbumDetailed extends AlbumBasic {
} }
export interface AlbumFull extends AlbumDetailed { export interface AlbumFull extends AlbumDetailed {
description: string | null description: string
songs: SongDetailed[] songs: SongDetailed[]
} }

146
src/interfaces.ts Normal file
View File

@ -0,0 +1,146 @@
import ObjectValidator from "validate-any/dist/validators/ObjectValidator"
import {
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from "."
import { BOOLEAN, LIST, NUMBER, OBJECT, STRING } from "validate-any"
export const THUMBNAIL_FULL: ObjectValidator<ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
export const ARTIST_BASIC: ObjectValidator<ArtistBasic> = OBJECT({
artistId: STRING(),
name: STRING()
})
export const ALBUM_BASIC: ObjectValidator<AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
export const SONG_DETAILED: ObjectValidator<SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const VIDEO_DETAILED: ObjectValidator<VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ARTIST_DETAILED: ObjectValidator<ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ALBUM_DETAILED: ObjectValidator<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<SongFull> = OBJECT({
type: STRING("SONG"),
videoId: STRING(),
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<VideoFull> = OBJECT({
type: STRING("VIDEO"),
videoId: STRING(),
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<ArtistFull> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
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<AlbumFull> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
songs: LIST(SONG_DETAILED)
})
export const PLAYLIST_FULL: ObjectValidator<PlaylistFull> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const PLAYLIST_VIDEO: ObjectValidator<Omit<VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})

View File

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

View File

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

View File

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

View File

@ -2,14 +2,14 @@ import AlbumParser from "./AlbumParser"
import ArtistParser from "./ArtistParser" import ArtistParser from "./ArtistParser"
import PlaylistParser from "./PlaylistParser" import PlaylistParser from "./PlaylistParser"
import SongParser from "./SongParser" import SongParser from "./SongParser"
import traverse from "../traverse" import traverseList from "../utils/traverseList"
import VideoParser from "./VideoParser" import VideoParser from "./VideoParser"
import { SearchResult } from ".." import { SearchResult } from ".."
export default class SearchParser { export default class SearchParser {
public static parse(item: any): SearchResult { public static parse(item: any): SearchResult {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverseList(item, "flexColumns")
const type = [traverse(flexColumns[1], "runs", "text")].flat().at(0) as const type = traverseList(flexColumns[1], "runs", "text").at(0) as
| "Song" | "Song"
| "Video" | "Video"
| "Artist" | "Artist"

View File

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

View File

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

23
src/utils/checkType.ts Normal file
View File

@ -0,0 +1,23 @@
import Validator from "validate-any/dist/classes/Validator"
import { validate } from "validate-any"
export default <T>(data: T, validator: Validator<T>): T => {
const result = validate(data, validator)
if (result.success) {
return result.data
} else {
console.error(
"Invalid data schema, please report to https://github.com/zS1L3NT/ts-npm-ytmusic-api/issues/new/choose",
JSON.stringify(
{
expected: validator.getSchema(),
actual: data,
errors: result.errors
},
null,
2
)
)
return data
}
}

View File

@ -0,0 +1,7 @@
import traverse from "./traverse"
export default (data: any, ...keys: string[]): any[] => {
const value = traverse(data, ...keys)
const flatValue = [value].flat()
return flatValue
}

View File

@ -0,0 +1,7 @@
import traverse from "./traverse"
export default (data: any, ...keys: string[]) => (index = 0): string => {
const value = traverse(data, ...keys)
const flatValue = [value].flat().at(index)
return flatValue || ""
}