Validate all types before returning to user

This commit is contained in:
Zechariah 2022-03-27 21:56:30 +08:00
parent 892fd5f82f
commit 45692dcaa8
11 changed files with 415 additions and 335 deletions

View File

@ -4,7 +4,7 @@ import axios, { AxiosInstance } from "axios"
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"
import traverse from "./traverse" import traverse from "./utils/traverse"
import VideoParser from "./parsers/VideoParser" import VideoParser from "./parsers/VideoParser"
import { import {
AlbumDetailed, AlbumDetailed,

View File

@ -1,162 +1,18 @@
import ObjectValidator from "validate-any/dist/validators/ObjectValidator"
import Validator from "validate-any/dist/classes/Validator" import Validator from "validate-any/dist/classes/Validator"
import YTMusic, { import YTMusic from ".."
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from ".."
import { import {
BOOLEAN, ALBUM_DETAILED,
iValidationError, ALBUM_FULL,
LIST, ARTIST_DETAILED,
NULL, ARTIST_FULL,
NUMBER, PLAYLIST_FULL,
OBJECT, PLAYLIST_VIDEO,
OR, SONG_DETAILED,
STRING, SONG_FULL,
validate VIDEO_DETAILED,
} from "validate-any" VIDEO_FULL
} from "../interfaces"
//#region Interfaces import { iValidationError, LIST, STRING, validate } from "validate-any"
const THUMBNAIL_FULL: ObjectValidator<ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
const ARTIST_BASIC: ObjectValidator<ArtistBasic> = OBJECT({
artistId: OR(STRING(), NULL()),
name: STRING()
})
const ALBUM_BASIC: ObjectValidator<AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
const SONG_DETAILED: ObjectValidator<SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const VIDEO_DETAILED: ObjectValidator<VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const ARTIST_DETAILED: ObjectValidator<ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
const ALBUM_DETAILED: ObjectValidator<AlbumDetailed> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const SONG_FULL: ObjectValidator<SongFull> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
formats: LIST(OBJECT()),
adaptiveFormats: LIST(OBJECT())
})
const VIDEO_FULL: ObjectValidator<VideoFull> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
unlisted: BOOLEAN(),
familySafe: BOOLEAN(),
paid: BOOLEAN(),
tags: LIST(STRING())
})
const ARTIST_FULL: ObjectValidator<ArtistFull> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
subscribers: NUMBER(),
topSongs: LIST(
OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
),
topAlbums: LIST(ALBUM_DETAILED)
})
const ALBUM_FULL: ObjectValidator<AlbumFull> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
songs: LIST(SONG_DETAILED)
})
const PLAYLIST_DETAILED: ObjectValidator<PlaylistFull> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const PLAYLIST_VIDEO: ObjectValidator<Omit<VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
//#endregion
const issues: iValidationError[][] = [] const issues: iValidationError[][] = []
const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"] const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"]
@ -231,7 +87,7 @@ queries.forEach(query => {
ytmusic ytmusic
.search(query, "PLAYLIST") .search(query, "PLAYLIST")
.then(playlists => { .then(playlists => {
_expect(playlists, LIST(PLAYLIST_DETAILED)) _expect(playlists, LIST(PLAYLIST_FULL))
done() done()
}) })
.catch(done) .catch(done)
@ -246,7 +102,7 @@ queries.forEach(query => {
LIST( LIST(
ALBUM_DETAILED, ALBUM_DETAILED,
ARTIST_DETAILED, ARTIST_DETAILED,
PLAYLIST_DETAILED, PLAYLIST_FULL,
SONG_DETAILED, SONG_DETAILED,
VIDEO_DETAILED VIDEO_DETAILED
) )
@ -327,7 +183,7 @@ queries.forEach(query => {
.search(query, "PLAYLIST") .search(query, "PLAYLIST")
.then(playlists => ytmusic.getPlaylist(playlists[0]!.playlistId!)) .then(playlists => ytmusic.getPlaylist(playlists[0]!.playlistId!))
.then(playlist => { .then(playlist => {
_expect(playlist, PLAYLIST_DETAILED) _expect(playlist, PLAYLIST_FULL)
done() done()
}) })
.catch(done) .catch(done)

146
src/interfaces.ts Normal file
View File

@ -0,0 +1,146 @@
import ObjectValidator from "validate-any/dist/validators/ObjectValidator"
import {
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from "."
import { BOOLEAN, LIST, NULL, NUMBER, OBJECT, OR, STRING } from "validate-any"
export const THUMBNAIL_FULL: ObjectValidator<ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
export const ARTIST_BASIC: ObjectValidator<ArtistBasic> = OBJECT({
artistId: OR(STRING(), NULL()),
name: STRING()
})
export const ALBUM_BASIC: ObjectValidator<AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
export const SONG_DETAILED: ObjectValidator<SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const VIDEO_DETAILED: ObjectValidator<VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ARTIST_DETAILED: ObjectValidator<ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ALBUM_DETAILED: ObjectValidator<AlbumDetailed> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const SONG_FULL: ObjectValidator<SongFull> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
formats: LIST(OBJECT()),
adaptiveFormats: LIST(OBJECT())
})
export const VIDEO_FULL: ObjectValidator<VideoFull> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
unlisted: BOOLEAN(),
familySafe: BOOLEAN(),
paid: BOOLEAN(),
tags: LIST(STRING())
})
export const ARTIST_FULL: ObjectValidator<ArtistFull> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
subscribers: NUMBER(),
topSongs: LIST(
OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
),
topAlbums: LIST(ALBUM_DETAILED)
})
export const ALBUM_FULL: ObjectValidator<AlbumFull> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
songs: LIST(SONG_DETAILED)
})
export const PLAYLIST_FULL: ObjectValidator<PlaylistFull> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const PLAYLIST_VIDEO: ObjectValidator<Omit<VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})

View File

@ -1,5 +1,7 @@
import checkType from "../utils/checkType"
import SongParser from "./SongParser" import SongParser from "./SongParser"
import traverse from "../traverse" import traverse from "../utils/traverse"
import { ALBUM_DETAILED, ALBUM_FULL } from "../interfaces"
import { AlbumDetailed, AlbumFull, ArtistBasic } from ".." import { AlbumDetailed, AlbumFull, ArtistBasic } from ".."
export default class AlbumParser { export default class AlbumParser {
@ -14,59 +16,71 @@ export default class AlbumParser {
const thumbnails = [traverse(data, "header", "thumbnails")].flat() const thumbnails = [traverse(data, "header", "thumbnails")].flat()
const description = traverse(data, "description", "text") const description = traverse(data, "description", "text")
return { return checkType<AlbumFull>(
type: "ALBUM", {
...albumBasic, type: "ALBUM",
playlistId: traverse(data, "buttonRenderer", "playlistId"), ...albumBasic,
artists, playlistId: traverse(data, "buttonRenderer", "playlistId"),
year: +traverse(data, "header", "subtitle", "text").at(-1), artists,
thumbnails, year: +traverse(data, "header", "subtitle", "text").at(-1),
description: description instanceof Array ? null : description, thumbnails,
songs: [traverse(data, "musicResponsiveListItemRenderer")] description: description instanceof Array ? null : description,
.flat() songs: [traverse(data, "musicResponsiveListItemRenderer")]
.map((item: any) => .flat()
SongParser.parseAlbumSong(item, artists, albumBasic, thumbnails) .map((item: any) =>
) SongParser.parseAlbumSong(item, artists, albumBasic, thumbnails)
} )
},
ALBUM_FULL
)
} }
public static parseSearchResult(item: any): AlbumDetailed { public static parseSearchResult(item: any): AlbumDetailed {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
return { return checkType<AlbumDetailed>(
type: "ALBUM", {
albumId: [traverse(item, "browseId")].flat().at(-1), type: "ALBUM",
playlistId: traverse(item, "overlay", "playlistId"), albumId: [traverse(item, "browseId")].flat().at(-1),
artists: traverse(flexColumns[1], "runs") playlistId: traverse(item, "overlay", "playlistId"),
.filter((run: any) => "navigationEndpoint" in run) artists: traverse(flexColumns[1], "runs")
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })), .filter((run: any) => "navigationEndpoint" in run)
name: traverse(flexColumns[0], "runs", "text"), .map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })),
year: +traverse(flexColumns[1], "runs", "text").at(-1), name: traverse(flexColumns[0], "runs", "text"),
thumbnails: [traverse(item, "thumbnails")].flat() year: +traverse(flexColumns[1], "runs", "text").at(-1),
} thumbnails: [traverse(item, "thumbnails")].flat()
},
ALBUM_DETAILED
)
} }
public static parseArtistAlbum(item: any, artistBasic: ArtistBasic): AlbumDetailed { public static parseArtistAlbum(item: any, artistBasic: ArtistBasic): AlbumDetailed {
return { return checkType<AlbumDetailed>(
type: "ALBUM", {
albumId: [traverse(item, "browseId")].flat().at(-1), type: "ALBUM",
playlistId: traverse(item, "thumbnailOverlay", "playlistId"), albumId: [traverse(item, "browseId")].flat().at(-1),
name: traverse(item, "title", "text").at(0), playlistId: traverse(item, "thumbnailOverlay", "playlistId"),
artists: [artistBasic], name: traverse(item, "title", "text").at(0),
year: +traverse(item, "subtitle", "text").at(-1), artists: [artistBasic],
thumbnails: [traverse(item, "thumbnails")].flat() year: +traverse(item, "subtitle", "text").at(-1),
} thumbnails: [traverse(item, "thumbnails")].flat()
},
ALBUM_DETAILED
)
} }
public static parseArtistTopAlbums(item: any, artistBasic: ArtistBasic): AlbumDetailed { public static parseArtistTopAlbums(item: any, artistBasic: ArtistBasic): AlbumDetailed {
return { return checkType<AlbumDetailed>(
type: "ALBUM", {
albumId: traverse(item, "browseId").at(-1), type: "ALBUM",
playlistId: traverse(item, "musicPlayButtonRenderer", "playlistId"), albumId: traverse(item, "browseId").at(-1),
name: traverse(item, "title", "text").at(0), playlistId: traverse(item, "musicPlayButtonRenderer", "playlistId"),
artists: [artistBasic], name: traverse(item, "title", "text").at(0),
year: +traverse(item, "subtitle", "text").at(-1), artists: [artistBasic],
thumbnails: [traverse(item, "thumbnails")].flat() year: +traverse(item, "subtitle", "text").at(-1),
} thumbnails: [traverse(item, "thumbnails")].flat()
},
ALBUM_DETAILED
)
} }
} }

View File

@ -1,7 +1,9 @@
import AlbumParser from "./AlbumParser" import AlbumParser from "./AlbumParser"
import checkType from "../utils/checkType"
import Parser from "./Parser" import Parser from "./Parser"
import SongParser from "./SongParser" import SongParser from "./SongParser"
import traverse from "../traverse" import traverse from "../utils/traverse"
import { ARTIST_DETAILED, ARTIST_FULL } from "../interfaces"
import { ArtistDetailed, ArtistFull } from ".." import { ArtistDetailed, ArtistFull } from ".."
export default class ArtistParser { export default class ArtistParser {
@ -13,30 +15,38 @@ export default class ArtistParser {
const description = traverse(data, "header", "description", "text") const description = traverse(data, "header", "description", "text")
return { return checkType<ArtistFull>(
type: "ARTIST", {
...artistBasic, type: "ARTIST",
thumbnails: [traverse(data, "header", "thumbnails")].flat(), ...artistBasic,
description: description instanceof Array ? null : description, thumbnails: [traverse(data, "header", "thumbnails")].flat(),
subscribers: Parser.parseNumber(traverse(data, "subscriberCountText", "text")), description: description instanceof Array ? null : description,
topSongs: traverse(data, "musicShelfRenderer", "contents").map((item: any) => subscribers: Parser.parseNumber(traverse(data, "subscriberCountText", "text")),
SongParser.parseArtistTopSong(item, artistBasic) topSongs: traverse(data, "musicShelfRenderer", "contents").map((item: any) =>
), SongParser.parseArtistTopSong(item, artistBasic)
topAlbums: [traverse(data, "musicCarouselShelfRenderer")] ),
.flat() topAlbums: [traverse(data, "musicCarouselShelfRenderer")]
.at(0) .flat()
.contents.map((item: any) => AlbumParser.parseArtistTopAlbums(item, artistBasic)) .at(0)
} .contents.map((item: any) =>
AlbumParser.parseArtistTopAlbums(item, artistBasic)
)
},
ARTIST_FULL
)
} }
public static parseSearchResult(item: any): ArtistDetailed { public static parseSearchResult(item: any): ArtistDetailed {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
return { return checkType<ArtistDetailed>(
type: "ARTIST", {
artistId: traverse(item, "browseId"), type: "ARTIST",
name: traverse(flexColumns[0], "runs", "text"), artistId: traverse(item, "browseId"),
thumbnails: [traverse(item, "thumbnails")].flat() name: traverse(flexColumns[0], "runs", "text"),
} thumbnails: [traverse(item, "thumbnails")].flat()
},
ARTIST_DETAILED
)
} }
} }

View File

@ -1,43 +1,51 @@
import traverse from "../traverse" import checkType from "../utils/checkType"
import traverse from "../utils/traverse"
import { PLAYLIST_FULL } from "../interfaces"
import { PlaylistFull } from ".." import { PlaylistFull } from ".."
export default class PlaylistParser { export default class PlaylistParser {
public static parse(data: any, playlistId: string): PlaylistFull { public static parse(data: any, playlistId: string): PlaylistFull {
return { return checkType<PlaylistFull>(
type: "PLAYLIST", {
playlistId, type: "PLAYLIST",
name: traverse(data, "header", "title", "text").at(0), playlistId,
artist: { name: traverse(data, "header", "title", "text").at(0),
artistId: traverse(data, "header", "subtitle", "browseId"), artist: {
name: traverse(data, "header", "subtitle", "text").at(2) artistId: traverse(data, "header", "subtitle", "browseId"),
name: traverse(data, "header", "subtitle", "text").at(2)
},
videoCount: +traverse(data, "header", "secondSubtitle", "text")
.at(0)
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: traverse(data, "header", "thumbnails")
}, },
videoCount: +traverse(data, "header", "secondSubtitle", "text") PLAYLIST_FULL
.at(0) )
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: traverse(data, "header", "thumbnails")
}
} }
public static parseSearchResult(item: any): PlaylistFull { public static parseSearchResult(item: any): PlaylistFull {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
const artistId = traverse(flexColumns[1], "browseId") const artistId = traverse(flexColumns[1], "browseId")
return { return checkType<PlaylistFull>(
type: "PLAYLIST", {
playlistId: traverse(item, "overlay", "playlistId"), type: "PLAYLIST",
name: traverse(flexColumns[0], "runs", "text"), playlistId: traverse(item, "overlay", "playlistId"),
artist: { name: traverse(flexColumns[0], "runs", "text"),
artistId: artistId instanceof Array ? null : artistId, artist: {
name: traverse(flexColumns[1], "runs", "text").at(-2) artistId: artistId instanceof Array ? null : artistId,
name: traverse(flexColumns[1], "runs", "text").at(-2)
},
videoCount: +traverse(flexColumns[1], "runs", "text")
.at(-1)
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: [traverse(item, "thumbnails")].flat()
}, },
videoCount: +traverse(flexColumns[1], "runs", "text") PLAYLIST_FULL
.at(-1) )
.split(" ")
.at(0)
.replaceAll(",", ""),
thumbnails: [traverse(item, "thumbnails")].flat()
}
} }
} }

View File

@ -2,7 +2,7 @@ import AlbumParser from "./AlbumParser"
import ArtistParser from "./ArtistParser" import ArtistParser from "./ArtistParser"
import PlaylistParser from "./PlaylistParser" import PlaylistParser from "./PlaylistParser"
import SongParser from "./SongParser" import SongParser from "./SongParser"
import traverse from "../traverse" import traverse from "../utils/traverse"
import VideoParser from "./VideoParser" import VideoParser from "./VideoParser"
import { SearchResult } from ".." import { SearchResult } from ".."

View File

@ -1,70 +1,82 @@
import checkType from "../utils/checkType"
import Parser from "./Parser" import Parser from "./Parser"
import traverse from "../traverse" import traverse from "../utils/traverse"
import { ALBUM_BASIC, ARTIST_BASIC, SONG_DETAILED, SONG_FULL, THUMBNAIL_FULL } from "../interfaces"
import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from ".." import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from ".."
import { LIST, OBJECT, STRING } from "validate-any"
export default class SongParser { export default class SongParser {
public static parse(data: any): SongFull { public static parse(data: any): SongFull {
return { return checkType<SongFull>(
type: "SONG", {
videoId: traverse(data, "videoDetails", "videoId"), type: "SONG",
name: traverse(data, "videoDetails", "title"), videoId: traverse(data, "videoDetails", "videoId"),
artists: [ name: traverse(data, "videoDetails", "title"),
{ artists: [
artistId: traverse(data, "videoDetails", "channelId"), {
name: traverse(data, "author") artistId: traverse(data, "videoDetails", "channelId"),
} name: traverse(data, "author")
], }
duration: +traverse(data, "videoDetails", "lengthSeconds"), ],
thumbnails: [traverse(data, "videoDetails", "thumbnails")].flat(), duration: +traverse(data, "videoDetails", "lengthSeconds"),
description: traverse(data, "description"), thumbnails: [traverse(data, "videoDetails", "thumbnails")].flat(),
formats: traverse(data, "streamingData", "formats"), description: traverse(data, "description"),
adaptiveFormats: traverse(data, "streamingData", "adaptiveFormats") formats: traverse(data, "streamingData", "formats"),
} adaptiveFormats: traverse(data, "streamingData", "adaptiveFormats")
},
SONG_FULL
)
} }
public static parseSearchResult(item: any): SongDetailed { public static parseSearchResult(item: any): SongDetailed {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId") const videoId = traverse(item, "playlistItemData", "videoId")
return { return checkType<SongDetailed>(
type: "SONG", {
videoId: videoId instanceof Array ? null : videoId, type: "SONG",
name: traverse(flexColumns[0], "runs", "text"), videoId: videoId instanceof Array ? null : videoId,
artists: traverse(flexColumns[1], "runs") name: traverse(flexColumns[0], "runs", "text"),
.filter((run: any) => "navigationEndpoint" in run) artists: traverse(flexColumns[1], "runs")
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })) .filter((run: any) => "navigationEndpoint" in run)
.slice(0, -1), .map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text }))
album: { .slice(0, -1),
albumId: traverse(item, "browseId").at(-1), album: {
name: traverse(flexColumns[1], "runs", "text").at(-3) albumId: traverse(item, "browseId").at(-1),
name: traverse(flexColumns[1], "runs", "text").at(-3)
},
duration: Parser.parseDuration(traverse(flexColumns[1], "runs", "text").at(-1)),
thumbnails: [traverse(item, "thumbnails")].flat()
}, },
duration: Parser.parseDuration(traverse(flexColumns[1], "runs", "text").at(-1)), SONG_DETAILED
thumbnails: [traverse(item, "thumbnails")].flat() )
}
} }
public static parseArtistSong(item: any): SongDetailed { public static parseArtistSong(item: any): SongDetailed {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId") const videoId = traverse(item, "playlistItemData", "videoId")
return { return checkType<SongDetailed>(
type: "SONG", {
videoId: videoId instanceof Array ? null : videoId, type: "SONG",
name: traverse(flexColumns[0], "runs", "text"), videoId: videoId instanceof Array ? null : videoId,
artists: [traverse(flexColumns[1], "runs")] name: traverse(flexColumns[0], "runs", "text"),
.flat() artists: [traverse(flexColumns[1], "runs")]
.filter((item: any) => "navigationEndpoint" in item) .flat()
.map((run: any) => ({ .filter((item: any) => "navigationEndpoint" in item)
artistId: traverse(run, "browseId"), .map((run: any) => ({
name: run.text artistId: traverse(run, "browseId"),
})), name: run.text
album: { })),
albumId: traverse(flexColumns[2], "browseId"), album: {
name: traverse(flexColumns[2], "runs", "text") albumId: traverse(flexColumns[2], "browseId"),
name: traverse(flexColumns[2], "runs", "text")
},
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails: [traverse(item, "thumbnails")].flat()
}, },
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")), SONG_DETAILED
thumbnails: [traverse(item, "thumbnails")].flat() )
}
} }
public static parseArtistTopSong( public static parseArtistTopSong(
@ -74,17 +86,27 @@ export default class SongParser {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId") const videoId = traverse(item, "playlistItemData", "videoId")
return { return checkType<Omit<SongDetailed, "duration">>(
type: "SONG", {
videoId: videoId instanceof Array ? null : videoId, type: "SONG",
name: traverse(flexColumns[0], "runs", "text"), videoId: videoId instanceof Array ? null : videoId,
artists: [artistBasic], name: traverse(flexColumns[0], "runs", "text"),
album: { artists: [artistBasic],
albumId: traverse(flexColumns[2], "runs", "text"), album: {
name: traverse(flexColumns[2], "browseId") albumId: traverse(flexColumns[2], "runs", "text"),
name: traverse(flexColumns[2], "browseId")
},
thumbnails: [traverse(item, "thumbnails")].flat()
}, },
thumbnails: [traverse(item, "thumbnails")].flat() OBJECT({
} type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
)
} }
public static parseAlbumSong( public static parseAlbumSong(
@ -96,14 +118,17 @@ export default class SongParser {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId") const videoId = traverse(item, "playlistItemData", "videoId")
return { return checkType<SongDetailed>(
type: "SONG", {
videoId: videoId instanceof Array ? null : videoId, type: "SONG",
name: traverse(flexColumns[0], "runs", "text"), videoId: videoId instanceof Array ? null : videoId,
artists, name: traverse(flexColumns[0], "runs", "text"),
album: albumBasic, artists,
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")), album: albumBasic,
thumbnails duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
} thumbnails
},
SONG_DETAILED
)
} }
} }

View File

@ -1,5 +1,7 @@
import checkType from "../utils/checkType"
import Parser from "./Parser" import Parser from "./Parser"
import traverse from "../traverse" import traverse from "../utils/traverse"
import { PLAYLIST_VIDEO } from "../interfaces"
import { VideoDetailed, VideoFull } from ".." import { VideoDetailed, VideoFull } from ".."
export default class VideoParser { export default class VideoParser {
@ -47,16 +49,19 @@ export default class VideoParser {
const flexColumns = traverse(item, "flexColumns") const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playNavigationEndpoint", "videoId") const videoId = traverse(item, "playNavigationEndpoint", "videoId")
return { return checkType<Omit<VideoDetailed, "views">>(
type: "VIDEO", {
videoId: videoId instanceof Array ? null : videoId, type: "VIDEO",
name: traverse(flexColumns[0], "runs", "text"), videoId: videoId instanceof Array ? null : videoId,
artists: [traverse(flexColumns[1], "runs")] name: traverse(flexColumns[0], "runs", "text"),
.flat() artists: [traverse(flexColumns[1], "runs")]
.filter((run: any) => "navigationEndpoint" in run) .flat()
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })), .filter((run: any) => "navigationEndpoint" in run)
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")), .map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })),
thumbnails: [traverse(item, "thumbnails")].flat() duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
} thumbnails: [traverse(item, "thumbnails")].flat()
},
PLAYLIST_VIDEO
)
} }
} }

16
src/utils/checkType.ts Normal file
View File

@ -0,0 +1,16 @@
import Validator from "validate-any/dist/classes/Validator"
import { validate } from "validate-any"
export default <T>(data: T, validator: Validator<T>): T => {
const result = validate(data, validator)
if (result.success) {
return result.data
} else {
console.error("Invalid data schema, please report as an issue", {
expected: validator.formatSchema(),
actual: data,
errors: result.errors
})
return data
}
}