feat(YTMusic): implement HomePageContent retrieval and parsing
Added functionality to retrieve and parse the home page content in YTMusic class. The `getHome` method fetches home page data and uses the new `Parser.parseMixedContent` method to parse the content into `HomePageContent` type. Also, updated AlbumParser to handle potential null values more gracefully and added PlaylistWatch type parsing. - Added `FE_MUSIC_HOME` constant for home page ID. - Implemented `getHome` method in `YTMusic` class. - Created `parseMixedContent` in `Parser` class. - Updated `AlbumParser` and `SongParser` to handle nullable fields. - Added `PlaylistWatch` type and parsing in `PlaylistParser`. - Added tests for the new home page content feature.
This commit is contained in:
parent
f59cfc7f29
commit
6ed8230448
|
|
@ -25,7 +25,7 @@ export const SongDetailed = type({
|
||||||
videoId: "string",
|
videoId: "string",
|
||||||
name: "string",
|
name: "string",
|
||||||
artist: ArtistBasic,
|
artist: ArtistBasic,
|
||||||
album: AlbumBasic,
|
album: union(AlbumBasic, "null"),
|
||||||
duration: "number|null",
|
duration: "number|null",
|
||||||
thumbnails: [ThumbnailFull, "[]"],
|
thumbnails: [ThumbnailFull, "[]"],
|
||||||
})
|
})
|
||||||
|
|
@ -135,3 +135,23 @@ export const SearchResult = union(
|
||||||
SongDetailed,
|
SongDetailed,
|
||||||
union(VideoDetailed, union(AlbumDetailed, union(ArtistDetailed, PlaylistDetailed))),
|
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))),
|
||||||
|
),
|
||||||
|
"[]",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
AlbumFull,
|
AlbumFull,
|
||||||
ArtistDetailed,
|
ArtistDetailed,
|
||||||
ArtistFull,
|
ArtistFull,
|
||||||
|
HomePageContent,
|
||||||
PlaylistDetailed,
|
PlaylistDetailed,
|
||||||
PlaylistFull,
|
PlaylistFull,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
|
|
@ -21,6 +22,8 @@ import SearchParser from "./parsers/SearchParser"
|
||||||
import SongParser from "./parsers/SongParser"
|
import SongParser from "./parsers/SongParser"
|
||||||
import VideoParser from "./parsers/VideoParser"
|
import VideoParser from "./parsers/VideoParser"
|
||||||
import { traverse, traverseList, traverseString } from "./utils/traverse"
|
import { traverse, traverseList, traverseString } from "./utils/traverse"
|
||||||
|
import { FE_MUSIC_HOME } from "./constants"
|
||||||
|
import Parser from "./parsers/Parser"
|
||||||
|
|
||||||
export default class YTMusic {
|
export default class YTMusic {
|
||||||
private cookiejar: CookieJar
|
private cookiejar: CookieJar
|
||||||
|
|
@ -498,4 +501,15 @@ export default class YTMusic {
|
||||||
|
|
||||||
return songs.map(VideoParser.parsePlaylistVideo)
|
return songs.map(VideoParser.parsePlaylistVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getHome(): Promise<HomePageContent[]> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +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",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FE_MUSIC_HOME = "FEmusic_home"
|
||||||
|
|
@ -39,21 +39,25 @@ export default class AlbumParser {
|
||||||
|
|
||||||
public static parseSearchResult(item: any): AlbumDetailed {
|
public static parseSearchResult(item: any): AlbumDetailed {
|
||||||
const columns = traverseList(item, "flexColumns", "runs").flat()
|
const columns = traverseList(item, "flexColumns", "runs").flat()
|
||||||
|
columns.push(item)
|
||||||
|
|
||||||
// No specific way to identify the title
|
// No specific way to identify the title
|
||||||
const title = columns[0]
|
const title = columns[0]
|
||||||
const artist = columns.find(isArtist) || columns[3]
|
const artist = columns.find(isArtist) || columns[3]
|
||||||
|
const playlistId =
|
||||||
|
traverseString(item, "overlay", "playlistId") ||
|
||||||
|
traverseString(item, "thumbnailOverlay", "playlistId")
|
||||||
|
|
||||||
return checkType(
|
return checkType(
|
||||||
{
|
{
|
||||||
type: "ALBUM",
|
type: "ALBUM",
|
||||||
albumId: traverseList(item, "browseId").at(-1),
|
albumId: traverseList(item, "browseId").at(-1),
|
||||||
playlistId: traverseString(item, "overlay", "playlistId"),
|
playlistId,
|
||||||
artist: {
|
artist: {
|
||||||
name: traverseString(artist, "text"),
|
name: traverseString(artist, "text"),
|
||||||
artistId: traverseString(artist, "browseId") || null,
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
},
|
},
|
||||||
year: AlbumParser.processYear(columns.at(-1).text),
|
year: AlbumParser.processYear(columns.at(-1)?.text),
|
||||||
name: traverseString(title, "text"),
|
name: traverseString(title, "text"),
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
},
|
},
|
||||||
|
|
@ -92,6 +96,6 @@ export default class AlbumParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static processYear(year: string) {
|
private static processYear(year: string) {
|
||||||
return year.match(/^\d{4}$/) ? +year : null
|
return year && year.match(/^\d{4}$/) ? +year : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
|
import { HomePageContent } from "../@types/types"
|
||||||
|
import { PageType } from "../constants"
|
||||||
|
import { traverse, traverseList } from "../utils/traverse"
|
||||||
|
import AlbumParser from "./AlbumParser"
|
||||||
|
import ArtistParser from "./ArtistParser"
|
||||||
|
import PlaylistParser from "./PlaylistParser"
|
||||||
|
import SongParser from "./SongParser"
|
||||||
|
|
||||||
export default class Parser {
|
export default class Parser {
|
||||||
public static parseDuration(time: string) {
|
public static parseDuration(time: string) {
|
||||||
|
if (!time) return null
|
||||||
|
|
||||||
const [seconds, minutes, hours] = time
|
const [seconds, minutes, hours] = time
|
||||||
.split(":")
|
.split(":")
|
||||||
.reverse()
|
.reverse()
|
||||||
|
|
@ -25,4 +35,82 @@ export default class Parser {
|
||||||
return +string
|
return +string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static parseMixedContent(data: any): HomePageContent | null {
|
||||||
|
const key = Object.keys(data)[0]
|
||||||
|
if (!key) throw new Error("Invalid content")
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ArtistBasic, PlaylistDetailed, PlaylistFull } from "../@types/types"
|
import { ArtistBasic, PlaylistDetailed, PlaylistFull, PlaylistWatch } from "../@types/types"
|
||||||
import checkType from "../utils/checkType"
|
import checkType from "../utils/checkType"
|
||||||
import { isArtist } from "../utils/filters"
|
import { isArtist } from "../utils/filters"
|
||||||
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
import { traverse, traverseList, traverseString } from "../utils/traverse"
|
||||||
|
|
@ -62,4 +62,16 @@ export default class PlaylistParser {
|
||||||
PlaylistDetailed,
|
PlaylistDetailed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static parseWatchPlaylist(item: any): PlaylistWatch {
|
||||||
|
return checkType(
|
||||||
|
{
|
||||||
|
type: "PLAYLIST",
|
||||||
|
playlistId: traverseString(item, "navigationEndpoint", "playlistId"),
|
||||||
|
name: traverseString(item, "runs", "text"),
|
||||||
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
|
},
|
||||||
|
PlaylistWatch,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default class SongParser {
|
||||||
|
|
||||||
const title = columns.find(isTitle)
|
const title = columns.find(isTitle)
|
||||||
const artist = columns.find(isArtist) || columns[1]
|
const artist = columns.find(isArtist) || columns[1]
|
||||||
const album = columns.find(isAlbum)
|
const album = columns.find(isAlbum) ?? null
|
||||||
const duration = columns.find(isDuration)
|
const duration = columns.find(isDuration)
|
||||||
|
|
||||||
return checkType(
|
return checkType(
|
||||||
|
|
@ -41,11 +41,11 @@ export default class SongParser {
|
||||||
name: traverseString(artist, "text"),
|
name: traverseString(artist, "text"),
|
||||||
artistId: traverseString(artist, "browseId") || null,
|
artistId: traverseString(artist, "browseId") || null,
|
||||||
},
|
},
|
||||||
album: {
|
album: album && {
|
||||||
name: traverseString(album, "text"),
|
name: traverseString(album, "text"),
|
||||||
albumId: traverseString(album, "browseId"),
|
albumId: traverseString(album, "browseId"),
|
||||||
},
|
},
|
||||||
duration: Parser.parseDuration(duration.text),
|
duration: Parser.parseDuration(duration?.text),
|
||||||
thumbnails: traverseList(item, "thumbnails"),
|
thumbnails: traverseList(item, "thumbnails"),
|
||||||
},
|
},
|
||||||
SongDetailed,
|
SongDetailed,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { arrayOf, Problem, Type } from "arktype"
|
||||||
|
import { equal } from "assert"
|
||||||
|
import { afterAll, beforeEach, describe, it } from "bun:test"
|
||||||
|
|
||||||
|
import { HomePageContent } from "../@types/types"
|
||||||
|
import { FE_MUSIC_HOME } from "../constants"
|
||||||
|
import YTMusic from "../YTMusic"
|
||||||
|
|
||||||
|
const errors: Problem[] = []
|
||||||
|
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!)
|
||||||
|
} else {
|
||||||
|
const empty = JSON.stringify(result.data).match(/"\w+":""/g)
|
||||||
|
if (empty) {
|
||||||
|
console.log(result.data, empty)
|
||||||
|
}
|
||||||
|
equal(empty, null)
|
||||||
|
}
|
||||||
|
equal(result.problems, undefined)
|
||||||
|
}
|
||||||
|
const ytmusic = new YTMusic()
|
||||||
|
beforeEach(() => {
|
||||||
|
const index = 0
|
||||||
|
return ytmusic.initialize(configs[index])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`Query: ${FE_MUSIC_HOME}`, () => {
|
||||||
|
configs.forEach(config => {
|
||||||
|
it(`Get ${config.GL} ${config.HL}`, async () => {
|
||||||
|
const page = await ytmusic.getHome()
|
||||||
|
expect(page, arrayOf(HomePageContent))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => console.log("Issues:", errors))
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
export const traverse = (data: any, ...keys: string[]) => {
|
export const traverse = (data: any, ...keys: string[]) => {
|
||||||
const again = (data: any, key: string): any => {
|
const again = (data: any, key: string, deadEnd = false): any => {
|
||||||
const res = []
|
const res = []
|
||||||
|
|
||||||
if (data instanceof Object && key in data) {
|
if (data instanceof Object && key in data) {
|
||||||
res.push(data[key])
|
res.push(data[key])
|
||||||
|
if (deadEnd) return res.length === 1 ? res[0] : res
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data instanceof Array) {
|
if (data instanceof Array) {
|
||||||
|
|
@ -20,8 +21,9 @@ export const traverse = (data: any, ...keys: string[]) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let value = data
|
let value = data
|
||||||
|
const lastKey = keys.at(-1)
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
value = again(value, key)
|
value = again(value, key, lastKey === key)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue