🔀 Merge pull request #26 from SAROND-DEV/feat/get-dinamic-content
feat: Implement HomePageContent Retrieval and Parsing in YTMusic ✨
This commit is contained in:
commit
86eda39ff4
|
|
@ -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))),
|
||||
),
|
||||
"[]",
|
||||
],
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
AlbumFull,
|
||||
ArtistDetailed,
|
||||
ArtistFull,
|
||||
HomePageContent,
|
||||
PlaylistDetailed,
|
||||
PlaylistFull,
|
||||
SearchResult,
|
||||
|
|
@ -14,8 +15,10 @@ import {
|
|||
VideoDetailed,
|
||||
VideoFull,
|
||||
} from "./@types/types"
|
||||
import { FE_MUSIC_HOME } from "./constants"
|
||||
import AlbumParser from "./parsers/AlbumParser"
|
||||
import ArtistParser from "./parsers/ArtistParser"
|
||||
import Parser from "./parsers/Parser"
|
||||
import PlaylistParser from "./parsers/PlaylistParser"
|
||||
import SearchParser from "./parsers/SearchParser"
|
||||
import SongParser from "./parsers/SongParser"
|
||||
|
|
@ -498,4 +501,30 @@ export default class YTMusic {
|
|||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,92 @@ export default class Parser {
|
|||
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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ export default class SongParser {
|
|||
}
|
||||
|
||||
public static parseSearchResult(item: any): SongDetailed {
|
||||
const columns = traverseList(item, "flexColumns", "runs").flat()
|
||||
const columns = traverseList(item, "flexColumns", "runs")
|
||||
|
||||
const title = columns.find(isTitle)
|
||||
const artist = columns.find(isArtist) || columns[1]
|
||||
const album = columns.find(isAlbum)
|
||||
// It is not possible to identify the title and author
|
||||
const title = columns[0]
|
||||
const artist = columns[1]
|
||||
const album = columns.find(isAlbum) ?? null
|
||||
const duration = columns.find(isDuration)
|
||||
|
||||
return checkType(
|
||||
|
|
@ -41,11 +42,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,
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue