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

@ -4,4 +4,3 @@
.prettierrc
.editorconfig
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
With `yarn`
```
$ yarn add ytmusic-api
```
With `npm`
```
$ npm i ytmusic-api
```
@ -294,7 +286,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description |
| :--------- | :---------------------------------- | :------------------ |
| type | `"SONG"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| videoId | `string` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Artists |
| album | [AlbumBasic](#AlbumBasic) | Album |
@ -306,7 +298,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description |
| :-------------- | :---------------------------------- | :--------------------- |
| type | `"SONG"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| videoId | `string` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Artists |
| duration | `number` | Duration in seconds |
@ -320,7 +312,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description |
| :--------- | :---------------------------------- | :------------------------------ |
| type | `"VIDEO"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| videoId | `string` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video |
| views | `number` | View count |
@ -332,7 +324,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description |
| :---------- | :---------------------------------- | :------------------------------------- |
| type | `"VIDEO"` | Type of data |
| videoId | `string \| null` | YouTube Video ID |
| videoId | `string` | YouTube Video ID |
| name | `string` | Name |
| artists | [ArtistBasic](#ArtistBasic)`[]` | Channels that created the video |
| views | `number` | View count |
@ -348,7 +340,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| Name | Data Type | Description |
| :------- | :--------------- | :---------- |
| artistId | `string \| null` | Artist ID |
| artistId | `string` | Artist ID |
| name | `string` | Name |
#### `ArtistDetailed`
@ -368,7 +360,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| artistId | `string` | Artist ID |
| name | `string` | Name |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string \| null` | Description |
| description | `string` | 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 |
@ -403,7 +395,7 @@ ytmusic.getPlaylistVideos("OLAK5uy_nRb467jR73IXKybwzw22_rTYIJ808x4Yc").then(play
| artists | [ArtistBasic](#ArtistBasic)`[]` | Creators of the Album |
| year | `number` | Publication Year |
| thumbnails | [ThumbnailFull](#ThumbnailFull)`[]` | Thumbnails |
| description | `string \| null` | Description |
| description | `string` | Description |
| songs | [SongDetailed](#SongDetailed)`[]` | Songs in the Album |
#### `PlaylistFull`
@ -426,7 +418,7 @@ A lot of the credit should go to [youtube-music-api](https://npmjs.com/package/y
## 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
@ -435,18 +427,17 @@ $ npm run test
## Built with
- 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)
- [![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](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](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
- [![@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)
- [![@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)
- [![@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)
- [![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)
- Mocha
- [![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)
- [![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)
- [![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)
- 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)

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",
"version": "1.0.4",
"version": "1.0.5",
"description": "YouTube Music API",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -11,22 +11,21 @@
"url": "https://github.com/zS1L3NT/ts-npm-ytmusic-api"
},
"scripts": {
"test": "jest"
"test": "ts-mocha --timeout 10000 src/__tests__/**/*.spec.ts"
},
"dependencies": {
"axios": "^0.25.0",
"tough-cookie": "^4.0.0"
},
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-typescript": "^7.16.7",
"@types/jest": "^27.4.0",
"@types/mocha": "^9.1.0",
"@types/node": "^17.0.23",
"@types/tough-cookie": "^4.0.1",
"babel-jest": "^27.4.6",
"jest": "^27.4.7",
"mocha": "^9.2.2",
"mocha.parallel": "^0.15.6",
"ts-mocha": "^9.0.2",
"typescript": "^4.5.5",
"validate-any": "^1.2.0"
"validate-any": "1.3.1"
},
"keywords": [
"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 SearchParser from "./parsers/SearchParser"
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 {
AlbumDetailed,
@ -203,7 +205,7 @@ export default class YTMusic {
* @returns Search suggestions
*/
public async getSearchSuggestions(query: string): Promise<string[]> {
return traverse(
return traverseList(
await this.constructRequest("music/get_search_suggestions", {
input: query
}),
@ -236,7 +238,7 @@ export default class YTMusic {
}[category!] || null
})
return [traverse(searchData, "musicResponsiveListItemRenderer")].flat().map(
return traverseList(searchData, "musicResponsiveListItemRenderer").map(
{
SONG: SongParser.parseSearchResult,
VIDEO: VideoParser.parseSearchResult,
@ -308,8 +310,8 @@ export default class YTMusic {
)
return [
...traverse(songsData, "musicResponsiveListItemRenderer"),
...traverse(moreSongsData, "musicResponsiveListItemRenderer")
...traverseList(songsData, "musicResponsiveListItemRenderer"),
...traverseList(moreSongsData, "musicResponsiveListItemRenderer")
].map(SongParser.parseArtistSong)
}
@ -321,15 +323,15 @@ export default class YTMusic {
*/
public async getArtistAlbums(artistId: string): Promise<AlbumDetailed[]> {
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 albumsData = await this.constructRequest("browse", browseBody)
return traverse(albumsData, "musicTwoRowItemRenderer").map((item: any) =>
return traverseList(albumsData, "musicTwoRowItemRenderer").map(item =>
AlbumParser.parseArtistAlbum(item, {
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
const playlistData = await this.constructRequest("browse", { browseId: playlistId })
const songs = traverse(
const songs = traverseList(
playlistData,
"musicPlaylistShelfRenderer",
"musicResponsiveListItemRenderer"
@ -379,7 +381,7 @@ export default class YTMusic {
if (continuation instanceof Array) break
const songsData = await this.constructRequest("browse", {}, { continuation })
songs.push(...traverse(songsData, "musicResponsiveListItemRenderer"))
songs.push(...traverseList(songsData, "musicResponsiveListItemRenderer"))
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 YTMusic, {
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from ".."
import YTMusic from ".."
import {
BOOLEAN,
iValidationError,
LIST,
NULL,
NUMBER,
OBJECT,
OR,
STRING,
validate
} from "validate-any"
//#region Interfaces
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
ALBUM_DETAILED,
ALBUM_FULL,
ARTIST_DETAILED,
ARTIST_FULL,
PLAYLIST_FULL,
PLAYLIST_VIDEO,
SONG_DETAILED,
SONG_FULL,
VIDEO_DETAILED,
VIDEO_FULL
} from "../interfaces"
import { iValidationError, LIST, STRING, validate } from "validate-any"
const issues: iValidationError[][] = []
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)
if (errors.length > 0) {
issues.push(errors)
}
expect(errors.length).toBe(0)
assert.equal(errors.length, 0)
}
const ytmusic = new YTMusic()
beforeAll(() => ytmusic.initialize())
beforeEach(() => jest.setTimeout(10_000))
before(() => ytmusic.initialize())
queries.forEach(query => {
describe("Query: " + query, () => {
test("Search suggestions", done => {
ytmusic
.getSearchSuggestions(query)
.then(suggestions => {
_expect(suggestions, LIST(STRING()))
done()
})
.catch(done)
describeParallel("Query: " + query, () => {
it("Search suggestions", async () => {
const suggestions = await ytmusic.getSearchSuggestions(query)
expect(suggestions, LIST(STRING()))
})
test("Search Songs", done => {
ytmusic
.search(query, "SONG")
.then(songs => {
_expect(songs, LIST(SONG_DETAILED))
done()
})
.catch(done)
it("Search Songs", async () => {
const songs = await ytmusic.search(query, "SONG")
expect(songs, LIST(SONG_DETAILED))
})
test("Search Videos", done => {
ytmusic
.search(query, "VIDEO")
.then(videos => {
_expect(videos, LIST(VIDEO_DETAILED))
done()
})
.catch(done)
it("Search Videos", async () => {
const videos = await ytmusic.search(query, "VIDEO")
expect(videos, LIST(VIDEO_DETAILED))
})
test("Search Artists", done => {
ytmusic
.search(query, "ARTIST")
.then(artists => {
_expect(artists, LIST(ARTIST_DETAILED))
done()
})
.catch(done)
it("Search Artists", async () => {
const artists = await ytmusic.search(query, "ARTIST")
expect(artists, LIST(ARTIST_DETAILED))
})
test("Search Albums", done => {
ytmusic
.search(query, "ALBUM")
.then(albums => {
_expect(albums, LIST(ALBUM_DETAILED))
done()
})
.catch(done)
it("Search Albums", async () => {
const albums = await ytmusic.search(query, "ALBUM")
expect(albums, LIST(ALBUM_DETAILED))
})
test("Search Playlists", done => {
ytmusic
.search(query, "PLAYLIST")
.then(playlists => {
_expect(playlists, LIST(PLAYLIST_DETAILED))
done()
})
.catch(done)
it("Search Playlists", async () => {
const playlists = await ytmusic.search(query, "PLAYLIST")
expect(playlists, LIST(PLAYLIST_FULL))
})
test("Search All", done => {
ytmusic
.search(query)
.then(results => {
_expect(
it("Search All", async () => {
const results = await ytmusic.search(query)
expect(
results,
LIST(
ALBUM_DETAILED,
ARTIST_DETAILED,
PLAYLIST_DETAILED,
SONG_DETAILED,
VIDEO_DETAILED
LIST(ALBUM_DETAILED, ARTIST_DETAILED, PLAYLIST_FULL, SONG_DETAILED, VIDEO_DETAILED)
)
)
done()
})
.catch(done)
})
test("Get details of the first song result", done => {
ytmusic
.search(query, "SONG")
.then(songs => ytmusic.getSong(songs[0]!.videoId!))
.then(song => {
_expect(song, SONG_FULL)
done()
})
.catch(done)
it("Get details of the first song result", async () => {
const songs = await ytmusic.search(query, "SONG")
const song = await ytmusic.getSong(songs[0]!.videoId)
expect(song, SONG_FULL)
})
test("Get details of the first video result", done => {
ytmusic
.search(query, "VIDEO")
.then(videos => ytmusic.getVideo(videos[0]!.videoId!))
.then(video => {
_expect(video, VIDEO_FULL)
done()
})
.catch(done)
it("Get details of the first video result", async () => {
const videos = await ytmusic.search(query, "VIDEO")
const video = await ytmusic.getVideo(videos[0]!.videoId)
expect(video, VIDEO_FULL)
})
test("Get details of the first artist result", done => {
ytmusic
.search(query, "ARTIST")
.then(artists => ytmusic.getArtist(artists[0]!.artistId!))
.then(artist => {
_expect(artist, ARTIST_FULL)
done()
})
.catch(done)
it("Get details of the first artist result", async () => {
const artists = await ytmusic.search(query, "ARTIST")
const artist = await ytmusic.getArtist(artists[0]!.artistId)
expect(artist, ARTIST_FULL)
})
test("Get the songs of the first artist result", done => {
ytmusic
.search(query, "ARTIST")
.then(artists => ytmusic.getArtistSongs(artists[0]!.artistId!))
.then(songs => {
_expect(songs, LIST(SONG_DETAILED))
done()
})
.catch(done)
it("Get the songs of the first artist result", async () => {
const artists = await ytmusic.search(query, "ARTIST")
const songs = await ytmusic.getArtistSongs(artists[0]!.artistId)
expect(songs, LIST(SONG_DETAILED))
})
test("Get the albums of the first artist result", done => {
ytmusic
.search(query, "ARTIST")
.then(artists => ytmusic.getArtistAlbums(artists[0]!.artistId!))
.then(albums => {
_expect(albums, LIST(ALBUM_DETAILED))
done()
})
.catch(done)
it("Get the albums of the first artist result", async () => {
const artists = await ytmusic.search(query, "ARTIST")
const albums = await ytmusic.getArtistAlbums(artists[0]!.artistId)
expect(albums, LIST(ALBUM_DETAILED))
})
test("Get details of the first album result", done => {
ytmusic
.search(query, "ALBUM")
.then(albums => ytmusic.getAlbum(albums[0]!.albumId!))
.then(album => {
_expect(album, ALBUM_FULL)
done()
})
.catch(done)
it("Get details of the first album result", async () => {
const albums = await ytmusic.search(query, "ALBUM")
const album = await ytmusic.getAlbum(albums[0]!.albumId)
expect(album, ALBUM_FULL)
})
test("Get details of the first playlist result", done => {
ytmusic
.search(query, "PLAYLIST")
.then(playlists => ytmusic.getPlaylist(playlists[0]!.playlistId!))
.then(playlist => {
_expect(playlist, PLAYLIST_DETAILED)
done()
})
.catch(done)
it("Get details of the first playlist result", async () => {
const playlists = await ytmusic.search(query, "PLAYLIST")
const playlist = await ytmusic.getPlaylist(playlists[0]!.playlistId)
expect(playlist, PLAYLIST_FULL)
})
test("Get the videos of the first playlist result", done => {
ytmusic
.search(query, "PLAYLIST")
.then(playlists => ytmusic.getPlaylistVideos(playlists[0]!.playlistId!))
.then(videos => {
_expect(videos, LIST(PLAYLIST_VIDEO))
done()
})
.catch(done)
it("Get the videos of the first playlist result", async () => {
const playlists = await ytmusic.search(query, "PLAYLIST")
const videos = await ytmusic.getPlaylistVideos(playlists[0]!.playlistId)
expect(videos, LIST(PLAYLIST_VIDEO))
})
})
})
afterAll(() => console.log("Issues:", issues))
after(() => console.log("Issues:", issues))

View File

@ -8,7 +8,7 @@ export interface ThumbnailFull {
export interface SongDetailed {
type: "SONG"
videoId: string | null
videoId: string
name: string
artists: ArtistBasic[]
album: AlbumBasic
@ -24,7 +24,7 @@ export interface SongFull extends Omit<SongDetailed, "album"> {
export interface VideoDetailed {
type: "VIDEO"
videoId: string | null
videoId: string
name: string
artists: ArtistBasic[]
views: number
@ -41,7 +41,7 @@ export interface VideoFull extends VideoDetailed {
}
export interface ArtistBasic {
artistId: string | null
artistId: string
name: string
}
@ -52,7 +52,7 @@ export interface ArtistDetailed extends ArtistBasic {
}
export interface ArtistFull extends ArtistDetailed {
description: string | null
description: string
subscribers: number
topSongs: Omit<SongDetailed, "duration">[]
topAlbums: AlbumDetailed[]
@ -72,7 +72,7 @@ export interface AlbumDetailed extends AlbumBasic {
}
export interface AlbumFull extends AlbumDetailed {
description: string | null
description: string
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 traverse from "../traverse"
import { AlbumDetailed, AlbumFull, ArtistBasic } from ".."
import traverseList from "../utils/traverseList"
import traverseString from "../utils/traverseString"
import { ALBUM_DETAILED, ALBUM_FULL } from "../interfaces"
import { AlbumBasic, AlbumDetailed, AlbumFull, ArtistBasic } from ".."
export default class AlbumParser {
public static parse(data: any, albumId: string): AlbumFull {
const albumBasic = {
const albumBasic: AlbumBasic = {
albumId,
name: traverse(data, "header", "title", "text").at(0)
name: traverseString(data, "header", "title", "text")()
}
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")
const artists: ArtistBasic[] = traverseList(data, "header", "subtitle", "runs")
.filter(run => "navigationEndpoint" in run)
.map(run => ({
artistId: traverseString(run, "browseId")(),
name: traverseString(run, "text")()
}))
const thumbnails = traverseList(data, "header", "thumbnails")
return {
return checkType<AlbumFull>(
{
type: "ALBUM",
...albumBasic,
playlistId: traverse(data, "buttonRenderer", "playlistId"),
playlistId: traverseString(data, "buttonRenderer", "playlistId")(),
artists,
year: +traverse(data, "header", "subtitle", "text").at(-1),
year: +traverseString(data, "header", "subtitle", "text")(-1),
thumbnails,
description: description instanceof Array ? null : description,
songs: [traverse(data, "musicResponsiveListItemRenderer")]
.flat()
.map((item: any) =>
description: traverseString(data, "description", "text")(),
songs: traverseList(data, "musicResponsiveListItemRenderer").map(item =>
SongParser.parseAlbumSong(item, artists, albumBasic, thumbnails)
)
}
},
ALBUM_FULL
)
}
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),
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()
}
albumId: traverseString(item, "browseId")(-1),
playlistId: traverseString(item, "overlay", "playlistId")(),
artists: traverseList(flexColumns[1], "runs")
.filter(run => "navigationEndpoint" in run)
.map(run => ({
artistId: traverseString(run, "browseId")(),
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 {
return {
return checkType<AlbumDetailed>(
{
type: "ALBUM",
albumId: [traverse(item, "browseId")].flat().at(-1),
playlistId: traverse(item, "thumbnailOverlay", "playlistId"),
name: traverse(item, "title", "text").at(0),
albumId: traverseString(item, "browseId")(-1),
playlistId: traverseString(item, "thumbnailOverlay", "playlistId")(),
name: traverseString(item, "title", "text")(),
artists: [artistBasic],
year: +traverse(item, "subtitle", "text").at(-1),
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 {
return {
return checkType<AlbumDetailed>(
{
type: "ALBUM",
albumId: traverse(item, "browseId").at(-1),
playlistId: traverse(item, "musicPlayButtonRenderer", "playlistId"),
name: traverse(item, "title", "text").at(0),
albumId: traverseString(item, "browseId")(-1),
playlistId: traverseString(item, "musicPlayButtonRenderer", "playlistId")(),
name: traverseString(item, "title", "text")(),
artists: [artistBasic],
year: +traverse(item, "subtitle", "text").at(-1),
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 checkType from "../utils/checkType"
import Parser from "./Parser"
import SongParser from "./SongParser"
import traverse from "../traverse"
import { ArtistDetailed, ArtistFull } from ".."
import traverseList from "../utils/traverseList"
import traverseString from "../utils/traverseString"
import { ARTIST_DETAILED, ARTIST_FULL } from "../interfaces"
import { ArtistBasic, ArtistDetailed, ArtistFull } from ".."
export default class ArtistParser {
public static parse(data: any, artistId: string): ArtistFull {
const artistBasic = {
const artistBasic: ArtistBasic = {
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,
thumbnails: [traverse(data, "header", "thumbnails")].flat(),
description: description instanceof Array ? null : description,
subscribers: Parser.parseNumber(traverse(data, "subscriberCountText", "text")),
topSongs: traverse(data, "musicShelfRenderer", "contents").map((item: any) =>
thumbnails: traverseList(data, "header", "thumbnails"),
description,
subscribers: Parser.parseNumber(
traverseString(data, "subscriberCountText", "text")()
),
topSongs: traverseList(data, "musicShelfRenderer", "contents").map(item =>
SongParser.parseArtistTopSong(item, artistBasic)
),
topAlbums: [traverse(data, "musicCarouselShelfRenderer")]
.flat()
topAlbums: traverseList(data, "musicCarouselShelfRenderer")
.at(0)
.contents.map((item: any) => AlbumParser.parseArtistTopAlbums(item, artistBasic))
}
.contents.map((item: any) =>
AlbumParser.parseArtistTopAlbums(item, artistBasic)
)
},
ARTIST_FULL
)
}
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"),
name: traverse(flexColumns[0], "runs", "text"),
thumbnails: [traverse(item, "thumbnails")].flat()
}
artistId: traverseString(item, "browseId")(),
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 ".."
export default class PlaylistParser {
public static parse(data: any, playlistId: string): PlaylistFull {
return {
return checkType<PlaylistFull>(
{
type: "PLAYLIST",
playlistId,
name: traverse(data, "header", "title", "text").at(0),
name: traverseString(data, "header", "title", "text")(),
artist: {
artistId: traverse(data, "header", "subtitle", "browseId"),
name: traverse(data, "header", "subtitle", "text").at(2)
artistId: traverseString(data, "header", "subtitle", "browseId")(),
name: traverseString(data, "header", "subtitle", "text")(2)
},
videoCount: +traverse(data, "header", "secondSubtitle", "text")
videoCount: +traverseList(data, "header", "secondSubtitle", "text")
.at(0)
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: traverse(data, "header", "thumbnails")
}
thumbnails: traverseList(data, "header", "thumbnails")
},
PLAYLIST_FULL
)
}
public static parseSearchResult(item: any): PlaylistFull {
const flexColumns = traverse(item, "flexColumns")
const artistId = traverse(flexColumns[1], "browseId")
const flexColumns = traverseList(item, "flexColumns")
const artistId = traverseString(flexColumns[1], "browseId")()
return {
return checkType<PlaylistFull>(
{
type: "PLAYLIST",
playlistId: traverse(item, "overlay", "playlistId"),
name: traverse(flexColumns[0], "runs", "text"),
playlistId: traverseString(item, "overlay", "playlistId")(),
name: traverseString(flexColumns[0], "runs", "text")(),
artist: {
artistId: artistId instanceof Array ? null : artistId,
name: traverse(flexColumns[1], "runs", "text").at(-2)
artistId,
name: traverseString(flexColumns[1], "runs", "text")(-2)
},
videoCount: +traverse(flexColumns[1], "runs", "text")
videoCount: +traverseList(flexColumns[1], "runs", "text")
.at(-1)
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: [traverse(item, "thumbnails")].flat()
}
thumbnails: traverseList(item, "thumbnails")
},
PLAYLIST_FULL
)
}
}

View File

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

View File

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