use zod instead of arktype

This commit is contained in:
zS1L3NT Mac 2024-07-08 04:20:06 +08:00
parent ed5e15c6ec
commit 09f113e7bb
No known key found for this signature in database
GPG Key ID: 02BE07CD431E4F42
18 changed files with 412 additions and 412 deletions

View File

@ -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",

BIN
bun.lockb

Binary file not shown.

View File

@ -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"]
} }

View File

@ -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))),
),
"[]",
],
})

View File

@ -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)
} }
} }

View File

@ -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"

View File

@ -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"

View File

@ -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
} }

View File

@ -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"

View File

@ -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 }
} }
} }

View File

@ -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,
) )
} }
} }

View File

@ -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"

View File

@ -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)
}
} }

View File

@ -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,

View File

@ -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))
}) })
}) })
}) })

View File

@ -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)
}) })
}) })
}) })

174
src/types.ts Normal file
View File

@ -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()

View File

@ -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
}
} }