diff --git a/bun.lockb b/bun.lockb index 1fcdd24..e43cff2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/YTMusic.ts b/src/YTMusic.ts index 393b95c..b99ef96 100644 --- a/src/YTMusic.ts +++ b/src/YTMusic.ts @@ -4,6 +4,7 @@ import { Cookie, CookieJar } from "tough-cookie" import { FE_MUSIC_HOME } from "./constants" import AlbumParser from "./parsers/AlbumParser" import ArtistParser from "./parsers/ArtistParser" +import NextParser from "./parsers/NextParser" import Parser from "./parsers/Parser" import PlaylistParser from "./parsers/PlaylistParser" import SearchParser from "./parsers/SearchParser" @@ -15,6 +16,7 @@ import { ArtistDetailed, ArtistFull, HomeSection, + NextResult, PlaylistDetailed, PlaylistFull, SearchResult, @@ -528,4 +530,31 @@ export default class YTMusic { return sections.map(Parser.parseHomeSection) } + + /** + * Get content for next song. + * + * @param videoId Video ID + * @param listId list ID + * @param params + * + * @returns List of the next song + */ + public async getNext( + videoId: string, + listId: string, + params?: string, + ): Promise { + const data = await this.constructRequest("next", { + enablePersistentPlaylistPanel: true, + isAudioOnly: true, + params: params, + playlistId: listId, + tunerSettingValue: "AUTOMIX_SETTING_NORMAL", + videoId: videoId, + }) + + const contents = traverse(traverseList(data, "tabs", "tabRenderer")[0], "contents") + return contents.map(NextParser.parse) + } } diff --git a/src/index.ts b/src/index.ts index ee6d650..3880412 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export type { VideoDetailed, VideoFull, HomeSection, + NextResult, } from "./types" export default YTMusic diff --git a/src/parsers/NextParser.ts b/src/parsers/NextParser.ts new file mode 100644 index 0000000..e58e2d6 --- /dev/null +++ b/src/parsers/NextParser.ts @@ -0,0 +1,28 @@ +import { ArtistBasic, NextResult } from "../types" +import checkType from "../utils/checkType" +import { traverse, traverseList, traverseString } from "../utils/traverse" + +export default class NextParser { + public static parse(data: any): NextResult { + const nextData = traverseString(data, "playlistPanelVideoRenderer") + const artistData = traverse(nextData, "longBylineText") + const artistBasic: ArtistBasic = { + artistId: traverseString(artistData, "browseId") || null, + name: traverseString(artistData, "text"), + } + + return checkType( + { + index: +traverseString(nextData, "navigationEndpoint", "index"), + selected: !!traverseString(nextData, "selected"), + params: traverseString(nextData, "navigationEndpoint", "params"), + name: traverseString(nextData, "title", "text"), + artist: artistBasic, + playlistId: traverseString(nextData, "navigationEndpoint", "playlistId"), + videoId: traverseString(nextData, "videoId"), + thumbnails: traverseList(nextData, "thumbnails"), + }, + NextResult, + ) + } +} diff --git a/src/parsers/SongParser.ts b/src/parsers/SongParser.ts index 27aca73..9f0958d 100644 --- a/src/parsers/SongParser.ts +++ b/src/parsers/SongParser.ts @@ -26,6 +26,7 @@ export default class SongParser { public static parseSearchResult(item: any): SongDetailed { const columns = traverseList(item, "flexColumns", "runs") + const listData = traverseList(item, "menu", "navigationEndpoint") // It is not possible to identify the title and author const title = columns[0] @@ -50,6 +51,8 @@ export default class SongParser { : null, duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, }, SongDetailed, ) @@ -57,6 +60,7 @@ export default class SongParser { public static parseArtistSong(item: any, artistBasic: ArtistBasic): SongDetailed { const columns = traverseList(item, "flexColumns", "runs").flat() + const listData = traverseList(item, "menu", "navigationEndpoint") const title = columns.find(isTitle) const album = columns.find(isAlbum) @@ -76,6 +80,8 @@ export default class SongParser { : null, duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, }, SongDetailed, ) @@ -83,6 +89,7 @@ export default class SongParser { public static parseArtistTopSong(item: any, artistBasic: ArtistBasic): SongDetailed { const columns = traverseList(item, "flexColumns", "runs").flat() + const listData = traverseList(item, "menu", "navigationEndpoint") const title = columns.find(isTitle) const album = columns.find(isAlbum) @@ -99,6 +106,8 @@ export default class SongParser { }, duration: null, thumbnails: traverseList(item, "thumbnails"), + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, }, SongDetailed, ) @@ -112,6 +121,7 @@ export default class SongParser { ): SongDetailed { const title = traverseList(item, "flexColumns", "runs").find(isTitle) const duration = traverseList(item, "fixedColumns", "runs").find(isDuration) + const listData = traverseList(item, "menu", "navigationEndpoint") return checkType( { @@ -122,6 +132,8 @@ export default class SongParser { album: albumBasic, duration: Parser.parseDuration(duration?.text), thumbnails, + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, }, SongDetailed, ) diff --git a/src/parsers/VideoParser.ts b/src/parsers/VideoParser.ts index b8cd290..37272e5 100644 --- a/src/parsers/VideoParser.ts +++ b/src/parsers/VideoParser.ts @@ -25,6 +25,7 @@ export default class VideoParser { public static parseSearchResult(item: any): VideoDetailed { const columns = traverseList(item, "flexColumns", "runs").flat() + const listData = traverseList(item, "menu", "navigationEndpoint") const title = columns.find(isTitle) const artist = columns.find(isArtist) || columns[1] @@ -40,10 +41,14 @@ export default class VideoParser { }, duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, } } public static parseArtistTopVideo(item: any, artistBasic: ArtistBasic): VideoDetailed { + const listData = traverseList(item, "menu", "navigationEndpoint") + return { type: "VIDEO", videoId: traverseString(item, "videoId"), @@ -51,11 +56,14 @@ export default class VideoParser { artist: artistBasic, duration: null, thumbnails: traverseList(item, "thumbnails"), + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, } } public static parsePlaylistVideo(item: any): VideoDetailed { const columns = traverseList(item, "flexColumns", "runs").flat() + const listData = traverseList(item, "menu", "navigationEndpoint") const title = columns.find(isTitle) || columns[0] const artist = columns.find(isArtist) || columns[1] @@ -76,6 +84,8 @@ export default class VideoParser { }, duration: Parser.parseDuration(duration?.text), thumbnails: traverseList(item, "thumbnails"), + listId: traverseString(listData, "playlistId") || null, + params: traverseString(listData, "params") || null, }, VideoDetailed, ) diff --git a/src/tests/core.spec.ts b/src/tests/core.spec.ts index a7488ee..fd78354 100644 --- a/src/tests/core.spec.ts +++ b/src/tests/core.spec.ts @@ -9,6 +9,7 @@ import { AlbumFull, ArtistDetailed, ArtistFull, + NextResult, PlaylistDetailed, PlaylistFull, SearchResult, @@ -20,6 +21,16 @@ import { const errors: ZodError[] = [] const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"] + +const nextQueries: [string, string][] = [ + // videoId listId + ["0-q1KafFCLU", "RDAMVMv7bnOxV4jAc"], + ["3_g2un5M350", "RDAOHSpo_Uv9STIRtF73zMywLg"], + ["k9r74T2d5zc", "RDAO58VCzmAxo6veMZY49UqvQw"], + ["2JFLxtcMQBM", "RDAOASZzAB4N6PSfsOwzAhxYyQ"], + ["g9m6oj9JvnE", "RDAOjWghT6s3mcT8SVl7jgbCXw"], +] + const expect = (data: any, type: ZodType) => { const result = type.safeParse(data) @@ -39,7 +50,7 @@ const expect = (data: any, type: ZodType) => { const ytmusic = new YTMusic() beforeAll(() => ytmusic.initialize()) -queries.forEach(query => { +queries.forEach((query, index) => { describe("Query: " + query, () => { it("Search suggestions", async () => { const suggestions = await ytmusic.getSearchSuggestions(query) @@ -76,6 +87,11 @@ queries.forEach(query => { expect(results, z.array(SearchResult)) }) + it("Get Next", async () => { + const results = await ytmusic.getNext(...nextQueries[index]!) + expect(results, z.array(NextResult)) + }) + it("Get lyrics of the first song result", async () => { const songs = await ytmusic.searchSongs(query) const lyrics = await ytmusic.getLyrics(songs[0]!.videoId) diff --git a/src/types.ts b/src/types.ts index 8e25c3c..c533603 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,9 @@ export const SongDetailed = z album: z.nullable(AlbumBasic), duration: z.nullable(z.number()), thumbnails: z.array(ThumbnailFull), + // for unavailable content it will be null + listId: z.nullable(z.string()), + params: z.nullable(z.string()), }) .strict() @@ -47,6 +50,9 @@ export const VideoDetailed = z artist: ArtistBasic, duration: z.nullable(z.number()), thumbnails: z.array(ThumbnailFull), + // for unavailable content it will be null + listId: z.nullable(z.string()), + params: z.nullable(z.string()), }) .strict() @@ -172,3 +178,17 @@ export const HomeSection = z contents: z.array(z.union([AlbumDetailed, PlaylistDetailed, SongDetailed])), }) .strict() + +export type NextResult = z.infer +export const NextResult = z + .object({ + index: z.number(), + name: z.string(), + artist: ArtistBasic, + playlistId: z.string(), + videoId: z.string(), + selected: z.boolean(), + params: z.string(), + thumbnails: z.array(ThumbnailFull), + }) + .strict() diff --git a/tsconfig.json b/tsconfig.json index 0e97510..d3f1ca6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,16 +4,15 @@ "target": "esnext", "lib": ["esnext"], "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, "allowSyntheticDefaultImports": true, /** Project */ "rootDir": "src", - "outDir": "dist/esm", + "outDir": "dist", + // "typeRoots": ["./node_modules/@types", "./src/@types"], "types": ["bun-types"], - "declaration": true, - "declarationDir": "dist/types", /** Type Checking */ "strict": true, @@ -30,16 +29,14 @@ "allowUnusedLabels": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, - "exactOptionalPropertyTypes": true, /** Other */ - // "noEmit": false, + // "noEmit": true, // "allowJs": true, // "jsx": "react-jsx", - "skipLibCheck": true + "skipLibCheck": true, // "isolatedModules": true, // "incremental": true }, - "include": ["src"], - "exclude": ["src/tests"] + "include": ["src"] }