diff --git a/bun.lockb b/bun.lockb index 54d036c..6c964b5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index f9e9cf3..592bdd6 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,11 @@ "clean": "tsc --noEmit && eslint src --fix && prettier src --write && rm tsconfig.tsbuildinfo" }, "dependencies": { + "arktype": "^1.0.28-alpha", "axios": "^0.27.2", - "tough-cookie": "^4.1.2", - "zod": "^3.20.2", - "zod-to-json-schema": "^3.20.1" + "tough-cookie": "^4.1.2" }, "devDependencies": { - "@types/json-schema": "^7.0.11", "@types/tough-cookie": "^4.0.2", "@typescript-eslint/eslint-plugin": "latest", "@typescript-eslint/parser": "latest", diff --git a/src/@types/types.ts b/src/@types/types.ts new file mode 100644 index 0000000..1be0193 --- /dev/null +++ b/src/@types/types.ts @@ -0,0 +1,141 @@ +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", + 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", + artists: [ArtistBasic, "[]"], + album: AlbumBasic, + duration: "number|null", + thumbnails: [ThumbnailFull, "[]"], +}) + +export type VideoDetailed = typeof VideoDetailed.infer +export const VideoDetailed = type({ + type: '"VIDEO"', + videoId: "string", + name: "string", + artists: [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", + artists: [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", + artists: [ArtistBasic, "[]"], + duration: "number", + thumbnails: [ThumbnailFull, "[]"], + description: "string", + formats: "any[]", + adaptiveFormats: "any[]", +}) + +export type VideoFull = typeof VideoFull.infer +export const VideoFull = type({ + type: '"VIDEO"', + videoId: "string", + name: "string", + artists: [ArtistBasic, "[]"], + duration: "number", + thumbnails: [ThumbnailFull, "[]"], + description: "string", + 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, "[]"], + description: "string", + 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", + artists: [ArtistBasic, "[]"], + year: "number|null", + thumbnails: [ThumbnailFull, "[]"], + description: "string", + 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))), +) diff --git a/src/YTMusic.ts b/src/YTMusic.ts index 07d28aa..229441d 100644 --- a/src/YTMusic.ts +++ b/src/YTMusic.ts @@ -1,13 +1,6 @@ import axios, { AxiosInstance } from "axios" import { Cookie, CookieJar } from "tough-cookie" -import { z } from "zod" -import AlbumParser from "./parsers/AlbumParser" -import ArtistParser from "./parsers/ArtistParser" -import PlaylistParser from "./parsers/PlaylistParser" -import SearchParser from "./parsers/SearchParser" -import SongParser from "./parsers/SongParser" -import VideoParser from "./parsers/VideoParser" import { AlbumDetailed, AlbumFull, @@ -20,7 +13,13 @@ import { SongFull, VideoDetailed, VideoFull, -} from "./schemas" +} from "./@types/types" +import AlbumParser from "./parsers/AlbumParser" +import ArtistParser from "./parsers/ArtistParser" +import PlaylistParser from "./parsers/PlaylistParser" +import SearchParser from "./parsers/SearchParser" +import SongParser from "./parsers/SongParser" +import VideoParser from "./parsers/VideoParser" import traverse from "./utils/traverse" import traverseList from "./utils/traverseList" import traverseString from "./utils/traverseString" @@ -226,7 +225,7 @@ export default class YTMusic { * * @param query Query string */ - public async search(query: string): Promise[]> { + public async search(query: string): Promise<(typeof SearchResult.infer)[]> { const searchData = await this.constructRequest("search", { query, params: null, @@ -234,7 +233,7 @@ export default class YTMusic { return traverseList(searchData, "musicResponsiveListItemRenderer") .map(SearchParser.parse) - .filter(Boolean) as z.infer[] + .filter(Boolean) as (typeof SearchResult.infer)[] } /** @@ -242,7 +241,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchSongs(query: string): Promise[]> { + public async searchSongs(query: string): Promise<(typeof SongDetailed.infer)[]> { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIARAAGAAgACgAMABqChAEEAMQCRAFEAo%3D", @@ -258,7 +257,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchVideos(query: string): Promise[]> { + public async searchVideos(query: string): Promise<(typeof VideoDetailed.infer)[]> { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABABGAAgACgAMABqChAEEAMQCRAFEAo%3D", @@ -274,7 +273,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchArtists(query: string): Promise[]> { + public async searchArtists(query: string): Promise<(typeof ArtistDetailed.infer)[]> { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABAAGAAgASgAMABqChAEEAMQCRAFEAo%3D", @@ -290,7 +289,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchAlbums(query: string): Promise[]> { + public async searchAlbums(query: string): Promise<(typeof AlbumDetailed.infer)[]> { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABAAGAEgACgAMABqChAEEAMQCRAFEAo%3D", @@ -306,7 +305,7 @@ export default class YTMusic { * * @param query Query string */ - public async searchPlaylists(query: string): Promise[]> { + public async searchPlaylists(query: string): Promise<(typeof PlaylistDetailed.infer)[]> { const searchData = await this.constructRequest("search", { query, params: "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D", @@ -323,7 +322,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 }) @@ -338,7 +337,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 }) @@ -353,7 +352,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, }) @@ -367,7 +366,7 @@ export default class YTMusic { * @param artistId Artist ID * @returns Artist's Songs */ - public async getArtistSongs(artistId: string): Promise[]> { + public async getArtistSongs(artistId: string): Promise<(typeof SongDetailed.infer)[]> { const artistData = await this.constructRequest("browse", { browseId: artistId, }) @@ -397,7 +396,7 @@ export default class YTMusic { * @param artistId Artist ID * @returns Artist's Albums */ - public async getArtistAlbums(artistId: string): Promise[]> { + public async getArtistAlbums(artistId: string): Promise<(typeof AlbumDetailed.infer)[]> { const artistData = await this.constructRequest("browse", { browseId: artistId, }) @@ -420,7 +419,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, }) @@ -434,7 +433,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, @@ -449,7 +448,7 @@ export default class YTMusic { * @param playlistId Playlist ID * @returns Playlist's Videos */ - public async getPlaylistVideos(playlistId: string): Promise[]> { + public async getPlaylistVideos(playlistId: string): Promise<(typeof VideoDetailed.infer)[]> { if (playlistId.startsWith("PL")) playlistId = "VL" + playlistId const playlistData = await this.constructRequest("browse", { browseId: playlistId, diff --git a/src/index.ts b/src/index.ts index 4923625..c3ddccb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,6 @@ export type { ThumbnailFull, VideoDetailed, VideoFull, -} from "./schemas" +} from "./@types/types" export default YTMusic diff --git a/src/parsers/AlbumParser.ts b/src/parsers/AlbumParser.ts index 469c15a..08d8ca5 100644 --- a/src/parsers/AlbumParser.ts +++ b/src/parsers/AlbumParser.ts @@ -1,4 +1,4 @@ -import { AlbumBasic, AlbumDetailed, AlbumFull, ArtistBasic } from "../schemas" +import { AlbumBasic, AlbumDetailed, AlbumFull, ArtistBasic } from "../@types/types" import checkType from "../utils/checkType" import traverseList from "../utils/traverseList" import traverseString from "../utils/traverseString" diff --git a/src/parsers/ArtistParser.ts b/src/parsers/ArtistParser.ts index 34546cb..77a78fb 100644 --- a/src/parsers/ArtistParser.ts +++ b/src/parsers/ArtistParser.ts @@ -1,4 +1,4 @@ -import { ArtistBasic, ArtistDetailed, ArtistFull } from "../schemas" +import { ArtistBasic, ArtistDetailed, ArtistFull } from "../@types/types" import checkType from "../utils/checkType" import traverseList from "../utils/traverseList" import traverseString from "../utils/traverseString" diff --git a/src/parsers/PlaylistParser.ts b/src/parsers/PlaylistParser.ts index 131aea6..11df24b 100644 --- a/src/parsers/PlaylistParser.ts +++ b/src/parsers/PlaylistParser.ts @@ -1,4 +1,4 @@ -import { PlaylistDetailed, PlaylistFull } from "../schemas" +import { PlaylistDetailed, PlaylistFull } from "../@types/types" import checkType from "../utils/checkType" import traverseList from "../utils/traverseList" import traverseString from "../utils/traverseString" diff --git a/src/parsers/SearchParser.ts b/src/parsers/SearchParser.ts index 7a59fe9..57dd535 100644 --- a/src/parsers/SearchParser.ts +++ b/src/parsers/SearchParser.ts @@ -1,4 +1,4 @@ -import { SearchResult } from "../schemas" +import { SearchResult } from "../@types/types" import traverseList from "../utils/traverseList" import AlbumParser from "./AlbumParser" import ArtistParser from "./ArtistParser" diff --git a/src/parsers/SongParser.ts b/src/parsers/SongParser.ts index 425a9e3..daed359 100644 --- a/src/parsers/SongParser.ts +++ b/src/parsers/SongParser.ts @@ -1,4 +1,4 @@ -import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from "../schemas" +import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from "../@types/types" import checkType from "../utils/checkType" import traverseList from "../utils/traverseList" import traverseString from "../utils/traverseString" @@ -81,10 +81,7 @@ export default class SongParser { ) } - public static parseArtistTopSong( - item: any, - artistBasic: ArtistBasic, - ): Omit { + public static parseArtistTopSong(item: any, artistBasic: ArtistBasic): SongDetailed { const flexColumns = traverseList(item, "flexColumns") const videoId = traverseString(item, "playlistItemData", "videoId")() @@ -98,9 +95,10 @@ export default class SongParser { albumId: traverseString(flexColumns[2], "browseId")(), name: traverseString(flexColumns[2], "runs", "text")(), }, + duration: null, thumbnails: traverseList(item, "thumbnails"), }, - SongDetailed.omit({ duration: true }), + SongDetailed, ) } diff --git a/src/parsers/VideoParser.ts b/src/parsers/VideoParser.ts index 233c18a..01a5145 100644 --- a/src/parsers/VideoParser.ts +++ b/src/parsers/VideoParser.ts @@ -1,4 +1,4 @@ -import { ArtistBasic, VideoDetailed, VideoFull } from "../schemas" +import { ArtistBasic, VideoDetailed, VideoFull } from "../@types/types" import checkType from "../utils/checkType" import traverse from "../utils/traverse" import traverseList from "../utils/traverseList" @@ -46,15 +46,13 @@ export default class VideoParser { } } - public static parseArtistTopVideo( - item: any, - artistBasic: ArtistBasic, - ): Omit { + public static parseArtistTopVideo(item: any, artistBasic: ArtistBasic): VideoDetailed { return { type: "VIDEO", videoId: traverseString(item, "videoId")(), name: traverseString(item, "runs", "text")(), artists: [artistBasic], + duration: null, thumbnails: traverseList(item, "thumbnails"), } } diff --git a/src/schemas.ts b/src/schemas.ts deleted file mode 100644 index 4c4581d..0000000 --- a/src/schemas.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { z } from "zod" - -export type ThumbnailFull = z.infer -export const ThumbnailFull = z.object({ - url: z.string(), - width: z.number(), - height: z.number(), -}) - -export type ArtistBasic = z.infer -export const ArtistBasic = z.object({ - artistId: z.string(), - name: z.string(), -}) - -export type AlbumBasic = z.infer -export const AlbumBasic = z.object({ - albumId: z.string(), - name: z.string(), -}) - -export type SongDetailed = z.infer -export const SongDetailed = z.object({ - type: z.literal("SONG"), - videoId: z.string(), - name: z.string(), - artists: z.array(ArtistBasic), - album: AlbumBasic, - duration: z.number(), - thumbnails: z.array(ThumbnailFull), -}) - -export type VideoDetailed = z.infer -export const VideoDetailed = z.object({ - type: z.literal("VIDEO"), - videoId: z.string(), - name: z.string(), - artists: z.array(ArtistBasic), - duration: z.number(), - thumbnails: z.array(ThumbnailFull), -}) - -export type ArtistDetailed = z.infer -export const ArtistDetailed = z.object({ - artistId: z.string(), - name: z.string(), - type: z.literal("ARTIST"), - thumbnails: z.array(ThumbnailFull), -}) - -export type AlbumDetailed = z.infer -export const AlbumDetailed = z.object({ - type: z.literal("ALBUM"), - albumId: z.string(), - playlistId: z.string(), - name: z.string(), - artists: z.array(ArtistBasic), - year: z.number().nullable(), - thumbnails: z.array(ThumbnailFull), -}) - -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), -}) - -export type SongFull = z.infer -export const SongFull = z.object({ - type: z.literal("SONG"), - videoId: z.string(), - name: z.string(), - artists: z.array(ArtistBasic), - duration: z.number(), - thumbnails: z.array(ThumbnailFull), - description: z.string(), - formats: z.array(z.any()), - adaptiveFormats: z.array(z.any()), -}) - -export type VideoFull = z.infer -export const VideoFull = z.object({ - type: z.literal("VIDEO"), - videoId: z.string(), - name: z.string(), - artists: z.array(ArtistBasic), - duration: z.number(), - thumbnails: z.array(ThumbnailFull), - description: z.string(), - unlisted: z.boolean(), - familySafe: z.boolean(), - paid: z.boolean(), - tags: z.array(z.string()), -}) - -export type ArtistFull = z.infer -export const ArtistFull = z.object({ - artistId: z.string(), - name: z.string(), - type: z.literal("ARTIST"), - thumbnails: z.array(ThumbnailFull), - description: z.string(), - topSongs: z.array(SongDetailed.omit({ duration: true })), - topAlbums: z.array(AlbumDetailed), - topSingles: z.array(AlbumDetailed), - topVideos: z.array(VideoDetailed.omit({ duration: true })), - featuredOn: z.array(PlaylistDetailed), - similarArtists: z.array(ArtistDetailed), -}) - -export type AlbumFull = z.infer -export const AlbumFull = z.object({ - type: z.literal("ALBUM"), - albumId: z.string(), - playlistId: z.string(), - name: z.string(), - artists: z.array(ArtistBasic), - year: z.number().nullable(), - thumbnails: z.array(ThumbnailFull), - description: z.string(), - songs: z.array(SongDetailed), -}) - -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), -}) - -export type SearchResult = z.infer -export const SearchResult = SongDetailed.or(VideoDetailed) - .or(AlbumDetailed) - .or(ArtistDetailed) - .or(PlaylistDetailed) diff --git a/src/tests/traversing.spec.ts b/src/tests/traversing.spec.ts index 9f05b0a..27aa0b4 100644 --- a/src/tests/traversing.spec.ts +++ b/src/tests/traversing.spec.ts @@ -1,6 +1,6 @@ +import { arrayOf, Problem, Type, type } from "arktype" import { equal } from "assert" import { afterAll, beforeAll, describe, it } from "bun:test" -import { z } from "zod" import { AlbumDetailed, @@ -9,21 +9,22 @@ import { ArtistFull, PlaylistDetailed, PlaylistFull, + SearchResult, SongDetailed, SongFull, VideoDetailed, VideoFull, -} from "../schemas" +} from "../@types/types" import YTMusic from "../YTMusic" -const errors = []>[] +const errors: Problem[] = [] const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"] -const expect = (data: any, schema: z.Schema) => { - const result = schema.safeParse(data) - if (!result.success && "error" in result) { - errors.push(result.error) +const expect = (data: any, type: Type) => { + const result = type(data) + if (!result.data && "problems" in result) { + errors.push(...result.problems!) } - equal(result.success, true) + equal(!!result.data, true) } const ytmusic = new YTMusic() @@ -33,45 +34,37 @@ queries.forEach(query => { describe("Query: " + query, () => { it("Search suggestions", async () => { const suggestions = await ytmusic.getSearchSuggestions(query) - expect(suggestions, z.array(z.string())) + expect(suggestions, type("string[]")) }) it("Search Songs", async () => { const songs = await ytmusic.searchSongs(query) - expect(songs, z.array(SongDetailed)) + expect(songs, arrayOf(SongDetailed)) }) it("Search Videos", async () => { const videos = await ytmusic.searchVideos(query) - expect(videos, z.array(VideoDetailed)) + expect(videos, arrayOf(VideoDetailed)) }) it("Search Artists", async () => { const artists = await ytmusic.searchArtists(query) - expect(artists, z.array(ArtistDetailed)) + expect(artists, arrayOf(ArtistDetailed)) }) it("Search Albums", async () => { const albums = await ytmusic.searchAlbums(query) - expect(albums, z.array(AlbumDetailed)) + expect(albums, arrayOf(AlbumDetailed)) }) it("Search Playlists", async () => { const playlists = await ytmusic.searchPlaylists(query) - expect(playlists, z.array(PlaylistDetailed)) + expect(playlists, arrayOf(PlaylistDetailed)) }) it("Search All", async () => { const results = await ytmusic.search(query) - expect( - results, - z.array( - AlbumDetailed.or(ArtistDetailed) - .or(PlaylistDetailed) - .or(SongDetailed) - .or(VideoDetailed), - ), - ) + expect(results, arrayOf(SearchResult)) }) it("Get details of the first song result", async () => { @@ -95,13 +88,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, z.array(SongDetailed)) + expect(songs, arrayOf(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, z.array(AlbumDetailed)) + expect(albums, arrayOf(AlbumDetailed)) }) it("Get details of the first album result", async () => { @@ -119,7 +112,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, z.array(VideoDetailed)) + expect(videos, arrayOf(VideoDetailed)) }) }) }) diff --git a/src/utils/checkType.ts b/src/utils/checkType.ts index 267aa23..916bbcf 100644 --- a/src/utils/checkType.ts +++ b/src/utils/checkType.ts @@ -1,17 +1,16 @@ -import { z } from "zod" -import zodtojson from "zod-to-json-schema" +import { Type } from "arktype" -export default (data: z.infer, schema: T): z.infer => { - const result = schema.safeParse(data) - if (result.success) { - return data +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 schema, 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( { - schema: zodtojson(schema), + type: type.definition, data, error: result.error, },