🔀 Merge pull request #26 from SAROND-DEV/feat/get-dinamic-content

feat: Implement HomePageContent Retrieval and Parsing in YTMusic 
This commit is contained in:
Zechariah 2024-01-16 09:32:47 +08:00 committed by GitHub
commit 86eda39ff4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 229 additions and 13 deletions

View File

@ -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))),
),
"[]",
],
})

View File

@ -6,6 +6,7 @@ import {
AlbumFull, AlbumFull,
ArtistDetailed, ArtistDetailed,
ArtistFull, ArtistFull,
HomePageContent,
PlaylistDetailed, PlaylistDetailed,
PlaylistFull, PlaylistFull,
SearchResult, SearchResult,
@ -14,8 +15,10 @@ import {
VideoDetailed, VideoDetailed,
VideoFull, VideoFull,
} from "./@types/types" } from "./@types/types"
import { FE_MUSIC_HOME } from "./constants"
import AlbumParser from "./parsers/AlbumParser" import AlbumParser from "./parsers/AlbumParser"
import ArtistParser from "./parsers/ArtistParser" import ArtistParser from "./parsers/ArtistParser"
import Parser from "./parsers/Parser"
import PlaylistParser from "./parsers/PlaylistParser" import PlaylistParser from "./parsers/PlaylistParser"
import SearchParser from "./parsers/SearchParser" import SearchParser from "./parsers/SearchParser"
import SongParser from "./parsers/SongParser" import SongParser from "./parsers/SongParser"
@ -498,4 +501,30 @@ export default class YTMusic {
return songs.map(VideoParser.parsePlaylistVideo) return songs.map(VideoParser.parsePlaylistVideo)
} }
/**
* Get content for the home page.
*
* @returns Mixed HomePageContent
*/
public async getHome(): Promise<HomePageContent[]> {
const results: HomePageContent[] = []
const page = await this.constructRequest("browse", { browseId: FE_MUSIC_HOME })
traverseList(page, "contents").forEach(content => {
const parsed = Parser.parseMixedContent(content)
parsed && results.push(parsed)
})
let continuation = traverseString(page, "continuation")
while (continuation) {
const nextPage = await this.constructRequest("browse", {}, { continuation })
traverseList(nextPage, "contents").forEach(content => {
const parsed = Parser.parseMixedContent(content)
parsed && results.push(parsed)
})
continuation = traverseString(nextPage, "continuation")
}
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 { 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
} }
} }

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 { 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,92 @@ export default class Parser {
return +string return +string
} }
} }
/**
* Parses mixed content data into a structured `HomePageContent` object.
*
* This static method takes raw data of mixed content types and attempts to parse it into a
* more structured format suitable for use as home page content. It supports multiple content
* types such as music descriptions, artists, albums, playlists, and songs.
*
* @param {any} data - The raw data to be parsed.
* @returns {HomePageContent | null} A `HomePageContent` object if parsing is successful, or null otherwise.
*/
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 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,
)
}
} }

View File

@ -25,11 +25,12 @@ export default class SongParser {
} }
public static parseSearchResult(item: any): SongDetailed { public static parseSearchResult(item: any): SongDetailed {
const columns = traverseList(item, "flexColumns", "runs").flat() const columns = traverseList(item, "flexColumns", "runs")
const title = columns.find(isTitle) // It is not possible to identify the title and author
const artist = columns.find(isArtist) || columns[1] const title = columns[0]
const album = columns.find(isAlbum) const artist = columns[1]
const album = columns.find(isAlbum) ?? null
const duration = columns.find(isDuration) const duration = columns.find(isDuration)
return checkType( return checkType(
@ -41,11 +42,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,

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[]) => { 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