Skip to content

Commit 86eda39

Browse files
authored
🔀 Merge pull request #26 from SAROND-DEV/feat/get-dinamic-content
feat: Implement HomePageContent Retrieval and Parsing in YTMusic ✨
2 parents f59cfc7 + f9664fe commit 86eda39

File tree

9 files changed

+229
-13
lines changed

9 files changed

+229
-13
lines changed

‎src/@types/types.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const SongDetailed = type({
2525
videoId: "string",
2626
name: "string",
2727
artist: ArtistBasic,
28-
album: AlbumBasic,
28+
album: union(AlbumBasic, "null"),
2929
duration: "number|null",
3030
thumbnails: [ThumbnailFull, "[]"],
3131
})
@@ -135,3 +135,23 @@ export const SearchResult = union(
135135
SongDetailed,
136136
union(VideoDetailed, union(AlbumDetailed, union(ArtistDetailed, PlaylistDetailed))),
137137
)
138+
139+
export type PlaylistWatch = typeof PlaylistWatch.infer
140+
export const PlaylistWatch = type({
141+
type: '"PLAYLIST"',
142+
playlistId: "string",
143+
name: "string",
144+
thumbnails: [ThumbnailFull, "[]"],
145+
})
146+
147+
export type HomePageContent = typeof HomePageContent.infer
148+
export const HomePageContent = type({
149+
title: "string",
150+
contents: [
151+
union(
152+
PlaylistWatch,
153+
union(ArtistDetailed, union(AlbumDetailed, union(PlaylistDetailed, SongDetailed))),
154+
),
155+
"[]",
156+
],
157+
})

‎src/YTMusic.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
AlbumFull,
77
ArtistDetailed,
88
ArtistFull,
9+
HomePageContent,
910
PlaylistDetailed,
1011
PlaylistFull,
1112
SearchResult,
@@ -14,8 +15,10 @@ import {
1415
VideoDetailed,
1516
VideoFull,
1617
} from "./@types/types"
18+
import { FE_MUSIC_HOME } from "./constants"
1719
import AlbumParser from "./parsers/AlbumParser"
1820
import ArtistParser from "./parsers/ArtistParser"
21+
import Parser from "./parsers/Parser"
1922
import PlaylistParser from "./parsers/PlaylistParser"
2023
import SearchParser from "./parsers/SearchParser"
2124
import SongParser from "./parsers/SongParser"
@@ -498,4 +501,30 @@ export default class YTMusic {
498501

499502
return songs.map(VideoParser.parsePlaylistVideo)
500503
}
504+
505+
/**
506+
* Get content for the home page.
507+
*
508+
* @returns Mixed HomePageContent
509+
*/
510+
public async getHome(): Promise<HomePageContent[]> {
511+
const results: HomePageContent[] = []
512+
const page = await this.constructRequest("browse", { browseId: FE_MUSIC_HOME })
513+
traverseList(page, "contents").forEach(content => {
514+
const parsed = Parser.parseMixedContent(content)
515+
parsed && results.push(parsed)
516+
})
517+
518+
let continuation = traverseString(page, "continuation")
519+
while (continuation) {
520+
const nextPage = await this.constructRequest("browse", {}, { continuation })
521+
traverseList(nextPage, "contents").forEach(content => {
522+
const parsed = Parser.parseMixedContent(content)
523+
parsed && results.push(parsed)
524+
})
525+
continuation = traverseString(nextPage, "continuation")
526+
}
527+
528+
return results
529+
}
501530
}

‎src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export enum PageType {
2+
MUSIC_PAGE_TYPE_ALBUM = "MUSIC_PAGE_TYPE_ALBUM",
3+
MUSIC_PAGE_TYPE_ARTIST = "MUSIC_PAGE_TYPE_ARTIST",
4+
MUSIC_PAGE_TYPE_PLAYLIST = "MUSIC_PAGE_TYPE_PLAYLIST",
5+
}
6+
7+
export const FE_MUSIC_HOME = "FEmusic_home"

‎src/parsers/AlbumParser.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,25 @@ export default class AlbumParser {
3939

4040
public static parseSearchResult(item: any): AlbumDetailed {
4141
const columns = traverseList(item, "flexColumns", "runs").flat()
42+
columns.push(item)
4243

4344
// No specific way to identify the title
4445
const title = columns[0]
4546
const artist = columns.find(isArtist) || columns[3]
47+
const playlistId =
48+
traverseString(item, "overlay", "playlistId") ||
49+
traverseString(item, "thumbnailOverlay", "playlistId")
4650

4751
return checkType(
4852
{
4953
type: "ALBUM",
5054
albumId: traverseList(item, "browseId").at(-1),
51-
playlistId: traverseString(item, "overlay", "playlistId"),
55+
playlistId,
5256
artist: {
5357
name: traverseString(artist, "text"),
5458
artistId: traverseString(artist, "browseId") || null,
5559
},
56-
year: AlbumParser.processYear(columns.at(-1).text),
60+
year: AlbumParser.processYear(columns.at(-1)?.text),
5761
name: traverseString(title, "text"),
5862
thumbnails: traverseList(item, "thumbnails"),
5963
},
@@ -92,6 +96,6 @@ export default class AlbumParser {
9296
}
9397

9498
private static processYear(year: string) {
95-
return year.match(/^\d{4}$/) ? +year : null
99+
return year && year.match(/^\d{4}$/) ? +year : null
96100
}
97101
}

‎src/parsers/Parser.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
import { HomePageContent } from "../@types/types"
2+
import { PageType } from "../constants"
3+
import { traverse, traverseList } from "../utils/traverse"
4+
import AlbumParser from "./AlbumParser"
5+
import ArtistParser from "./ArtistParser"
6+
import PlaylistParser from "./PlaylistParser"
7+
import SongParser from "./SongParser"
8+
19
export default class Parser {
210
public static parseDuration(time: string) {
11+
if (!time) return null
12+
313
const [seconds, minutes, hours] = time
414
.split(":")
515
.reverse()
@@ -25,4 +35,92 @@ export default class Parser {
2535
return +string
2636
}
2737
}
38+
39+
/**
40+
* Parses mixed content data into a structured `HomePageContent` object.
41+
*
42+
* This static method takes raw data of mixed content types and attempts to parse it into a
43+
* more structured format suitable for use as home page content. It supports multiple content
44+
* types such as music descriptions, artists, albums, playlists, and songs.
45+
*
46+
* @param {any} data - The raw data to be parsed.
47+
* @returns {HomePageContent | null} A `HomePageContent` object if parsing is successful, or null otherwise.
48+
*/
49+
public static parseMixedContent(data: any): HomePageContent | null {
50+
const key = Object.keys(data)[0]
51+
if (!key) throw new Error("Invalid content")
52+
53+
const result = data[key]
54+
const musicDescriptionShelfRenderer = traverse(result, "musicDescriptionShelfRenderer")
55+
56+
if (musicDescriptionShelfRenderer && !Array.isArray(musicDescriptionShelfRenderer)) {
57+
return {
58+
title: traverse(musicDescriptionShelfRenderer, "header", "title", "text"),
59+
contents: traverseList(
60+
musicDescriptionShelfRenderer,
61+
"description",
62+
"runs",
63+
"text",
64+
),
65+
}
66+
}
67+
68+
if (!Array.isArray(result.contents)) {
69+
return null
70+
}
71+
72+
const title = traverse(result, "header", "title", "text")
73+
const contents: HomePageContent["contents"] = []
74+
result.contents.forEach((content: any) => {
75+
const musicTwoRowItemRenderer = traverse(content, "musicTwoRowItemRenderer")
76+
if (musicTwoRowItemRenderer && !Array.isArray(musicTwoRowItemRenderer)) {
77+
const pageType = traverse(
78+
result,
79+
"navigationEndpoint",
80+
"browseEndpoint",
81+
"browseEndpointContextSupportedConfigs",
82+
"browseEndpointContextMusicConfig",
83+
"pageType",
84+
)
85+
const playlistId = traverse(
86+
content,
87+
"navigationEndpoint",
88+
"watchPlaylistEndpoint",
89+
"playlistId",
90+
)
91+
92+
switch (pageType) {
93+
case PageType.MUSIC_PAGE_TYPE_ARTIST:
94+
contents.push(ArtistParser.parseSearchResult(content))
95+
break
96+
case PageType.MUSIC_PAGE_TYPE_ALBUM:
97+
contents.push(AlbumParser.parseSearchResult(content))
98+
break
99+
case PageType.MUSIC_PAGE_TYPE_PLAYLIST:
100+
contents.push(PlaylistParser.parseSearchResult(content))
101+
break
102+
default:
103+
if (playlistId) {
104+
contents.push(PlaylistParser.parseWatchPlaylist(content))
105+
} else {
106+
contents.push(SongParser.parseSearchResult(content))
107+
}
108+
}
109+
} else {
110+
const musicResponsiveListItemRenderer = traverse(
111+
content,
112+
"musicResponsiveListItemRenderer",
113+
)
114+
115+
if (
116+
musicResponsiveListItemRenderer &&
117+
!Array.isArray(musicResponsiveListItemRenderer)
118+
) {
119+
contents.push(SongParser.parseSearchResult(musicResponsiveListItemRenderer))
120+
}
121+
}
122+
})
123+
124+
return { title, contents }
125+
}
28126
}

‎src/parsers/PlaylistParser.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArtistBasic, PlaylistDetailed, PlaylistFull } from "../@types/types"
1+
import { ArtistBasic, PlaylistDetailed, PlaylistFull, PlaylistWatch } from "../@types/types"
22
import checkType from "../utils/checkType"
33
import { isArtist } from "../utils/filters"
44
import { traverse, traverseList, traverseString } from "../utils/traverse"
@@ -62,4 +62,16 @@ export default class PlaylistParser {
6262
PlaylistDetailed,
6363
)
6464
}
65+
66+
public static parseWatchPlaylist(item: any): PlaylistWatch {
67+
return checkType(
68+
{
69+
type: "PLAYLIST",
70+
playlistId: traverseString(item, "navigationEndpoint", "playlistId"),
71+
name: traverseString(item, "runs", "text"),
72+
thumbnails: traverseList(item, "thumbnails"),
73+
},
74+
PlaylistWatch,
75+
)
76+
}
6577
}

‎src/parsers/SongParser.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ export default class SongParser {
2525
}
2626

2727
public static parseSearchResult(item: any): SongDetailed {
28-
const columns = traverseList(item, "flexColumns", "runs").flat()
28+
const columns = traverseList(item, "flexColumns", "runs")
2929

30-
const title = columns.find(isTitle)
31-
const artist = columns.find(isArtist) || columns[1]
32-
const album = columns.find(isAlbum)
30+
// It is not possible to identify the title and author
31+
const title = columns[0]
32+
const artist = columns[1]
33+
const album = columns.find(isAlbum) ?? null
3334
const duration = columns.find(isDuration)
3435

3536
return checkType(
@@ -41,11 +42,11 @@ export default class SongParser {
4142
name: traverseString(artist, "text"),
4243
artistId: traverseString(artist, "browseId") || null,
4344
},
44-
album: {
45+
album: album && {
4546
name: traverseString(album, "text"),
4647
albumId: traverseString(album, "browseId"),
4748
},
48-
duration: Parser.parseDuration(duration.text),
49+
duration: Parser.parseDuration(duration?.text),
4950
thumbnails: traverseList(item, "thumbnails"),
5051
},
5152
SongDetailed,

‎src/tests/getHome.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { arrayOf, Problem, Type } from "arktype"
2+
import { equal } from "assert"
3+
import { afterAll, beforeEach, describe, it } from "bun:test"
4+
5+
import { HomePageContent } from "../@types/types"
6+
import { FE_MUSIC_HOME } from "../constants"
7+
import YTMusic from "../YTMusic"
8+
9+
const errors: Problem[] = []
10+
const configs = [
11+
{ GL: "RU", HL: "ru" },
12+
{ GL: "US", HL: "en" },
13+
{ GL: "DE", HL: "de" },
14+
]
15+
const expect = (data: any, type: Type) => {
16+
const result = type(data)
17+
if (result.problems?.length) {
18+
errors.push(...result.problems!)
19+
} else {
20+
const empty = JSON.stringify(result.data).match(/"\w+":""/g)
21+
if (empty) {
22+
console.log(result.data, empty)
23+
}
24+
equal(empty, null)
25+
}
26+
equal(result.problems, undefined)
27+
}
28+
const ytmusic = new YTMusic()
29+
beforeEach(() => {
30+
const index = 0
31+
return ytmusic.initialize(configs[index])
32+
})
33+
34+
describe(`Query: ${FE_MUSIC_HOME}`, () => {
35+
configs.forEach(config => {
36+
it(`Get ${config.GL} ${config.HL}`, async () => {
37+
const page = await ytmusic.getHome()
38+
expect(page, arrayOf(HomePageContent))
39+
})
40+
})
41+
})
42+
43+
afterAll(() => console.log("Issues:", errors))

‎src/utils/traverse.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
export const traverse = (data: any, ...keys: string[]) => {
2-
const again = (data: any, key: string): any => {
2+
const again = (data: any, key: string, deadEnd = false): any => {
33
const res = []
44

55
if (data instanceof Object && key in data) {
66
res.push(data[key])
7+
if (deadEnd) return res.length === 1 ? res[0] : res
78
}
89

910
if (data instanceof Array) {
@@ -20,8 +21,9 @@ export const traverse = (data: any, ...keys: string[]) => {
2021
}
2122

2223
let value = data
24+
const lastKey = keys.at(-1)
2325
for (const key of keys) {
24-
value = again(value, key)
26+
value = again(value, key, lastKey === key)
2527
}
2628

2729
return value

0 commit comments

Comments
 (0)