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 a57375b..1fcdd24 100755 Binary files a/bun.lockb and b/bun.lockb differ 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 }