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:
SAROND 2024-01-15 19:34:51 +04:00
parent f59cfc7f29
commit 6ed8230448
9 changed files with 200 additions and 10 deletions

View File

@ -25,7 +25,7 @@ export const SongDetailed = type({
videoId: "string",
name: "string",
artist: ArtistBasic,
album: AlbumBasic,
album: union(AlbumBasic, "null"),
duration: "number|null",
thumbnails: [ThumbnailFull, "[]"],
})
@ -135,3 +135,23 @@ 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))),
),
"[]",
],
})

View File

@ -6,6 +6,7 @@ import {
AlbumFull,
ArtistDetailed,
ArtistFull,
HomePageContent,
PlaylistDetailed,
PlaylistFull,
SearchResult,
@ -21,6 +22,8 @@ import SearchParser from "./parsers/SearchParser"
import SongParser from "./parsers/SongParser"
import VideoParser from "./parsers/VideoParser"
import { traverse, traverseList, traverseString } from "./utils/traverse"
import { FE_MUSIC_HOME } from "./constants"
import Parser from "./parsers/Parser"
export default class YTMusic {
private cookiejar: CookieJar
@ -498,4 +501,15 @@ export default class YTMusic {
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
}
}

7
src/constants.ts Normal file
View File

@ -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"

View File

@ -39,21 +39,25 @@ export default class AlbumParser {
public static parseSearchResult(item: any): AlbumDetailed {
const columns = traverseList(item, "flexColumns", "runs").flat()
columns.push(item)
// No specific way to identify the title
const title = columns[0]
const artist = columns.find(isArtist) || columns[3]
const playlistId =
traverseString(item, "overlay", "playlistId") ||
traverseString(item, "thumbnailOverlay", "playlistId")
return checkType(
{
type: "ALBUM",
albumId: traverseList(item, "browseId").at(-1),
playlistId: traverseString(item, "overlay", "playlistId"),
playlistId,
artist: {
name: traverseString(artist, "text"),
artistId: traverseString(artist, "browseId") || null,
},
year: AlbumParser.processYear(columns.at(-1).text),
year: AlbumParser.processYear(columns.at(-1)?.text),
name: traverseString(title, "text"),
thumbnails: traverseList(item, "thumbnails"),
},
@ -92,6 +96,6 @@ export default class AlbumParser {
}
private static processYear(year: string) {
return year.match(/^\d{4}$/) ? +year : null
return year && year.match(/^\d{4}$/) ? +year : null
}
}

View File

@ -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 {
public static parseDuration(time: string) {
if (!time) return null
const [seconds, minutes, hours] = time
.split(":")
.reverse()
@ -25,4 +35,82 @@ export default class Parser {
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 }
}
}

View File

@ -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 { isArtist } from "../utils/filters"
import { traverse, traverseList, traverseString } from "../utils/traverse"
@ -62,4 +62,16 @@ export default class PlaylistParser {
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,
)
}
}

View File

@ -29,7 +29,7 @@ export default class SongParser {
const title = columns.find(isTitle)
const artist = columns.find(isArtist) || columns[1]
const album = columns.find(isAlbum)
const album = columns.find(isAlbum) ?? null
const duration = columns.find(isDuration)
return checkType(
@ -41,11 +41,11 @@ export default class SongParser {
name: traverseString(artist, "text"),
artistId: traverseString(artist, "browseId") || null,
},
album: {
album: album && {
name: traverseString(album, "text"),
albumId: traverseString(album, "browseId"),
},
duration: Parser.parseDuration(duration.text),
duration: Parser.parseDuration(duration?.text),
thumbnails: traverseList(item, "thumbnails"),
},
SongDetailed,

43
src/tests/getHome.spec.ts Normal file
View File

@ -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))

View File

@ -1,9 +1,10 @@
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 = []
if (data instanceof Object && key in data) {
res.push(data[key])
if (deadEnd) return res.length === 1 ? res[0] : res
}
if (data instanceof Array) {
@ -20,8 +21,9 @@ export const traverse = (data: any, ...keys: string[]) => {
}
let value = data
const lastKey = keys.at(-1)
for (const key of keys) {
value = again(value, key)
value = again(value, key, lastKey === key)
}
return value