✨ use zod instead of arktype
This commit is contained in:
parent
ed5e15c6ec
commit
09f113e7bb
|
|
@ -41,9 +41,10 @@
|
||||||
"noUnreachableSuper": "error",
|
"noUnreachableSuper": "error",
|
||||||
"noUnsafeFinally": "error",
|
"noUnsafeFinally": "error",
|
||||||
"noUnsafeOptionalChaining": "error",
|
"noUnsafeOptionalChaining": "error",
|
||||||
"noUnusedLabels": "error",
|
"noUnusedImports": "warn",
|
||||||
"noUnusedPrivateClassMembers": "error",
|
"noUnusedLabels": "warn",
|
||||||
"noUnusedVariables": "error",
|
"noUnusedPrivateClassMembers": "warn",
|
||||||
|
"noUnusedVariables": "warn",
|
||||||
"useArrayLiterals": "off",
|
"useArrayLiterals": "off",
|
||||||
"useIsNan": "error",
|
"useIsNan": "error",
|
||||||
"useValidForDirection": "error",
|
"useValidForDirection": "error",
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,20 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --dts --format cjs,esm --clean --out-dir dist",
|
"build": "tsup src/index.ts --dts --format cjs,esm --clean --out-dir dist",
|
||||||
"lint": "tsc --noEmit && rm tsconfig.tsbuildinfo ; biome format --write"
|
"lint": "tsc --noEmit ; bunx @biomejs/biome check --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@biomejs/biome": "1.8.3",
|
"@biomejs/biome": "1.8.3",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"tough-cookie": "^4.1.4"
|
"tough-cookie": "^4.1.4",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/tough-cookie": "^4.0.5",
|
"@types/tough-cookie": "^4.0.5",
|
||||||
"bun-types": "^1.1.18",
|
"bun-types": "^1.1.18",
|
||||||
"tsup": "^8.1.0",
|
"tsup": "^8.1.0",
|
||||||
"typescript": "5.1"
|
"typescript": "5.1",
|
||||||
|
"zod-to-json-schema": "^3.23.1"
|
||||||
},
|
},
|
||||||
"keywords": ["youtube", "music", "api"]
|
"keywords": ["youtube", "music", "api"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import { type, union } from "arktype"
|
|
||||||
|
|
||||||
export type ThumbnailFull = typeof ThumbnailFull.infer
|
|
||||||
export const ThumbnailFull = type({
|
|
||||||
url: "string",
|
|
||||||
width: "number",
|
|
||||||
height: "number",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ArtistBasic = typeof ArtistBasic.infer
|
|
||||||
export const ArtistBasic = type({
|
|
||||||
artistId: "string|null",
|
|
||||||
name: "string",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type AlbumBasic = typeof AlbumBasic.infer
|
|
||||||
export const AlbumBasic = type({
|
|
||||||
albumId: "string",
|
|
||||||
name: "string",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SongDetailed = typeof SongDetailed.infer
|
|
||||||
export const SongDetailed = type({
|
|
||||||
type: '"SONG"',
|
|
||||||
videoId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
album: union(AlbumBasic, "null"),
|
|
||||||
duration: "number|null",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type VideoDetailed = typeof VideoDetailed.infer
|
|
||||||
export const VideoDetailed = type({
|
|
||||||
type: '"VIDEO"',
|
|
||||||
videoId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
duration: "number|null",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ArtistDetailed = typeof ArtistDetailed.infer
|
|
||||||
export const ArtistDetailed = type({
|
|
||||||
artistId: "string",
|
|
||||||
name: "string",
|
|
||||||
type: '"ARTIST"',
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type AlbumDetailed = typeof AlbumDetailed.infer
|
|
||||||
export const AlbumDetailed = type({
|
|
||||||
type: '"ALBUM"',
|
|
||||||
albumId: "string",
|
|
||||||
playlistId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
year: "number|null",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type PlaylistDetailed = typeof PlaylistDetailed.infer
|
|
||||||
export const PlaylistDetailed = type({
|
|
||||||
type: '"PLAYLIST"',
|
|
||||||
playlistId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SongFull = typeof SongFull.infer
|
|
||||||
export const SongFull = type({
|
|
||||||
type: '"SONG"',
|
|
||||||
videoId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
duration: "number",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
formats: "any[]",
|
|
||||||
adaptiveFormats: "any[]",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type VideoFull = typeof VideoFull.infer
|
|
||||||
export const VideoFull = type({
|
|
||||||
type: '"VIDEO"',
|
|
||||||
videoId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
duration: "number",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
unlisted: "boolean",
|
|
||||||
familySafe: "boolean",
|
|
||||||
paid: "boolean",
|
|
||||||
tags: "string[]",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ArtistFull = typeof ArtistFull.infer
|
|
||||||
export const ArtistFull = type({
|
|
||||||
artistId: "string",
|
|
||||||
name: "string",
|
|
||||||
type: '"ARTIST"',
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
topSongs: [SongDetailed, "[]"],
|
|
||||||
topAlbums: [AlbumDetailed, "[]"],
|
|
||||||
topSingles: [AlbumDetailed, "[]"],
|
|
||||||
topVideos: [VideoDetailed, "[]"],
|
|
||||||
featuredOn: [PlaylistDetailed, "[]"],
|
|
||||||
similarArtists: [ArtistDetailed, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type AlbumFull = typeof AlbumFull.infer
|
|
||||||
export const AlbumFull = type({
|
|
||||||
type: '"ALBUM"',
|
|
||||||
albumId: "string",
|
|
||||||
playlistId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
year: "number|null",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
songs: [SongDetailed, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type PlaylistFull = typeof PlaylistFull.infer
|
|
||||||
export const PlaylistFull = type({
|
|
||||||
type: '"PLAYLIST"',
|
|
||||||
playlistId: "string",
|
|
||||||
name: "string",
|
|
||||||
artist: ArtistBasic,
|
|
||||||
videoCount: "number",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type SearchResult = typeof SearchResult.infer
|
|
||||||
export const SearchResult = union(
|
|
||||||
SongDetailed,
|
|
||||||
union(VideoDetailed, union(AlbumDetailed, union(ArtistDetailed, PlaylistDetailed))),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type PlaylistWatch = typeof PlaylistWatch.infer
|
|
||||||
export const PlaylistWatch = type({
|
|
||||||
type: '"PLAYLIST"',
|
|
||||||
playlistId: "string",
|
|
||||||
name: "string",
|
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
|
||||||
})
|
|
||||||
|
|
||||||
export type HomePageContent = typeof HomePageContent.infer
|
|
||||||
export const HomePageContent = type({
|
|
||||||
title: "string",
|
|
||||||
contents: [
|
|
||||||
union(
|
|
||||||
PlaylistWatch,
|
|
||||||
union(ArtistDetailed, union(AlbumDetailed, union(PlaylistDetailed, SongDetailed))),
|
|
||||||
),
|
|
||||||
"[]",
|
|
||||||
],
|
|
||||||
})
|
|
||||||
123
src/YTMusic.ts
123
src/YTMusic.ts
|
|
@ -1,20 +1,6 @@
|
||||||
import axios, { AxiosInstance } from "axios"
|
import axios, { AxiosInstance } from "axios"
|
||||||
import { Cookie, CookieJar } from "tough-cookie"
|
import { Cookie, CookieJar } from "tough-cookie"
|
||||||
|
|
||||||
import {
|
|
||||||
AlbumDetailed,
|
|
||||||
AlbumFull,
|
|
||||||
ArtistDetailed,
|
|
||||||
ArtistFull,
|
|
||||||
HomePageContent,
|
|
||||||
PlaylistDetailed,
|
|
||||||
PlaylistFull,
|
|
||||||
SearchResult,
|
|
||||||
SongDetailed,
|
|
||||||
SongFull,
|
|
||||||
VideoDetailed,
|
|
||||||
VideoFull,
|
|
||||||
} from "./@types/types"
|
|
||||||
import { FE_MUSIC_HOME } from "./constants"
|
import { FE_MUSIC_HOME } from "./constants"
|
||||||
import AlbumParser from "./parsers/AlbumParser"
|
import AlbumParser from "./parsers/AlbumParser"
|
||||||
import ArtistParser from "./parsers/ArtistParser"
|
import ArtistParser from "./parsers/ArtistParser"
|
||||||
|
|
@ -23,8 +9,24 @@ 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 VideoParser from "./parsers/VideoParser"
|
import VideoParser from "./parsers/VideoParser"
|
||||||
|
import {
|
||||||
|
AlbumDetailed,
|
||||||
|
AlbumFull,
|
||||||
|
ArtistDetailed,
|
||||||
|
ArtistFull,
|
||||||
|
HomeSection,
|
||||||
|
PlaylistDetailed,
|
||||||
|
PlaylistFull,
|
||||||
|
SearchResult,
|
||||||
|
SongDetailed,
|
||||||
|
SongFull,
|
||||||
|
VideoDetailed,
|
||||||
|
VideoFull,
|
||||||
|
} from "./types"
|
||||||
import { traverse, traverseList, traverseString } from "./utils/traverse"
|
import { traverse, traverseList, traverseString } from "./utils/traverse"
|
||||||
|
|
||||||
|
axios.defaults.headers.common["Accept-Encoding"] = "gzip"
|
||||||
|
|
||||||
export default class YTMusic {
|
export default class YTMusic {
|
||||||
private cookiejar: CookieJar
|
private cookiejar: CookieJar
|
||||||
private config?: Record<string, string>
|
private config?: Record<string, string>
|
||||||
|
|
@ -48,31 +50,27 @@ export default class YTMusic {
|
||||||
})
|
})
|
||||||
|
|
||||||
this.client.interceptors.request.use(req => {
|
this.client.interceptors.request.use(req => {
|
||||||
if (!req.baseURL) return
|
if (req.baseURL) {
|
||||||
|
|
||||||
const cookieString = this.cookiejar.getCookieStringSync(req.baseURL)
|
const cookieString = this.cookiejar.getCookieStringSync(req.baseURL)
|
||||||
if (cookieString) {
|
if (cookieString) {
|
||||||
if (!req.headers) {
|
req.headers["cookie"] = cookieString
|
||||||
req.headers = {}
|
|
||||||
}
|
}
|
||||||
req.headers["Cookie"] = cookieString
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return req
|
return req
|
||||||
})
|
})
|
||||||
|
|
||||||
this.client.interceptors.response.use(res => {
|
this.client.interceptors.response.use(res => {
|
||||||
if ("set-cookie" in res.headers) {
|
if (res.headers && res.config.baseURL) {
|
||||||
if (!res.config.baseURL) return
|
const cookieStrings = res.headers["set-cookie"] || []
|
||||||
|
for (const cookieString of cookieStrings) {
|
||||||
const setCookie = res.headers["set-cookie"] as Array<string> | string
|
const cookie = Cookie.parse(cookieString)
|
||||||
for (const cookieString of [setCookie].flat()) {
|
if (cookie) {
|
||||||
const cookie = Cookie.parse(`${cookieString}`)
|
|
||||||
if (!cookie) return
|
|
||||||
|
|
||||||
this.cookiejar.setCookieSync(cookie, res.config.baseURL)
|
this.cookiejar.setCookieSync(cookie, res.config.baseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -80,7 +78,11 @@ export default class YTMusic {
|
||||||
/**
|
/**
|
||||||
* Initializes the API
|
* Initializes the API
|
||||||
*/
|
*/
|
||||||
public async initialize(options?: { cookies?: string; GL?: string; HL?: string }) {
|
public async initialize(options?: {
|
||||||
|
cookies?: string
|
||||||
|
GL?: string
|
||||||
|
HL?: string
|
||||||
|
}) {
|
||||||
const { cookies, GL, HL } = options ?? {}
|
const { cookies, GL, HL } = options ?? {}
|
||||||
|
|
||||||
if (cookies) {
|
if (cookies) {
|
||||||
|
|
@ -235,7 +237,7 @@ export default class YTMusic {
|
||||||
*
|
*
|
||||||
* @param query Query string
|
* @param query Query string
|
||||||
*/
|
*/
|
||||||
public async search(query: string): Promise<(typeof SearchResult.infer)[]> {
|
public async search(query: string): Promise<SearchResult[]> {
|
||||||
const searchData = await this.constructRequest("search", {
|
const searchData = await this.constructRequest("search", {
|
||||||
query,
|
query,
|
||||||
params: null,
|
params: null,
|
||||||
|
|
@ -243,7 +245,7 @@ export default class YTMusic {
|
||||||
|
|
||||||
return traverseList(searchData, "musicResponsiveListItemRenderer")
|
return traverseList(searchData, "musicResponsiveListItemRenderer")
|
||||||
.map(SearchParser.parse)
|
.map(SearchParser.parse)
|
||||||
.filter(Boolean) as (typeof SearchResult.infer)[]
|
.filter(Boolean) as SearchResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -251,7 +253,7 @@ export default class YTMusic {
|
||||||
*
|
*
|
||||||
* @param query Query string
|
* @param query Query string
|
||||||
*/
|
*/
|
||||||
public async searchSongs(query: string): Promise<(typeof SongDetailed.infer)[]> {
|
public async searchSongs(query: string): Promise<SongDetailed[]> {
|
||||||
const searchData = await this.constructRequest("search", {
|
const searchData = await this.constructRequest("search", {
|
||||||
query,
|
query,
|
||||||
params: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D",
|
params: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D",
|
||||||
|
|
@ -267,7 +269,7 @@ export default class YTMusic {
|
||||||
*
|
*
|
||||||
* @param query Query string
|
* @param query Query string
|
||||||
*/
|
*/
|
||||||
public async searchVideos(query: string): Promise<(typeof VideoDetailed.infer)[]> {
|
public async searchVideos(query: string): Promise<VideoDetailed[]> {
|
||||||
const searchData = await this.constructRequest("search", {
|
const searchData = await this.constructRequest("search", {
|
||||||
query,
|
query,
|
||||||
params: "Eg-KAQwIABABGAAgACgAMABqChAEEAMQCRAFEAo%3D",
|
params: "Eg-KAQwIABABGAAgACgAMABqChAEEAMQCRAFEAo%3D",
|
||||||
|
|
@ -283,7 +285,7 @@ export default class YTMusic {
|
||||||
*
|
*
|
||||||
* @param query Query string
|
* @param query Query string
|
||||||
*/
|
*/
|
||||||
public async searchArtists(query: string): Promise<(typeof ArtistDetailed.infer)[]> {
|
public async searchArtists(query: string): Promise<ArtistDetailed[]> {
|
||||||
const searchData = await this.constructRequest("search", {
|
const searchData = await this.constructRequest("search", {
|
||||||
query,
|
query,
|
||||||
params: "Eg-KAQwIABAAGAAgASgAMABqChAEEAMQCRAFEAo%3D",
|
params: "Eg-KAQwIABAAGAAgASgAMABqChAEEAMQCRAFEAo%3D",
|
||||||
|
|
@ -299,7 +301,7 @@ export default class YTMusic {
|
||||||
*
|
*
|
||||||
* @param query Query string
|
* @param query Query string
|
||||||
*/
|
*/
|
||||||
public async searchAlbums(query: string): Promise<(typeof AlbumDetailed.infer)[]> {
|
public async searchAlbums(query: string): Promise<AlbumDetailed[]> {
|
||||||
const searchData = await this.constructRequest("search", {
|
const searchData = await this.constructRequest("search", {
|
||||||
query,
|
query,
|
||||||
params: "Eg-KAQwIABAAGAEgACgAMABqChAEEAMQCRAFEAo%3D",
|
params: "Eg-KAQwIABAAGAEgACgAMABqChAEEAMQCRAFEAo%3D",
|
||||||
|
|
@ -315,7 +317,7 @@ export default class YTMusic {
|
||||||
*
|
*
|
||||||
* @param query Query string
|
* @param query Query string
|
||||||
*/
|
*/
|
||||||
public async searchPlaylists(query: string): Promise<(typeof PlaylistDetailed.infer)[]> {
|
public async searchPlaylists(query: string): Promise<PlaylistDetailed[]> {
|
||||||
const searchData = await this.constructRequest("search", {
|
const searchData = await this.constructRequest("search", {
|
||||||
query,
|
query,
|
||||||
params: "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D",
|
params: "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D",
|
||||||
|
|
@ -332,7 +334,7 @@ export default class YTMusic {
|
||||||
* @param videoId Video ID
|
* @param videoId Video ID
|
||||||
* @returns Song Data
|
* @returns Song Data
|
||||||
*/
|
*/
|
||||||
public async getSong(videoId: string): Promise<typeof SongFull.infer> {
|
public async getSong(videoId: string): Promise<SongFull> {
|
||||||
if (!videoId.match(/^[a-zA-Z0-9-_]{11}$/)) throw new Error("Invalid videoId")
|
if (!videoId.match(/^[a-zA-Z0-9-_]{11}$/)) throw new Error("Invalid videoId")
|
||||||
const data = await this.constructRequest("player", { videoId })
|
const data = await this.constructRequest("player", { videoId })
|
||||||
|
|
||||||
|
|
@ -347,7 +349,7 @@ export default class YTMusic {
|
||||||
* @param videoId Video ID
|
* @param videoId Video ID
|
||||||
* @returns Video Data
|
* @returns Video Data
|
||||||
*/
|
*/
|
||||||
public async getVideo(videoId: string): Promise<typeof VideoFull.infer> {
|
public async getVideo(videoId: string): Promise<VideoFull> {
|
||||||
if (!videoId.match(/^[a-zA-Z0-9-_]{11}$/)) throw new Error("Invalid videoId")
|
if (!videoId.match(/^[a-zA-Z0-9-_]{11}$/)) throw new Error("Invalid videoId")
|
||||||
const data = await this.constructRequest("player", { videoId })
|
const data = await this.constructRequest("player", { videoId })
|
||||||
|
|
||||||
|
|
@ -384,7 +386,7 @@ export default class YTMusic {
|
||||||
* @param artistId Artist ID
|
* @param artistId Artist ID
|
||||||
* @returns Artist Data
|
* @returns Artist Data
|
||||||
*/
|
*/
|
||||||
public async getArtist(artistId: string): Promise<typeof ArtistFull.infer> {
|
public async getArtist(artistId: string): Promise<ArtistFull> {
|
||||||
const data = await this.constructRequest("browse", {
|
const data = await this.constructRequest("browse", {
|
||||||
browseId: artistId,
|
browseId: artistId,
|
||||||
})
|
})
|
||||||
|
|
@ -398,13 +400,17 @@ export default class YTMusic {
|
||||||
* @param artistId Artist ID
|
* @param artistId Artist ID
|
||||||
* @returns Artist's Songs
|
* @returns Artist's Songs
|
||||||
*/
|
*/
|
||||||
public async getArtistSongs(artistId: string): Promise<(typeof SongDetailed.infer)[]> {
|
public async getArtistSongs(artistId: string): Promise<SongDetailed[]> {
|
||||||
const artistData = await this.constructRequest("browse", { browseId: artistId })
|
const artistData = await this.constructRequest("browse", {
|
||||||
|
browseId: artistId,
|
||||||
|
})
|
||||||
const browseToken = traverse(artistData, "musicShelfRenderer", "title", "browseId")
|
const browseToken = traverse(artistData, "musicShelfRenderer", "title", "browseId")
|
||||||
|
|
||||||
if (browseToken instanceof Array) return []
|
if (browseToken instanceof Array) return []
|
||||||
|
|
||||||
const songsData = await this.constructRequest("browse", { browseId: browseToken })
|
const songsData = await this.constructRequest("browse", {
|
||||||
|
browseId: browseToken,
|
||||||
|
})
|
||||||
const continueToken = traverse(songsData, "continuation")
|
const continueToken = traverse(songsData, "continuation")
|
||||||
const moreSongsData = await this.constructRequest(
|
const moreSongsData = await this.constructRequest(
|
||||||
"browse",
|
"browse",
|
||||||
|
|
@ -429,7 +435,7 @@ export default class YTMusic {
|
||||||
* @param artistId Artist ID
|
* @param artistId Artist ID
|
||||||
* @returns Artist's Albums
|
* @returns Artist's Albums
|
||||||
*/
|
*/
|
||||||
public async getArtistAlbums(artistId: string): Promise<(typeof AlbumDetailed.infer)[]> {
|
public async getArtistAlbums(artistId: string): Promise<AlbumDetailed[]> {
|
||||||
const artistData = await this.constructRequest("browse", {
|
const artistData = await this.constructRequest("browse", {
|
||||||
browseId: artistId,
|
browseId: artistId,
|
||||||
})
|
})
|
||||||
|
|
@ -452,7 +458,7 @@ export default class YTMusic {
|
||||||
* @param albumId Album ID
|
* @param albumId Album ID
|
||||||
* @returns Album Data
|
* @returns Album Data
|
||||||
*/
|
*/
|
||||||
public async getAlbum(albumId: string): Promise<typeof AlbumFull.infer> {
|
public async getAlbum(albumId: string): Promise<AlbumFull> {
|
||||||
const data = await this.constructRequest("browse", {
|
const data = await this.constructRequest("browse", {
|
||||||
browseId: albumId,
|
browseId: albumId,
|
||||||
})
|
})
|
||||||
|
|
@ -466,7 +472,7 @@ export default class YTMusic {
|
||||||
* @param playlistId Playlist ID
|
* @param playlistId Playlist ID
|
||||||
* @returns Playlist Data
|
* @returns Playlist Data
|
||||||
*/
|
*/
|
||||||
public async getPlaylist(playlistId: string): Promise<typeof PlaylistFull.infer> {
|
public async getPlaylist(playlistId: string): Promise<PlaylistFull> {
|
||||||
if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
|
if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
|
||||||
const data = await this.constructRequest("browse", {
|
const data = await this.constructRequest("browse", {
|
||||||
browseId: playlistId,
|
browseId: playlistId,
|
||||||
|
|
@ -481,7 +487,7 @@ export default class YTMusic {
|
||||||
* @param playlistId Playlist ID
|
* @param playlistId Playlist ID
|
||||||
* @returns Playlist's Videos
|
* @returns Playlist's Videos
|
||||||
*/
|
*/
|
||||||
public async getPlaylistVideos(playlistId: string): Promise<(typeof VideoDetailed.infer)[]> {
|
public async getPlaylistVideos(playlistId: string): Promise<VideoDetailed[]> {
|
||||||
if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
|
if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId
|
||||||
const playlistData = await this.constructRequest("browse", {
|
const playlistData = await this.constructRequest("browse", {
|
||||||
browseId: playlistId,
|
browseId: playlistId,
|
||||||
|
|
@ -503,28 +509,23 @@ export default class YTMusic {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get content for the home page.
|
* Get sections for the home page.
|
||||||
*
|
*
|
||||||
* @returns Mixed HomePageContent
|
* @returns Mixed HomeSection
|
||||||
*/
|
*/
|
||||||
public async getHome(): Promise<HomePageContent[]> {
|
public async getHomeSections(): Promise<HomeSection[]> {
|
||||||
const results: HomePageContent[] = []
|
const data = await this.constructRequest("browse", {
|
||||||
const page = await this.constructRequest("browse", { browseId: FE_MUSIC_HOME })
|
browseId: FE_MUSIC_HOME,
|
||||||
traverseList(page, "sectionListRenderer", "contents").forEach(content => {
|
|
||||||
const parsed = Parser.parseMixedContent(content)
|
|
||||||
parsed && results.push(parsed)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let continuation = traverseString(page, "continuation")
|
const sections = traverseList("sectionListRenderer", "contents")
|
||||||
|
let continuation = traverseString(data, "continuation")
|
||||||
while (continuation) {
|
while (continuation) {
|
||||||
const nextPage = await this.constructRequest("browse", {}, { continuation })
|
const data = await this.constructRequest("browse", {}, { continuation })
|
||||||
traverseList(nextPage, "sectionListContinuation", "contents").forEach(content => {
|
sections.push(...traverseList(data, "sectionListContinuation", "contents"))
|
||||||
const parsed = Parser.parseMixedContent(content)
|
continuation = traverseString(data, "continuation")
|
||||||
parsed && results.push(parsed)
|
|
||||||
})
|
|
||||||
continuation = traverseString(nextPage, "continuation")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return sections.map(Parser.parseHomeSection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export enum PageType {
|
export enum PageType {
|
||||||
MUSIC_PAGE_TYPE_ALBUM = "MUSIC_PAGE_TYPE_ALBUM",
|
MUSIC_PAGE_TYPE_ALBUM = "MUSIC_PAGE_TYPE_ALBUM",
|
||||||
MUSIC_PAGE_TYPE_ARTIST = "MUSIC_PAGE_TYPE_ARTIST",
|
|
||||||
MUSIC_PAGE_TYPE_PLAYLIST = "MUSIC_PAGE_TYPE_PLAYLIST",
|
MUSIC_PAGE_TYPE_PLAYLIST = "MUSIC_PAGE_TYPE_PLAYLIST",
|
||||||
|
MUSIC_VIDEO_TYPE_OMV = "MUSIC_VIDEO_TYPE_OMV",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FE_MUSIC_HOME = "FEmusic_home"
|
export const FE_MUSIC_HOME = "FEmusic_home"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import YTMusic from "./YTMusic"
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AlbumBasic,
|
AlbumBasic,
|
||||||
AlbumDetailed,
|
AlbumDetailed,
|
||||||
|
|
@ -15,6 +13,7 @@ export type {
|
||||||
ThumbnailFull,
|
ThumbnailFull,
|
||||||
VideoDetailed,
|
VideoDetailed,
|
||||||
VideoFull,
|
VideoFull,
|
||||||
} from "./@types/types"
|
HomeSection,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
export default YTMusic
|
export { default as YTMusic } from "./YTMusic"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { AlbumBasic, AlbumDetailed, AlbumFull, ArtistBasic } from "../@types/types"
|
import { AlbumBasic, AlbumDetailed, AlbumFull, ArtistBasic } from "../types"
|
||||||
import checkType from "../utils/checkType"
|
import checkType from "../utils/checkType"
|
||||||
import { isArtist } from "../utils/filters"
|
import { isArtist } from "../utils/filters"
|
||||||
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
||||||
|
|
@ -8,25 +8,25 @@ export default class AlbumParser {
|
||||||
public static parse(data: any, albumId: string): AlbumFull {
|
public static parse(data: any, albumId: string): AlbumFull {
|
||||||
const albumBasic: AlbumBasic = {
|
const albumBasic: AlbumBasic = {
|
||||||
albumId,
|
albumId,
|
||||||
name: traverseString(data, "header", "title", "text"),
|
name: traverseString(data, "tabs", "title", "text"),
|
||||||
}
|
}
|
||||||
|
|
||||||
const artistData = traverse(data, "header", "subtitle", "runs")
|
const artistData = traverse(data, "tabs", "straplineTextOne", "runs")
|
||||||
const artistBasic: ArtistBasic = {
|
const artistBasic: ArtistBasic = {
|
||||||
artistId: traverseString(artistData, "browseId") || null,
|
artistId: traverseString(artistData, "browseId") || null,
|
||||||
name: traverseString(artistData, "text"),
|
name: traverseString(artistData, "text"),
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnails = traverseList(data, "header", "thumbnails")
|
const thumbnails = traverseList(data, "background", "thumbnails")
|
||||||
|
|
||||||
return checkType(
|
return checkType(
|
||||||
{
|
{
|
||||||
type: "ALBUM",
|
type: "ALBUM",
|
||||||
...albumBasic,
|
...albumBasic,
|
||||||
playlistId: traverseString(data, "buttonRenderer", "playlistId"),
|
playlistId: traverseString(data, "musicPlayButtonRenderer", "playlistId"),
|
||||||
artist: artistBasic,
|
artist: artistBasic,
|
||||||
year: AlbumParser.processYear(
|
year: AlbumParser.processYear(
|
||||||
traverseList(data, "header", "subtitle", "text").at(-1),
|
traverseList(data, "tabs", "subtitle", "text").at(-1),
|
||||||
),
|
),
|
||||||
thumbnails,
|
thumbnails,
|
||||||
songs: traverseList(data, "musicResponsiveListItemRenderer").map(item =>
|
songs: traverseList(data, "musicResponsiveListItemRenderer").map(item =>
|
||||||
|
|
@ -94,6 +94,26 @@ export default class AlbumParser {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static parseHomeSection(item: any): AlbumDetailed {
|
||||||
|
const artist = traverse(item, "subtitle", "runs").at(-1)
|
||||||
|
|
||||||
|
return checkType(
|
||||||
|
{
|
||||||
|
type: "ALBUM",
|
||||||
|
albumId: traverseString(item, "title", "browseId"),
|
||||||
|
playlistId: traverseString(item, "thumbnailOverlay", "playlistId"),
|
||||||
|
name: traverseString(item, "title", "text"),
|
||||||
|
artist: {
|
||||||
|
name: traverseString(artist, "text"),
|
||||||
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
|
},
|
||||||
|
year: null,
|
||||||
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
|
},
|
||||||
|
AlbumDetailed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static processYear(year: string) {
|
private static processYear(year: string) {
|
||||||
return year && year.match(/^\d{4}$/) ? +year : null
|
return year && year.match(/^\d{4}$/) ? +year : null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArtistDetailed, ArtistFull } from "../@types/types"
|
import { ArtistDetailed, ArtistFull } from "../types"
|
||||||
import checkType from "../utils/checkType"
|
import checkType from "../utils/checkType"
|
||||||
import { traverseList, traverseString } from "../utils/traverse"
|
import { traverseList, traverseString } from "../utils/traverse"
|
||||||
import AlbumParser from "./AlbumParser"
|
import AlbumParser from "./AlbumParser"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { HomePageContent } from "../@types/types"
|
|
||||||
import { PageType } from "../constants"
|
import { PageType } from "../constants"
|
||||||
import { traverse, traverseList } from "../utils/traverse"
|
import { AlbumDetailed, HomeSection } from "../types"
|
||||||
|
import checkType from "../utils/checkType"
|
||||||
|
import { traverseList, traverseString } from "../utils/traverse"
|
||||||
import AlbumParser from "./AlbumParser"
|
import AlbumParser from "./AlbumParser"
|
||||||
import ArtistParser from "./ArtistParser"
|
|
||||||
import PlaylistParser from "./PlaylistParser"
|
import PlaylistParser from "./PlaylistParser"
|
||||||
import SongParser from "./SongParser"
|
import SongParser from "./SongParser"
|
||||||
|
|
||||||
|
|
@ -36,91 +36,36 @@ export default class Parser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static parseHomeSection(data: any): HomeSection {
|
||||||
* Parses mixed content data into a structured `HomePageContent` object.
|
const pageType = traverseString(data, "contents", "title", "browseEndpoint", "pageType")
|
||||||
*
|
const playlistId = traverseString(
|
||||||
* This static method takes raw data of mixed content types and attempts to parse it into a
|
data,
|
||||||
* more structured format suitable for use as home page content. It supports multiple content
|
|
||||||
* types such as music descriptions, artists, albums, playlists, and songs.
|
|
||||||
*
|
|
||||||
* @param {any} data - The raw data to be parsed.
|
|
||||||
* @returns {HomePageContent | null} A `HomePageContent` object if parsing is successful, or null otherwise.
|
|
||||||
*/
|
|
||||||
public static parseMixedContent(data: any): HomePageContent | null {
|
|
||||||
const key = Object.keys(data)[0]
|
|
||||||
if (!key) throw new Error("Invalid content")
|
|
||||||
|
|
||||||
const result = data[key]
|
|
||||||
const musicDescriptionShelfRenderer = traverse(result, "musicDescriptionShelfRenderer")
|
|
||||||
|
|
||||||
if (musicDescriptionShelfRenderer && !Array.isArray(musicDescriptionShelfRenderer)) {
|
|
||||||
return {
|
|
||||||
title: traverse(musicDescriptionShelfRenderer, "header", "title", "text"),
|
|
||||||
contents: traverseList(
|
|
||||||
musicDescriptionShelfRenderer,
|
|
||||||
"description",
|
|
||||||
"runs",
|
|
||||||
"text",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(result.contents)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = traverse(result, "header", "title", "text")
|
|
||||||
const contents: HomePageContent["contents"] = []
|
|
||||||
result.contents.forEach((content: any) => {
|
|
||||||
const musicTwoRowItemRenderer = traverse(content, "musicTwoRowItemRenderer")
|
|
||||||
if (musicTwoRowItemRenderer && !Array.isArray(musicTwoRowItemRenderer)) {
|
|
||||||
const pageType = traverse(
|
|
||||||
result,
|
|
||||||
"navigationEndpoint",
|
|
||||||
"browseEndpoint",
|
|
||||||
"browseEndpointContextSupportedConfigs",
|
|
||||||
"browseEndpointContextMusicConfig",
|
|
||||||
"pageType",
|
|
||||||
)
|
|
||||||
const playlistId = traverse(
|
|
||||||
content,
|
|
||||||
"navigationEndpoint",
|
"navigationEndpoint",
|
||||||
"watchPlaylistEndpoint",
|
"watchPlaylistEndpoint",
|
||||||
"playlistId",
|
"playlistId",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return checkType(
|
||||||
|
{
|
||||||
|
title: traverseString(data, "header", "title", "text"),
|
||||||
|
contents: traverseList(data, "contents").map(item => {
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case PageType.MUSIC_PAGE_TYPE_ARTIST:
|
|
||||||
contents.push(ArtistParser.parseSearchResult(content))
|
|
||||||
break
|
|
||||||
case PageType.MUSIC_PAGE_TYPE_ALBUM:
|
case PageType.MUSIC_PAGE_TYPE_ALBUM:
|
||||||
contents.push(AlbumParser.parseSearchResult(content))
|
return AlbumParser.parseHomeSection(item)
|
||||||
break
|
|
||||||
case PageType.MUSIC_PAGE_TYPE_PLAYLIST:
|
case PageType.MUSIC_PAGE_TYPE_PLAYLIST:
|
||||||
contents.push(PlaylistParser.parseSearchResult(content))
|
return PlaylistParser.parseHomeSection(item)
|
||||||
break
|
case "":
|
||||||
default:
|
|
||||||
if (playlistId) {
|
if (playlistId) {
|
||||||
contents.push(PlaylistParser.parseWatchPlaylist(content))
|
return PlaylistParser.parseHomeSection(item)
|
||||||
} else {
|
} else {
|
||||||
contents.push(SongParser.parseSearchResult(content))
|
return SongParser.parseHomeSection(item)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
return null as unknown as AlbumDetailed
|
||||||
}
|
}
|
||||||
} else {
|
}),
|
||||||
const musicResponsiveListItemRenderer = traverse(
|
},
|
||||||
content,
|
HomeSection,
|
||||||
"musicResponsiveListItemRenderer",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
|
||||||
musicResponsiveListItemRenderer &&
|
|
||||||
!Array.isArray(musicResponsiveListItemRenderer)
|
|
||||||
) {
|
|
||||||
contents.push(SongParser.parseSearchResult(musicResponsiveListItemRenderer))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { title, contents }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
import { ArtistBasic, PlaylistDetailed, PlaylistFull, PlaylistWatch } from "../@types/types"
|
import { ArtistBasic, PlaylistDetailed, PlaylistFull } from "../types"
|
||||||
import checkType from "../utils/checkType"
|
import checkType from "../utils/checkType"
|
||||||
import { isArtist } from "../utils/filters"
|
import { isArtist } from "../utils/filters"
|
||||||
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
||||||
|
|
||||||
export default class PlaylistParser {
|
export default class PlaylistParser {
|
||||||
public static parse(data: any, playlistId: string): PlaylistFull {
|
public static parse(data: any, playlistId: string): PlaylistFull {
|
||||||
const artist = traverse(data, "header", "subtitle")
|
const artist = traverse(data, "tabs", "straplineTextOne")
|
||||||
|
|
||||||
return checkType(
|
return checkType(
|
||||||
{
|
{
|
||||||
type: "PLAYLIST",
|
type: "PLAYLIST",
|
||||||
playlistId,
|
playlistId,
|
||||||
name: traverseString(data, "header", "title", "text"),
|
name: traverseString(data, "tabs", "title", "text"),
|
||||||
artist: {
|
artist: {
|
||||||
name: traverseString(artist, "text"),
|
name: traverseString(artist, "text"),
|
||||||
artistId: traverseString(artist, "browseId") || null,
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
},
|
},
|
||||||
videoCount:
|
videoCount:
|
||||||
+traverseList(data, "header", "secondSubtitle", "text")
|
+traverseList(data, "tabs", "secondSubtitle", "text")
|
||||||
.at(2)
|
.at(2)
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.at(0)
|
.at(0)
|
||||||
.replaceAll(",", "") ?? null,
|
.replaceAll(",", "") ?? null,
|
||||||
thumbnails: traverseList(data, "header", "thumbnails"),
|
thumbnails: traverseList(data, "tabs", "thumbnails"),
|
||||||
},
|
},
|
||||||
PlaylistFull,
|
PlaylistFull,
|
||||||
)
|
)
|
||||||
|
|
@ -63,15 +63,21 @@ export default class PlaylistParser {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static parseWatchPlaylist(item: any): PlaylistWatch {
|
public static parseHomeSection(item: any): PlaylistDetailed {
|
||||||
|
const artist = traverse(item, "subtitle", "runs")
|
||||||
|
|
||||||
return checkType(
|
return checkType(
|
||||||
{
|
{
|
||||||
type: "PLAYLIST",
|
type: "PLAYLIST",
|
||||||
playlistId: traverseString(item, "navigationEndpoint", "playlistId"),
|
playlistId: traverseString(item, "navigationEndpoint", "playlistId"),
|
||||||
name: traverseString(item, "runs", "text"),
|
name: traverseString(item, "runs", "text"),
|
||||||
|
artist: {
|
||||||
|
name: traverseString(artist, "text"),
|
||||||
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
|
},
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
},
|
},
|
||||||
PlaylistWatch,
|
PlaylistDetailed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { SearchResult } from "../@types/types"
|
import { SearchResult } from "../types"
|
||||||
import { traverseList } from "../utils/traverse"
|
import { traverseList } from "../utils/traverse"
|
||||||
import AlbumParser from "./AlbumParser"
|
import AlbumParser from "./AlbumParser"
|
||||||
import ArtistParser from "./ArtistParser"
|
import ArtistParser from "./ArtistParser"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from "../@types/types"
|
import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from "../types"
|
||||||
import checkType from "../utils/checkType"
|
import checkType from "../utils/checkType"
|
||||||
import { isAlbum, isArtist, isDuration, isTitle } from "../utils/filters"
|
import { isAlbum, isArtist, isDuration, isTitle } from "../utils/filters"
|
||||||
import { traverseList, traverseString } from "../utils/traverse"
|
import { traverseList, traverseString } from "../utils/traverse"
|
||||||
|
|
@ -29,7 +29,7 @@ export default class SongParser {
|
||||||
|
|
||||||
// It is not possible to identify the title and author
|
// It is not possible to identify the title and author
|
||||||
const title = columns[0]
|
const title = columns[0]
|
||||||
const artist = columns[1]
|
const artist = columns.find(isArtist) || columns[3]
|
||||||
const album = columns.find(isAlbum) ?? null
|
const album = columns.find(isAlbum) ?? null
|
||||||
const duration = columns.find(isDuration)
|
const duration = columns.find(isDuration)
|
||||||
|
|
||||||
|
|
@ -42,10 +42,12 @@ export default class SongParser {
|
||||||
name: traverseString(artist, "text"),
|
name: traverseString(artist, "text"),
|
||||||
artistId: traverseString(artist, "browseId") || null,
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
},
|
},
|
||||||
album: album && {
|
album: album
|
||||||
|
? {
|
||||||
name: traverseString(album, "text"),
|
name: traverseString(album, "text"),
|
||||||
albumId: traverseString(album, "browseId"),
|
albumId: traverseString(album, "browseId"),
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
duration: Parser.parseDuration(duration?.text),
|
duration: Parser.parseDuration(duration?.text),
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
},
|
},
|
||||||
|
|
@ -66,11 +68,13 @@ export default class SongParser {
|
||||||
videoId: traverseString(item, "playlistItemData", "videoId"),
|
videoId: traverseString(item, "playlistItemData", "videoId"),
|
||||||
name: traverseString(title, "text"),
|
name: traverseString(title, "text"),
|
||||||
artist: artistBasic,
|
artist: artistBasic,
|
||||||
album: {
|
album: album
|
||||||
|
? {
|
||||||
name: traverseString(album, "text"),
|
name: traverseString(album, "text"),
|
||||||
albumId: traverseString(album, "browseId"),
|
albumId: traverseString(album, "browseId"),
|
||||||
},
|
}
|
||||||
duration: duration ? Parser.parseDuration(duration.text) : null,
|
: null,
|
||||||
|
duration: Parser.parseDuration(duration?.text),
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
},
|
},
|
||||||
SongDetailed,
|
SongDetailed,
|
||||||
|
|
@ -106,10 +110,8 @@ export default class SongParser {
|
||||||
albumBasic: AlbumBasic,
|
albumBasic: AlbumBasic,
|
||||||
thumbnails: ThumbnailFull[],
|
thumbnails: ThumbnailFull[],
|
||||||
): SongDetailed {
|
): SongDetailed {
|
||||||
const columns = traverseList(item, "flexColumns", "runs").flat()
|
const title = traverseList(item, "flexColumns", "runs").find(isTitle)
|
||||||
|
const duration = traverseList(item, "fixedColumns", "runs").find(isDuration)
|
||||||
const title = columns.find(isTitle)
|
|
||||||
const duration = columns.find(isDuration)
|
|
||||||
|
|
||||||
return checkType(
|
return checkType(
|
||||||
{
|
{
|
||||||
|
|
@ -118,10 +120,14 @@ export default class SongParser {
|
||||||
name: traverseString(title, "text"),
|
name: traverseString(title, "text"),
|
||||||
artist: artistBasic,
|
artist: artistBasic,
|
||||||
album: albumBasic,
|
album: albumBasic,
|
||||||
duration: duration ? Parser.parseDuration(duration.text) : null,
|
duration: Parser.parseDuration(duration?.text),
|
||||||
thumbnails,
|
thumbnails,
|
||||||
},
|
},
|
||||||
SongDetailed,
|
SongDetailed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static parseHomeSection(item: any) {
|
||||||
|
return SongParser.parseSearchResult(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArtistBasic, VideoDetailed, VideoFull } from "../@types/types"
|
import { ArtistBasic, VideoDetailed, VideoFull } from "../types"
|
||||||
import checkType from "../utils/checkType"
|
import checkType from "../utils/checkType"
|
||||||
import { isArtist, isDuration, isTitle } from "../utils/filters"
|
import { isArtist, isDuration, isTitle } from "../utils/filters"
|
||||||
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
||||||
|
|
@ -38,7 +38,7 @@ export default class VideoParser {
|
||||||
artistId: traverseString(artist, "browseId") || null,
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
name: traverseString(artist, "text"),
|
name: traverseString(artist, "text"),
|
||||||
},
|
},
|
||||||
duration: Parser.parseDuration(duration.text),
|
duration: Parser.parseDuration(duration?.text),
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +74,7 @@ export default class VideoParser {
|
||||||
name: traverseString(artist, "text"),
|
name: traverseString(artist, "text"),
|
||||||
artistId: traverseString(artist, "browseId") || null,
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
},
|
},
|
||||||
duration: duration ? Parser.parseDuration(duration.text) : null,
|
duration: Parser.parseDuration(duration?.text),
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
},
|
},
|
||||||
VideoDetailed,
|
VideoDetailed,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { arrayOf, Problem, Type, type } from "arktype"
|
|
||||||
import { equal } from "assert"
|
|
||||||
import { afterAll, beforeAll, describe, it } from "bun:test"
|
import { afterAll, beforeAll, describe, it } from "bun:test"
|
||||||
|
import { equal } from "assert"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { ZodError, ZodType } from "zod"
|
||||||
|
import YTMusic from "../YTMusic"
|
||||||
import {
|
import {
|
||||||
AlbumDetailed,
|
AlbumDetailed,
|
||||||
AlbumFull,
|
AlbumFull,
|
||||||
|
|
@ -14,15 +16,15 @@ import {
|
||||||
SongFull,
|
SongFull,
|
||||||
VideoDetailed,
|
VideoDetailed,
|
||||||
VideoFull,
|
VideoFull,
|
||||||
} from "../@types/types"
|
} from "../types"
|
||||||
import YTMusic from "../YTMusic"
|
|
||||||
|
|
||||||
const errors: Problem[] = []
|
const errors: ZodError[] = []
|
||||||
const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"]
|
const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"]
|
||||||
const expect = (data: any, type: Type) => {
|
const expect = (data: any, type: ZodType) => {
|
||||||
const result = type(data)
|
const result = type.safeParse(data)
|
||||||
if (result.problems?.length) {
|
|
||||||
errors.push(...result.problems!)
|
if (result.error) {
|
||||||
|
errors.push(result.error)
|
||||||
} else {
|
} else {
|
||||||
const empty = JSON.stringify(result.data).match(/"\w+":""/g)
|
const empty = JSON.stringify(result.data).match(/"\w+":""/g)
|
||||||
if (empty) {
|
if (empty) {
|
||||||
|
|
@ -30,7 +32,8 @@ const expect = (data: any, type: Type) => {
|
||||||
}
|
}
|
||||||
equal(empty, null)
|
equal(empty, null)
|
||||||
}
|
}
|
||||||
equal(result.problems, undefined)
|
|
||||||
|
equal(result.error, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ytmusic = new YTMusic()
|
const ytmusic = new YTMusic()
|
||||||
|
|
@ -40,43 +43,43 @@ queries.forEach(query => {
|
||||||
describe("Query: " + query, () => {
|
describe("Query: " + query, () => {
|
||||||
it("Search suggestions", async () => {
|
it("Search suggestions", async () => {
|
||||||
const suggestions = await ytmusic.getSearchSuggestions(query)
|
const suggestions = await ytmusic.getSearchSuggestions(query)
|
||||||
expect(suggestions, type("string[]"))
|
expect(suggestions, z.array(z.string()))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Search Songs", async () => {
|
it("Search Songs", async () => {
|
||||||
const songs = await ytmusic.searchSongs(query)
|
const songs = await ytmusic.searchSongs(query)
|
||||||
expect(songs, arrayOf(SongDetailed))
|
expect(songs, z.array(SongDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Search Videos", async () => {
|
it("Search Videos", async () => {
|
||||||
const videos = await ytmusic.searchVideos(query)
|
const videos = await ytmusic.searchVideos(query)
|
||||||
expect(videos, arrayOf(VideoDetailed))
|
expect(videos, z.array(VideoDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Search Artists", async () => {
|
it("Search Artists", async () => {
|
||||||
const artists = await ytmusic.searchArtists(query)
|
const artists = await ytmusic.searchArtists(query)
|
||||||
expect(artists, arrayOf(ArtistDetailed))
|
expect(artists, z.array(ArtistDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Search Albums", async () => {
|
it("Search Albums", async () => {
|
||||||
const albums = await ytmusic.searchAlbums(query)
|
const albums = await ytmusic.searchAlbums(query)
|
||||||
expect(albums, arrayOf(AlbumDetailed))
|
expect(albums, z.array(AlbumDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Search Playlists", async () => {
|
it("Search Playlists", async () => {
|
||||||
const playlists = await ytmusic.searchPlaylists(query)
|
const playlists = await ytmusic.searchPlaylists(query)
|
||||||
expect(playlists, arrayOf(PlaylistDetailed))
|
expect(playlists, z.array(PlaylistDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Search All", async () => {
|
it("Search All", async () => {
|
||||||
const results = await ytmusic.search(query)
|
const results = await ytmusic.search(query)
|
||||||
expect(results, arrayOf(SearchResult))
|
expect(results, z.array(SearchResult))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Get lyrics of the first song result", async () => {
|
it("Get lyrics of the first song result", async () => {
|
||||||
const songs = await ytmusic.searchSongs(query)
|
const songs = await ytmusic.searchSongs(query)
|
||||||
const lyrics = await ytmusic.getLyrics(songs[0]!.videoId)
|
const lyrics = await ytmusic.getLyrics(songs[0]!.videoId)
|
||||||
expect(lyrics, type("string[]|null"))
|
expect(lyrics, z.nullable(z.array(z.string())))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Get details of the first song result", async () => {
|
it("Get details of the first song result", async () => {
|
||||||
|
|
@ -100,13 +103,13 @@ queries.forEach(query => {
|
||||||
it("Get the songs of the first artist result", async () => {
|
it("Get the songs of the first artist result", async () => {
|
||||||
const artists = await ytmusic.searchArtists(query)
|
const artists = await ytmusic.searchArtists(query)
|
||||||
const songs = await ytmusic.getArtistSongs(artists[0]!.artistId)
|
const songs = await ytmusic.getArtistSongs(artists[0]!.artistId)
|
||||||
expect(songs, arrayOf(SongDetailed))
|
expect(songs, z.array(SongDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Get the albums of the first artist result", async () => {
|
it("Get the albums of the first artist result", async () => {
|
||||||
const artists = await ytmusic.searchArtists(query)
|
const artists = await ytmusic.searchArtists(query)
|
||||||
const albums = await ytmusic.getArtistAlbums(artists[0]!.artistId)
|
const albums = await ytmusic.getArtistAlbums(artists[0]!.artistId)
|
||||||
expect(albums, arrayOf(AlbumDetailed))
|
expect(albums, z.array(AlbumDetailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("Get details of the first album result", async () => {
|
it("Get details of the first album result", async () => {
|
||||||
|
|
@ -124,7 +127,7 @@ queries.forEach(query => {
|
||||||
it("Get the videos of the first playlist result", async () => {
|
it("Get the videos of the first playlist result", async () => {
|
||||||
const playlists = await ytmusic.searchPlaylists(query)
|
const playlists = await ytmusic.searchPlaylists(query)
|
||||||
const videos = await ytmusic.getPlaylistVideos(playlists[0]!.playlistId)
|
const videos = await ytmusic.getPlaylistVideos(playlists[0]!.playlistId)
|
||||||
expect(videos, arrayOf(VideoDetailed))
|
expect(videos, z.array(VideoDetailed))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1,21 +1,23 @@
|
||||||
import { arrayOf, Problem, Type } from "arktype"
|
|
||||||
import { equal, ok } from "assert"
|
|
||||||
import { afterAll, beforeEach, describe, it } from "bun:test"
|
import { afterAll, beforeEach, describe, it } from "bun:test"
|
||||||
|
import { equal } from "assert"
|
||||||
|
|
||||||
import { HomePageContent } from "../@types/types"
|
import { ZodError, ZodType, z } from "zod"
|
||||||
import { FE_MUSIC_HOME } from "../constants"
|
|
||||||
import YTMusic from "../YTMusic"
|
import YTMusic from "../YTMusic"
|
||||||
|
import { FE_MUSIC_HOME } from "../constants"
|
||||||
|
import { HomeSection } from "../types"
|
||||||
|
|
||||||
const errors: Problem[] = []
|
const errors: ZodError[] = []
|
||||||
const configs = [
|
const configs = [
|
||||||
{ GL: "RU", HL: "ru" },
|
{ GL: "RU", HL: "ru" },
|
||||||
{ GL: "US", HL: "en" },
|
{ GL: "US", HL: "en" },
|
||||||
{ GL: "DE", HL: "de" },
|
{ GL: "DE", HL: "de" },
|
||||||
]
|
]
|
||||||
const expect = (data: any, type: Type) => {
|
|
||||||
const result = type(data)
|
const expect = (data: any, type: ZodType) => {
|
||||||
if (result.problems?.length) {
|
const result = type.safeParse(data)
|
||||||
errors.push(...result.problems!)
|
|
||||||
|
if (result.error) {
|
||||||
|
errors.push(result.error)
|
||||||
} else {
|
} else {
|
||||||
const empty = JSON.stringify(result.data).match(/"\w+":""/g)
|
const empty = JSON.stringify(result.data).match(/"\w+":""/g)
|
||||||
if (empty) {
|
if (empty) {
|
||||||
|
|
@ -23,22 +25,21 @@ const expect = (data: any, type: Type) => {
|
||||||
}
|
}
|
||||||
equal(empty, null)
|
equal(empty, null)
|
||||||
}
|
}
|
||||||
equal(result.problems, undefined)
|
|
||||||
|
equal(result.error, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let index = 0
|
||||||
const ytmusic = new YTMusic()
|
const ytmusic = new YTMusic()
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const index = 0
|
return ytmusic.initialize(configs[index++])
|
||||||
return ytmusic.initialize(configs[index])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe(`Query: ${FE_MUSIC_HOME}`, () => {
|
describe(`Query: ${FE_MUSIC_HOME}`, () => {
|
||||||
configs.forEach(config => {
|
configs.forEach(config => {
|
||||||
it(`Get ${config.GL} ${config.HL}`, async () => {
|
it(`Get ${config.GL} ${config.HL}`, async () => {
|
||||||
const homePageContents = await ytmusic.getHome()
|
const sections = await ytmusic.getHomeSections()
|
||||||
ok(homePageContents.length)
|
expect(sections, z.array(HomeSection))
|
||||||
expect(homePageContents, arrayOf(HomePageContent))
|
|
||||||
console.log("Length: ", homePageContents.length)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export type ThumbnailFull = z.infer<typeof ThumbnailFull>
|
||||||
|
export const ThumbnailFull = z
|
||||||
|
.object({
|
||||||
|
url: z.string(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ArtistBasic = z.infer<typeof ArtistBasic>
|
||||||
|
export const ArtistBasic = z
|
||||||
|
.object({
|
||||||
|
artistId: z.nullable(z.string()),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type AlbumBasic = z.infer<typeof AlbumBasic>
|
||||||
|
export const AlbumBasic = z
|
||||||
|
.object({
|
||||||
|
albumId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type SongDetailed = z.infer<typeof SongDetailed>
|
||||||
|
export const SongDetailed = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("SONG"),
|
||||||
|
videoId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
album: z.nullable(AlbumBasic),
|
||||||
|
duration: z.nullable(z.number()),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type VideoDetailed = z.infer<typeof VideoDetailed>
|
||||||
|
export const VideoDetailed = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("VIDEO"),
|
||||||
|
videoId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
duration: z.nullable(z.number()),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ArtistDetailed = z.infer<typeof ArtistDetailed>
|
||||||
|
export const ArtistDetailed = z
|
||||||
|
.object({
|
||||||
|
artistId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.literal("ARTIST"),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type AlbumDetailed = z.infer<typeof AlbumDetailed>
|
||||||
|
export const AlbumDetailed = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("ALBUM"),
|
||||||
|
albumId: z.string(),
|
||||||
|
playlistId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
year: z.nullable(z.number()),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type PlaylistDetailed = z.infer<typeof PlaylistDetailed>
|
||||||
|
export const PlaylistDetailed = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("PLAYLIST"),
|
||||||
|
playlistId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type SongFull = z.infer<typeof SongFull>
|
||||||
|
export const SongFull = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("SONG"),
|
||||||
|
videoId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
duration: z.number(),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
formats: z.array(z.any()),
|
||||||
|
adaptiveFormats: z.array(z.any()),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type VideoFull = z.infer<typeof VideoFull>
|
||||||
|
export const VideoFull = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("VIDEO"),
|
||||||
|
videoId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
duration: z.number(),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
unlisted: z.boolean(),
|
||||||
|
familySafe: z.boolean(),
|
||||||
|
paid: z.boolean(),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ArtistFull = z.infer<typeof ArtistFull>
|
||||||
|
export const ArtistFull = z
|
||||||
|
.object({
|
||||||
|
artistId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.literal("ARTIST"),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
topSongs: z.array(SongDetailed),
|
||||||
|
topAlbums: z.array(AlbumDetailed),
|
||||||
|
topSingles: z.array(AlbumDetailed),
|
||||||
|
topVideos: z.array(VideoDetailed),
|
||||||
|
featuredOn: z.array(PlaylistDetailed),
|
||||||
|
similarArtists: z.array(ArtistDetailed),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type AlbumFull = z.infer<typeof AlbumFull>
|
||||||
|
export const AlbumFull = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("ALBUM"),
|
||||||
|
albumId: z.string(),
|
||||||
|
playlistId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
year: z.nullable(z.number()),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
songs: z.array(SongDetailed),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type PlaylistFull = z.infer<typeof PlaylistFull>
|
||||||
|
export const PlaylistFull = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("PLAYLIST"),
|
||||||
|
playlistId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
artist: ArtistBasic,
|
||||||
|
videoCount: z.number(),
|
||||||
|
thumbnails: z.array(ThumbnailFull),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type SearchResult = z.infer<typeof SearchResult>
|
||||||
|
export const SearchResult = z.discriminatedUnion("type", [
|
||||||
|
SongDetailed,
|
||||||
|
VideoDetailed,
|
||||||
|
AlbumDetailed,
|
||||||
|
ArtistDetailed,
|
||||||
|
PlaylistDetailed,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type HomeSection = z.infer<typeof HomeSection>
|
||||||
|
export const HomeSection = z
|
||||||
|
.object({
|
||||||
|
title: z.string(),
|
||||||
|
contents: z.array(z.union([AlbumDetailed, PlaylistDetailed, SongDetailed])),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import { Type } from "arktype"
|
import { ZodType } from "zod"
|
||||||
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
|
|
||||||
export default <T>(data: T, type: Type<T>): T => {
|
export default <T>(data: T, type: ZodType<T>): T => {
|
||||||
const result = type(data)
|
const result = type.safeParse(data)
|
||||||
if (result.data) {
|
|
||||||
return result.data as T
|
if (result.error) {
|
||||||
} else {
|
|
||||||
if ("error" in result) {
|
|
||||||
console.error(
|
console.error(
|
||||||
"Invalid data type, please report to https://github.com/zS1L3NT/ts-npm-ytmusic-api/issues/new/choose",
|
"Invalid data type, please report to https://github.com/zS1L3NT/ts-npm-ytmusic-api/issues/new/choose",
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
type: type.definition,
|
|
||||||
data,
|
data,
|
||||||
|
schema: zodToJsonSchema(type, "schema"),
|
||||||
error: result.error,
|
error: result.error,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|
@ -19,6 +18,6 @@ export default <T>(data: T, type: Type<T>): T => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue