From 09f113e7bb35cbc04356663d2038fc8c2db37b94 Mon Sep 17 00:00:00 2001 From: zS1L3NT Mac Date: Mon, 8 Jul 2024 04:20:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20use=20zod=20instead=20of=20arktype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- biome.json | 7 +- bun.lockb | Bin 69126 -> 69868 bytes package.json | 8 +- src/@types/types.ts | 157 ---------------- src/YTMusic.ts | 129 ++++++------- src/constants.ts | 2 +- src/index.ts | 7 +- src/parsers/AlbumParser.ts | 32 +++- src/parsers/ArtistParser.ts | 2 +- src/parsers/Parser.ts | 121 ++++-------- src/parsers/PlaylistParser.ts | 20 +- src/parsers/SearchParser.ts | 2 +- src/parsers/SongParser.ts | 38 ++-- src/parsers/VideoParser.ts | 6 +- .../{traversing.spec.ts => core.spec.ts} | 45 ++--- src/tests/{getHome.spec.ts => home.spec.ts} | 33 ++-- src/types.ts | 174 ++++++++++++++++++ src/utils/checkType.ts | 41 ++--- 18 files changed, 412 insertions(+), 412 deletions(-) delete mode 100644 src/@types/types.ts rename src/tests/{traversing.spec.ts => core.spec.ts} (80%) rename src/tests/{getHome.spec.ts => home.spec.ts} (51%) create mode 100644 src/types.ts diff --git a/biome.json b/biome.json index 7d5fd83..11402ca 100644 --- a/biome.json +++ b/biome.json @@ -41,9 +41,10 @@ "noUnreachableSuper": "error", "noUnsafeFinally": "error", "noUnsafeOptionalChaining": "error", - "noUnusedLabels": "error", - "noUnusedPrivateClassMembers": "error", - "noUnusedVariables": "error", + "noUnusedImports": "warn", + "noUnusedLabels": "warn", + "noUnusedPrivateClassMembers": "warn", + "noUnusedVariables": "warn", "useArrayLiterals": "off", "useIsNan": "error", "useValidForDirection": "error", diff --git a/bun.lockb b/bun.lockb index a57375b646cec0c67c6ea29d6df696bfc5c4b331..1fcdd24828c0d262b1adf3f86e9e9bf2c0efed77 100755 GIT binary patch delta 11911 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 B_M!j) diff --git a/package.json b/package.json index faeb83e..4c9e09a 100644 --- a/package.json +++ b/package.json @@ -22,18 +22,20 @@ }, "scripts": { "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": { "@biomejs/biome": "1.8.3", "axios": "^1.7.2", - "tough-cookie": "^4.1.4" + "tough-cookie": "^4.1.4", + "zod": "^3.23.8" }, "devDependencies": { "@types/tough-cookie": "^4.0.5", "bun-types": "^1.1.18", "tsup": "^8.1.0", - "typescript": "5.1" + "typescript": "5.1", + "zod-to-json-schema": "^3.23.1" }, "keywords": ["youtube", "music", "api"] } diff --git a/src/@types/types.ts b/src/@types/types.ts deleted file mode 100644 index d461b16..0000000 --- a/src/@types/types.ts +++ /dev/null @@ -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))), - ), - "[]", - ], -}) \ No newline at end of file diff --git a/src/YTMusic.ts b/src/YTMusic.ts index 11fb846..c2f2250 100644 --- a/src/YTMusic.ts +++ b/src/YTMusic.ts @@ -1,20 +1,6 @@ import axios, { AxiosInstance } from "axios" 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 AlbumParser from "./parsers/AlbumParser" import ArtistParser from "./parsers/ArtistParser" @@ -23,8 +9,24 @@ import PlaylistParser from "./parsers/PlaylistParser" import SearchParser from "./parsers/SearchParser" import SongParser from "./parsers/SongParser" 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" +axios.defaults.headers.common["Accept-Encoding"] = "gzip" + export default class YTMusic { private cookiejar: CookieJar private config?: Record @@ -48,31 +50,27 @@ export default class YTMusic { }) this.client.interceptors.request.use(req => { - if (!req.baseURL) return - - const cookieString = this.cookiejar.getCookieStringSync(req.baseURL) - if (cookieString) { - if (!req.headers) { - req.headers = {} + if (req.baseURL) { + const cookieString = this.cookiejar.getCookieStringSync(req.baseURL) + if (cookieString) { + req.headers["cookie"] = cookieString } - req.headers["Cookie"] = cookieString } return req }) this.client.interceptors.response.use(res => { - if ("set-cookie" in res.headers) { - if (!res.config.baseURL) return - - const setCookie = res.headers["set-cookie"] as Array | string - for (const cookieString of [setCookie].flat()) { - const cookie = Cookie.parse(`${cookieString}`) - if (!cookie) return - - this.cookiejar.setCookieSync(cookie, res.config.baseURL) + if (res.headers && res.config.baseURL) { + const cookieStrings = res.headers["set-cookie"] || [] + for (const cookieString of cookieStrings) { + const cookie = Cookie.parse(cookieString) + if (cookie) { + this.cookiejar.setCookieSync(cookie, res.config.baseURL) + } } } + return res }) } @@ -80,7 +78,11 @@ export default class YTMusic { /** * 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 ?? {} if (cookies) { @@ -235,7 +237,7 @@ export default class YTMusic { * * @param query Query string */ - public async search(query: string): Promise<(typeof SearchResult.infer)[]> { + public async search(query: string): Promise { const searchData = await this.constructRequest("search", { query, params: null, @@ -243,7 +245,7 @@ export default class YTMusic { return traverseList(searchData, "musicResponsiveListItemRenderer") .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 */ - public async searchSongs(query: string): Promise<(typeof SongDetailed.infer)[]> { + public async searchSongs(query: string): Promise { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D", @@ -267,7 +269,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchVideos(query: string): Promise<(typeof VideoDetailed.infer)[]> { + public async searchVideos(query: string): Promise { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABABGAAgACgAMABqChAEEAMQCRAFEAo%3D", @@ -283,7 +285,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchArtists(query: string): Promise<(typeof ArtistDetailed.infer)[]> { + public async searchArtists(query: string): Promise { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABAAGAAgASgAMABqChAEEAMQCRAFEAo%3D", @@ -299,7 +301,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchAlbums(query: string): Promise<(typeof AlbumDetailed.infer)[]> { + public async searchAlbums(query: string): Promise { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABAAGAEgACgAMABqChAEEAMQCRAFEAo%3D", @@ -315,7 +317,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchPlaylists(query: string): Promise<(typeof PlaylistDetailed.infer)[]> { + public async searchPlaylists(query: string): Promise { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D", @@ -332,7 +334,7 @@ export default class YTMusic { * @param videoId Video ID * @returns Song Data */ - public async getSong(videoId: string): Promise { + public async getSong(videoId: string): Promise { if (!videoId.match(/^[a-zA-Z0-9-_]{11}$/)) throw new Error("Invalid videoId") const data = await this.constructRequest("player", { videoId }) @@ -347,7 +349,7 @@ export default class YTMusic { * @param videoId Video ID * @returns Video Data */ - public async getVideo(videoId: string): Promise { + public async getVideo(videoId: string): Promise { if (!videoId.match(/^[a-zA-Z0-9-_]{11}$/)) throw new Error("Invalid videoId") const data = await this.constructRequest("player", { videoId }) @@ -384,7 +386,7 @@ export default class YTMusic { * @param artistId Artist ID * @returns Artist Data */ - public async getArtist(artistId: string): Promise { + public async getArtist(artistId: string): Promise { const data = await this.constructRequest("browse", { browseId: artistId, }) @@ -398,13 +400,17 @@ export default class YTMusic { * @param artistId Artist ID * @returns Artist's Songs */ - public async getArtistSongs(artistId: string): Promise<(typeof SongDetailed.infer)[]> { - const artistData = await this.constructRequest("browse", { browseId: artistId }) + public async getArtistSongs(artistId: string): Promise { + const artistData = await this.constructRequest("browse", { + browseId: artistId, + }) const browseToken = traverse(artistData, "musicShelfRenderer", "title", "browseId") 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 moreSongsData = await this.constructRequest( "browse", @@ -429,7 +435,7 @@ export default class YTMusic { * @param artistId Artist ID * @returns Artist's Albums */ - public async getArtistAlbums(artistId: string): Promise<(typeof AlbumDetailed.infer)[]> { + public async getArtistAlbums(artistId: string): Promise { const artistData = await this.constructRequest("browse", { browseId: artistId, }) @@ -452,7 +458,7 @@ export default class YTMusic { * @param albumId Album ID * @returns Album Data */ - public async getAlbum(albumId: string): Promise { + public async getAlbum(albumId: string): Promise { const data = await this.constructRequest("browse", { browseId: albumId, }) @@ -466,7 +472,7 @@ export default class YTMusic { * @param playlistId Playlist ID * @returns Playlist Data */ - public async getPlaylist(playlistId: string): Promise { + public async getPlaylist(playlistId: string): Promise { if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId const data = await this.constructRequest("browse", { browseId: playlistId, @@ -481,7 +487,7 @@ export default class YTMusic { * @param playlistId Playlist ID * @returns Playlist's Videos */ - public async getPlaylistVideos(playlistId: string): Promise<(typeof VideoDetailed.infer)[]> { + public async getPlaylistVideos(playlistId: string): Promise { if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId const playlistData = await this.constructRequest("browse", { 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 { - const results: HomePageContent[] = [] - const page = await this.constructRequest("browse", { browseId: FE_MUSIC_HOME }) - traverseList(page, "sectionListRenderer", "contents").forEach(content => { - const parsed = Parser.parseMixedContent(content) - parsed && results.push(parsed) + public async getHomeSections(): Promise { + const data = await this.constructRequest("browse", { + browseId: FE_MUSIC_HOME, }) - let continuation = traverseString(page, "continuation") + const sections = traverseList("sectionListRenderer", "contents") + let continuation = traverseString(data, "continuation") while (continuation) { - const nextPage = await this.constructRequest("browse", {}, { continuation }) - traverseList(nextPage, "sectionListContinuation", "contents").forEach(content => { - const parsed = Parser.parseMixedContent(content) - parsed && results.push(parsed) - }) - continuation = traverseString(nextPage, "continuation") + const data = await this.constructRequest("browse", {}, { continuation }) + sections.push(...traverseList(data, "sectionListContinuation", "contents")) + continuation = traverseString(data, "continuation") } - return results + return sections.map(Parser.parseHomeSection) } } diff --git a/src/constants.ts b/src/constants.ts index d4a0118..c6b5daa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,7 @@ export enum PageType { 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_VIDEO_TYPE_OMV = "MUSIC_VIDEO_TYPE_OMV", } export const FE_MUSIC_HOME = "FEmusic_home" diff --git a/src/index.ts b/src/index.ts index c3ddccb..844a3cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -import YTMusic from "./YTMusic" - export type { AlbumBasic, AlbumDetailed, @@ -15,6 +13,7 @@ export type { ThumbnailFull, VideoDetailed, VideoFull, -} from "./@types/types" + HomeSection, +} from "./types" -export default YTMusic +export { default as YTMusic } from "./YTMusic" diff --git a/src/parsers/AlbumParser.ts b/src/parsers/AlbumParser.ts index 278305e..999de5c 100644 --- a/src/parsers/AlbumParser.ts +++ b/src/parsers/AlbumParser.ts @@ -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 { isArtist } from "../utils/filters" import { traverse, traverseList, traverseString } from "../utils/traverse" @@ -8,25 +8,25 @@ export default class AlbumParser { public static parse(data: any, albumId: string): AlbumFull { const albumBasic: AlbumBasic = { 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 = { artistId: traverseString(artistData, "browseId") || null, name: traverseString(artistData, "text"), } - const thumbnails = traverseList(data, "header", "thumbnails") + const thumbnails = traverseList(data, "background", "thumbnails") return checkType( { type: "ALBUM", ...albumBasic, - playlistId: traverseString(data, "buttonRenderer", "playlistId"), + playlistId: traverseString(data, "musicPlayButtonRenderer", "playlistId"), artist: artistBasic, year: AlbumParser.processYear( - traverseList(data, "header", "subtitle", "text").at(-1), + traverseList(data, "tabs", "subtitle", "text").at(-1), ), thumbnails, 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) { return year && year.match(/^\d{4}$/) ? +year : null } diff --git a/src/parsers/ArtistParser.ts b/src/parsers/ArtistParser.ts index 68c459a..98db25a 100644 --- a/src/parsers/ArtistParser.ts +++ b/src/parsers/ArtistParser.ts @@ -1,4 +1,4 @@ -import { ArtistDetailed, ArtistFull } from "../@types/types" +import { ArtistDetailed, ArtistFull } from "../types" import checkType from "../utils/checkType" import { traverseList, traverseString } from "../utils/traverse" import AlbumParser from "./AlbumParser" diff --git a/src/parsers/Parser.ts b/src/parsers/Parser.ts index 05ac2a2..ecf7bd0 100644 --- a/src/parsers/Parser.ts +++ b/src/parsers/Parser.ts @@ -1,8 +1,8 @@ -import { HomePageContent } from "../@types/types" 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 ArtistParser from "./ArtistParser" import PlaylistParser from "./PlaylistParser" import SongParser from "./SongParser" @@ -36,91 +36,36 @@ export default class Parser { } } - /** - * Parses mixed content data into a structured `HomePageContent` object. - * - * This static method takes raw data of mixed content types and attempts to parse it into a - * 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") + public static parseHomeSection(data: any): HomeSection { + const pageType = traverseString(data, "contents", "title", "browseEndpoint", "pageType") + const playlistId = traverseString( + data, + "navigationEndpoint", + "watchPlaylistEndpoint", + "playlistId", + ) - 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", - "watchPlaylistEndpoint", - "playlistId", - ) - - switch (pageType) { - case PageType.MUSIC_PAGE_TYPE_ARTIST: - contents.push(ArtistParser.parseSearchResult(content)) - break - case PageType.MUSIC_PAGE_TYPE_ALBUM: - contents.push(AlbumParser.parseSearchResult(content)) - break - case PageType.MUSIC_PAGE_TYPE_PLAYLIST: - contents.push(PlaylistParser.parseSearchResult(content)) - break - default: - if (playlistId) { - contents.push(PlaylistParser.parseWatchPlaylist(content)) - } else { - contents.push(SongParser.parseSearchResult(content)) - } - } - } else { - const musicResponsiveListItemRenderer = traverse( - content, - "musicResponsiveListItemRenderer", - ) - - if ( - musicResponsiveListItemRenderer && - !Array.isArray(musicResponsiveListItemRenderer) - ) { - contents.push(SongParser.parseSearchResult(musicResponsiveListItemRenderer)) - } - } - }) - - return { title, contents } + return checkType( + { + title: traverseString(data, "header", "title", "text"), + contents: traverseList(data, "contents").map(item => { + switch (pageType) { + case PageType.MUSIC_PAGE_TYPE_ALBUM: + return AlbumParser.parseHomeSection(item) + case PageType.MUSIC_PAGE_TYPE_PLAYLIST: + return PlaylistParser.parseHomeSection(item) + case "": + if (playlistId) { + return PlaylistParser.parseHomeSection(item) + } else { + return SongParser.parseHomeSection(item) + } + default: + return null as unknown as AlbumDetailed + } + }), + }, + HomeSection, + ) } } diff --git a/src/parsers/PlaylistParser.ts b/src/parsers/PlaylistParser.ts index b5e33c4..7390472 100644 --- a/src/parsers/PlaylistParser.ts +++ b/src/parsers/PlaylistParser.ts @@ -1,28 +1,28 @@ -import { ArtistBasic, PlaylistDetailed, PlaylistFull, PlaylistWatch } from "../@types/types" +import { ArtistBasic, PlaylistDetailed, PlaylistFull } from "../types" import checkType from "../utils/checkType" import { isArtist } from "../utils/filters" import { traverse, traverseList, traverseString } from "../utils/traverse" export default class PlaylistParser { public static parse(data: any, playlistId: string): PlaylistFull { - const artist = traverse(data, "header", "subtitle") + const artist = traverse(data, "tabs", "straplineTextOne") return checkType( { type: "PLAYLIST", playlistId, - name: traverseString(data, "header", "title", "text"), + name: traverseString(data, "tabs", "title", "text"), artist: { name: traverseString(artist, "text"), artistId: traverseString(artist, "browseId") || null, }, videoCount: - +traverseList(data, "header", "secondSubtitle", "text") + +traverseList(data, "tabs", "secondSubtitle", "text") .at(2) .split(" ") .at(0) .replaceAll(",", "") ?? null, - thumbnails: traverseList(data, "header", "thumbnails"), + thumbnails: traverseList(data, "tabs", "thumbnails"), }, 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( { type: "PLAYLIST", playlistId: traverseString(item, "navigationEndpoint", "playlistId"), name: traverseString(item, "runs", "text"), + artist: { + name: traverseString(artist, "text"), + artistId: traverseString(artist, "browseId") || null, + }, thumbnails: traverseList(item, "thumbnails"), }, - PlaylistWatch, + PlaylistDetailed, ) } } diff --git a/src/parsers/SearchParser.ts b/src/parsers/SearchParser.ts index 54e87ba..5935a3c 100644 --- a/src/parsers/SearchParser.ts +++ b/src/parsers/SearchParser.ts @@ -1,4 +1,4 @@ -import { SearchResult } from "../@types/types" +import { SearchResult } from "../types" import { traverseList } from "../utils/traverse" import AlbumParser from "./AlbumParser" import ArtistParser from "./ArtistParser" diff --git a/src/parsers/SongParser.ts b/src/parsers/SongParser.ts index cb7f21c..27aca73 100644 --- a/src/parsers/SongParser.ts +++ b/src/parsers/SongParser.ts @@ -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 { isAlbum, isArtist, isDuration, isTitle } from "../utils/filters" import { traverseList, traverseString } from "../utils/traverse" @@ -29,7 +29,7 @@ export default class SongParser { // It is not possible to identify the title and author const title = columns[0] - const artist = columns[1] + const artist = columns.find(isArtist) || columns[3] const album = columns.find(isAlbum) ?? null const duration = columns.find(isDuration) @@ -42,10 +42,12 @@ export default class SongParser { name: traverseString(artist, "text"), artistId: traverseString(artist, "browseId") || null, }, - album: album && { - name: traverseString(album, "text"), - albumId: traverseString(album, "browseId"), - }, + album: album + ? { + name: traverseString(album, "text"), + albumId: traverseString(album, "browseId"), + } + : null, duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), }, @@ -66,11 +68,13 @@ export default class SongParser { videoId: traverseString(item, "playlistItemData", "videoId"), name: traverseString(title, "text"), artist: artistBasic, - album: { - name: traverseString(album, "text"), - albumId: traverseString(album, "browseId"), - }, - duration: duration ? Parser.parseDuration(duration.text) : null, + album: album + ? { + name: traverseString(album, "text"), + albumId: traverseString(album, "browseId"), + } + : null, + duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), }, SongDetailed, @@ -106,10 +110,8 @@ export default class SongParser { albumBasic: AlbumBasic, thumbnails: ThumbnailFull[], ): SongDetailed { - const columns = traverseList(item, "flexColumns", "runs").flat() - - const title = columns.find(isTitle) - const duration = columns.find(isDuration) + const title = traverseList(item, "flexColumns", "runs").find(isTitle) + const duration = traverseList(item, "fixedColumns", "runs").find(isDuration) return checkType( { @@ -118,10 +120,14 @@ export default class SongParser { name: traverseString(title, "text"), artist: artistBasic, album: albumBasic, - duration: duration ? Parser.parseDuration(duration.text) : null, + duration: Parser.parseDuration(duration?.text), thumbnails, }, SongDetailed, ) } + + public static parseHomeSection(item: any) { + return SongParser.parseSearchResult(item) + } } diff --git a/src/parsers/VideoParser.ts b/src/parsers/VideoParser.ts index 41275af..b8cd290 100644 --- a/src/parsers/VideoParser.ts +++ b/src/parsers/VideoParser.ts @@ -1,4 +1,4 @@ -import { ArtistBasic, VideoDetailed, VideoFull } from "../@types/types" +import { ArtistBasic, VideoDetailed, VideoFull } from "../types" import checkType from "../utils/checkType" import { isArtist, isDuration, isTitle } from "../utils/filters" import { traverse, traverseList, traverseString } from "../utils/traverse" @@ -38,7 +38,7 @@ export default class VideoParser { artistId: traverseString(artist, "browseId") || null, name: traverseString(artist, "text"), }, - duration: Parser.parseDuration(duration.text), + duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), } } @@ -74,7 +74,7 @@ export default class VideoParser { name: traverseString(artist, "text"), artistId: traverseString(artist, "browseId") || null, }, - duration: duration ? Parser.parseDuration(duration.text) : null, + duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), }, VideoDetailed, diff --git a/src/tests/traversing.spec.ts b/src/tests/core.spec.ts similarity index 80% rename from src/tests/traversing.spec.ts rename to src/tests/core.spec.ts index b80bcb4..a7488ee 100644 --- a/src/tests/traversing.spec.ts +++ b/src/tests/core.spec.ts @@ -1,7 +1,9 @@ -import { arrayOf, Problem, Type, type } from "arktype" -import { equal } from "assert" 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 { AlbumDetailed, AlbumFull, @@ -14,15 +16,15 @@ import { SongFull, VideoDetailed, VideoFull, -} from "../@types/types" -import YTMusic from "../YTMusic" +} from "../types" -const errors: Problem[] = [] +const errors: ZodError[] = [] const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"] -const expect = (data: any, type: Type) => { - const result = type(data) - if (result.problems?.length) { - errors.push(...result.problems!) +const expect = (data: any, type: ZodType) => { + const result = type.safeParse(data) + + if (result.error) { + errors.push(result.error) } else { const empty = JSON.stringify(result.data).match(/"\w+":""/g) if (empty) { @@ -30,7 +32,8 @@ const expect = (data: any, type: Type) => { } equal(empty, null) } - equal(result.problems, undefined) + + equal(result.error, undefined) } const ytmusic = new YTMusic() @@ -40,43 +43,43 @@ queries.forEach(query => { describe("Query: " + query, () => { it("Search suggestions", async () => { const suggestions = await ytmusic.getSearchSuggestions(query) - expect(suggestions, type("string[]")) + expect(suggestions, z.array(z.string())) }) it("Search Songs", async () => { const songs = await ytmusic.searchSongs(query) - expect(songs, arrayOf(SongDetailed)) + expect(songs, z.array(SongDetailed)) }) it("Search Videos", async () => { const videos = await ytmusic.searchVideos(query) - expect(videos, arrayOf(VideoDetailed)) + expect(videos, z.array(VideoDetailed)) }) it("Search Artists", async () => { const artists = await ytmusic.searchArtists(query) - expect(artists, arrayOf(ArtistDetailed)) + expect(artists, z.array(ArtistDetailed)) }) it("Search Albums", async () => { const albums = await ytmusic.searchAlbums(query) - expect(albums, arrayOf(AlbumDetailed)) + expect(albums, z.array(AlbumDetailed)) }) it("Search Playlists", async () => { const playlists = await ytmusic.searchPlaylists(query) - expect(playlists, arrayOf(PlaylistDetailed)) + expect(playlists, z.array(PlaylistDetailed)) }) it("Search All", async () => { const results = await ytmusic.search(query) - expect(results, arrayOf(SearchResult)) + expect(results, z.array(SearchResult)) }) it("Get lyrics of the first song result", async () => { const songs = await ytmusic.searchSongs(query) 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 () => { @@ -100,13 +103,13 @@ queries.forEach(query => { it("Get the songs of the first artist result", async () => { const artists = await ytmusic.searchArtists(query) 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 () => { const artists = await ytmusic.searchArtists(query) 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 () => { @@ -124,7 +127,7 @@ queries.forEach(query => { it("Get the videos of the first playlist result", async () => { const playlists = await ytmusic.searchPlaylists(query) const videos = await ytmusic.getPlaylistVideos(playlists[0]!.playlistId) - expect(videos, arrayOf(VideoDetailed)) + expect(videos, z.array(VideoDetailed)) }) }) }) diff --git a/src/tests/getHome.spec.ts b/src/tests/home.spec.ts similarity index 51% rename from src/tests/getHome.spec.ts rename to src/tests/home.spec.ts index cca7d17..273a71e 100644 --- a/src/tests/getHome.spec.ts +++ b/src/tests/home.spec.ts @@ -1,21 +1,23 @@ -import { arrayOf, Problem, Type } from "arktype" -import { equal, ok } from "assert" import { afterAll, beforeEach, describe, it } from "bun:test" +import { equal } from "assert" -import { HomePageContent } from "../@types/types" -import { FE_MUSIC_HOME } from "../constants" +import { ZodError, ZodType, z } from "zod" import YTMusic from "../YTMusic" +import { FE_MUSIC_HOME } from "../constants" +import { HomeSection } from "../types" -const errors: Problem[] = [] +const errors: ZodError[] = [] const configs = [ { GL: "RU", HL: "ru" }, { GL: "US", HL: "en" }, { GL: "DE", HL: "de" }, ] -const expect = (data: any, type: Type) => { - const result = type(data) - if (result.problems?.length) { - errors.push(...result.problems!) + +const expect = (data: any, type: ZodType) => { + const result = type.safeParse(data) + + if (result.error) { + errors.push(result.error) } else { const empty = JSON.stringify(result.data).match(/"\w+":""/g) if (empty) { @@ -23,22 +25,21 @@ const expect = (data: any, type: Type) => { } equal(empty, null) } - equal(result.problems, undefined) + + equal(result.error, undefined) } +let index = 0 const ytmusic = new YTMusic() beforeEach(() => { - const index = 0 - return ytmusic.initialize(configs[index]) + return ytmusic.initialize(configs[index++]) }) describe(`Query: ${FE_MUSIC_HOME}`, () => { configs.forEach(config => { it(`Get ${config.GL} ${config.HL}`, async () => { - const homePageContents = await ytmusic.getHome() - ok(homePageContents.length) - expect(homePageContents, arrayOf(HomePageContent)) - console.log("Length: ", homePageContents.length) + const sections = await ytmusic.getHomeSections() + expect(sections, z.array(HomeSection)) }) }) }) diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8e25c3c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,174 @@ +import { z } from "zod" + +export type ThumbnailFull = z.infer +export const ThumbnailFull = z + .object({ + url: z.string(), + width: z.number(), + height: z.number(), + }) + .strict() + +export type ArtistBasic = z.infer +export const ArtistBasic = z + .object({ + artistId: z.nullable(z.string()), + name: z.string(), + }) + .strict() + +export type AlbumBasic = z.infer +export const AlbumBasic = z + .object({ + albumId: z.string(), + name: z.string(), + }) + .strict() + +export type SongDetailed = z.infer +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +export const SearchResult = z.discriminatedUnion("type", [ + SongDetailed, + VideoDetailed, + AlbumDetailed, + ArtistDetailed, + PlaylistDetailed, +]) + +export type HomeSection = z.infer +export const HomeSection = z + .object({ + title: z.string(), + contents: z.array(z.union([AlbumDetailed, PlaylistDetailed, SongDetailed])), + }) + .strict() diff --git a/src/utils/checkType.ts b/src/utils/checkType.ts index 916bbcf..ae8fd26 100644 --- a/src/utils/checkType.ts +++ b/src/utils/checkType.ts @@ -1,24 +1,23 @@ -import { Type } from "arktype" +import { ZodType } from "zod" +import { zodToJsonSchema } from "zod-to-json-schema" -export default (data: T, type: Type): T => { - const result = type(data) - if (result.data) { - return result.data as T - } else { - if ("error" in result) { - console.error( - "Invalid data type, please report to https://github.com/zS1L3NT/ts-npm-ytmusic-api/issues/new/choose", - JSON.stringify( - { - type: type.definition, - data, - error: result.error, - }, - null, - 2, - ), - ) - } - return data +export default (data: T, type: ZodType): T => { + const result = type.safeParse(data) + + if (result.error) { + console.error( + "Invalid data type, please report to https://github.com/zS1L3NT/ts-npm-ytmusic-api/issues/new/choose", + JSON.stringify( + { + data, + schema: zodToJsonSchema(type, "schema"), + error: result.error, + }, + null, + 2, + ), + ) } + + return data }