Initial commit
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
package it.vfsfitvnm.youtubemusic
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.compression.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import it.vfsfitvnm.youtubemusic.models.*
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
||||
object YouTube {
|
||||
private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
val client = HttpClient(CIO) {
|
||||
BrowserUserAgent()
|
||||
|
||||
expectSuccess = true
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
})
|
||||
}
|
||||
|
||||
install(ContentEncoding) {
|
||||
brotli()
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url("https://music.youtube.com")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BrowseBody(
|
||||
val context: Context,
|
||||
val browseId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchBody(
|
||||
val context: Context,
|
||||
val query: String,
|
||||
val params: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayerBody(
|
||||
val context: Context,
|
||||
val videoId: String,
|
||||
val playlistId: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetQueueBody(
|
||||
val context: Context,
|
||||
val videoIds: List<String>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NextBody(
|
||||
val context: Context,
|
||||
val isAudioOnly: Boolean,
|
||||
val videoId: String,
|
||||
val playlistId: String?,
|
||||
val tunerSettingValue: String,
|
||||
val index: Int?,
|
||||
val params: String?,
|
||||
val playlistSetVideoId: String?,
|
||||
val continuation: String?,
|
||||
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs
|
||||
) {
|
||||
@Serializable
|
||||
data class WatchEndpointMusicSupportedConfigs(
|
||||
val musicVideoType: String
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GetSearchSuggestionsBody(
|
||||
val context: Context,
|
||||
val input: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Context(
|
||||
val client: Client,
|
||||
val thirdParty: ThirdParty? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class Client(
|
||||
val clientName: String,
|
||||
val clientVersion: String,
|
||||
val visitorData: String?,
|
||||
// val gl: String = "US",
|
||||
val hl: String = "en",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ThirdParty(
|
||||
val embedUrl: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val DefaultWeb = Context(
|
||||
client = Client(
|
||||
clientName = "WEB_REMIX",
|
||||
clientVersion = "1.20220328.01.00",
|
||||
visitorData = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D"
|
||||
)
|
||||
)
|
||||
val DefaultAndroid = Context(
|
||||
client = Client(
|
||||
clientName = "ANDROID",
|
||||
clientVersion = "16.50",
|
||||
visitorData = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Info<T : NavigationEndpoint.Endpoint>(
|
||||
val name: String,
|
||||
val endpoint: T?
|
||||
) {
|
||||
companion object {
|
||||
inline fun <reified T : NavigationEndpoint.Endpoint> from(run: Runs.Run): Info<T> {
|
||||
return Info(
|
||||
name = run.text,
|
||||
endpoint = run.navigationEndpoint?.endpoint as T?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Item {
|
||||
abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail
|
||||
|
||||
data class Song(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Watch>,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>,
|
||||
val album: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val durationText: String,
|
||||
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail
|
||||
) : Item() {
|
||||
companion object : FromMusicShelfRendererContent<Song> {
|
||||
val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
|
||||
|
||||
override fun from(content: MusicShelfRenderer.Content): Song {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Song(
|
||||
info = Info.from(mainRuns.first()),
|
||||
authors = otherRuns.getOrNull(otherRuns.lastIndex - 2)
|
||||
?.map(Info.Companion::from) ?: emptyList(),
|
||||
album = otherRuns.getOrNull(otherRuns.lastIndex - 1)?.firstOrNull()?.let(
|
||||
Info.Companion::from
|
||||
),
|
||||
durationText = otherRuns.getOrNull(otherRuns.lastIndex)?.first()?.text
|
||||
?: "?",
|
||||
thumbnail = content.thumbnail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class Video(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Watch>,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>,
|
||||
val views: List<Info<NavigationEndpoint.Endpoint.Browse>>,
|
||||
val durationText: String,
|
||||
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail
|
||||
) : Item() {
|
||||
val isOfficialMusicVideo: Boolean
|
||||
get() = info.endpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV"
|
||||
|
||||
val isUserGeneratedContent: Boolean
|
||||
get() = info.endpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC"
|
||||
|
||||
companion object : FromMusicShelfRendererContent<Video> {
|
||||
val Filter = Filter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
|
||||
override fun from(content: MusicShelfRenderer.Content): Video {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Video(
|
||||
info = Info.from(mainRuns.first()),
|
||||
authors = otherRuns.getOrNull(otherRuns.lastIndex - 2)
|
||||
?.map(Info.Companion::from) ?: emptyList(),
|
||||
views = otherRuns.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.map(Info.Companion::from) ?: emptyList(),
|
||||
durationText = otherRuns.getOrNull(otherRuns.lastIndex)?.first()?.text
|
||||
?: "?",
|
||||
thumbnail = content.thumbnail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Album(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>,
|
||||
val year: String,
|
||||
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail
|
||||
) : Item() {
|
||||
companion object : FromMusicShelfRendererContent<Album> {
|
||||
val Filter = Filter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
|
||||
override fun from(content: MusicShelfRenderer.Content): Album {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Album(
|
||||
info = Info(
|
||||
name = mainRuns.first().text,
|
||||
endpoint = content.musicResponsiveListItemRenderer.navigationEndpoint?.browseEndpoint
|
||||
),
|
||||
authors = otherRuns[otherRuns.lastIndex - 1].map(Info.Companion::from),
|
||||
year = otherRuns[otherRuns.lastIndex].first().text,
|
||||
thumbnail = content.thumbnail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Artist(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>,
|
||||
override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail
|
||||
) : Item() {
|
||||
companion object : FromMusicShelfRendererContent<Artist> {
|
||||
val Filter = Filter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
|
||||
override fun from(content: MusicShelfRenderer.Content): Artist {
|
||||
val (mainRuns) = content.runs
|
||||
|
||||
return Artist(
|
||||
info = Info(
|
||||
name = mainRuns.first().text,
|
||||
endpoint = content.musicResponsiveListItemRenderer.navigationEndpoint?.browseEndpoint
|
||||
),
|
||||
thumbnail = content.thumbnail
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FromMusicShelfRendererContent<out T : Item> {
|
||||
fun from(content: MusicShelfRenderer.Content): T
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class Filter(val value: String)
|
||||
}
|
||||
|
||||
class SearchResult(val items: List<Item>, val continuation: String?)
|
||||
|
||||
suspend fun search(
|
||||
query: String,
|
||||
filter: String,
|
||||
continuation: String?
|
||||
): Outcome<SearchResult> {
|
||||
return client.postCatching("/youtubei/v1/search") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
SearchBody(
|
||||
context = Context.DefaultWeb,
|
||||
query = query,
|
||||
params = filter
|
||||
)
|
||||
)
|
||||
parameter("key", Key)
|
||||
parameter("prettyPrint", false)
|
||||
parameter("continuation", continuation)
|
||||
}.flatMap { response ->
|
||||
if (continuation == null) {
|
||||
response.bodyCatching<SearchResponse>()
|
||||
.map { body ->
|
||||
body
|
||||
.contents
|
||||
.tabbedSearchResultsRenderer
|
||||
.tabs
|
||||
.first()
|
||||
.tabRenderer
|
||||
.content!!
|
||||
.sectionListRenderer
|
||||
.contents
|
||||
.first()
|
||||
.musicShelfRenderer
|
||||
}
|
||||
} else {
|
||||
response.bodyCatching<ContinuationResponse>().map { body ->
|
||||
body
|
||||
.continuationContents
|
||||
.musicShelfContinuation
|
||||
}
|
||||
}
|
||||
}.map { musicShelfRenderer ->
|
||||
SearchResult(
|
||||
items = musicShelfRenderer
|
||||
?.contents
|
||||
?.map(
|
||||
when (filter) {
|
||||
Item.Song.Filter.value -> Item.Song.Companion::from
|
||||
Item.Album.Filter.value -> Item.Album.Companion::from
|
||||
Item.Artist.Filter.value -> Item.Artist.Companion::from
|
||||
Item.Video.Filter.value -> Item.Video.Companion::from
|
||||
else -> error("Unknow filter: $filter")
|
||||
}
|
||||
) ?: emptyList(),
|
||||
continuation = musicShelfRenderer
|
||||
?.continuations
|
||||
?.first()
|
||||
?.nextRadioContinuationData
|
||||
?.continuation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSearchSuggestions(input: String): Outcome<List<String>?> {
|
||||
return client.postCatching("/youtubei/v1/music/get_search_suggestions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
GetSearchSuggestionsBody(
|
||||
context = Context.DefaultWeb,
|
||||
input = input
|
||||
)
|
||||
)
|
||||
parameter("key", Key)
|
||||
parameter("prettyPrint", false)
|
||||
}.bodyCatching<GetSearchSuggestionsResponse>().map { response ->
|
||||
response.contents?.flatMap { content ->
|
||||
content.searchSuggestionsSectionRenderer.contents.map {
|
||||
it.searchSuggestionRenderer.navigationEndpoint.searchEndpoint!!.query
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun player(videoId: String, playlistId: String? = null): Outcome<PlayerResponse> {
|
||||
return client.postCatching("/youtubei/v1/player") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
PlayerBody(
|
||||
context = Context.DefaultAndroid,
|
||||
videoId = videoId,
|
||||
playlistId = playlistId,
|
||||
)
|
||||
)
|
||||
parameter("key", Key)
|
||||
parameter("prettyPrint", false)
|
||||
}.bodyCatching()
|
||||
}
|
||||
|
||||
suspend fun getQueue(videoId: String): Outcome<Item.Song?> {
|
||||
return client.postCatching("/youtubei/v1/music/get_queue") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
GetQueueBody(
|
||||
context = Context.DefaultWeb,
|
||||
videoIds = listOf(videoId)
|
||||
)
|
||||
)
|
||||
parameter("key", Key)
|
||||
parameter("prettyPrint", false)
|
||||
}
|
||||
.bodyCatching<GetQueueResponse>()
|
||||
.map { body ->
|
||||
body.queueDatas?.firstOrNull()?.content?.playlistPanelVideoRenderer?.let { renderer ->
|
||||
Item.Song(
|
||||
info = Info(
|
||||
name = renderer.title.text,
|
||||
endpoint = renderer.navigationEndpoint.watchEndpoint
|
||||
),
|
||||
authors = renderer.longBylineText?.splitBySeparator()?.getOrNull(0)?.map { run ->
|
||||
Info.from(run)
|
||||
} ?: emptyList(),
|
||||
album = renderer.longBylineText?.splitBySeparator()?.getOrNull(1)?.get(0)
|
||||
?.let { run ->
|
||||
Info.from(run)
|
||||
},
|
||||
thumbnail = renderer.thumbnail.thumbnails[0],
|
||||
durationText = renderer.lengthText.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun next(
|
||||
videoId: String,
|
||||
playlistId: String?,
|
||||
index: Int? = null,
|
||||
params: String? = null,
|
||||
playlistSetVideoId: String? = null,
|
||||
continuation: String? = null,
|
||||
): Outcome<NextResult> {
|
||||
return client.postCatching("/youtubei/v1/next") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
NextBody(
|
||||
context = Context.DefaultWeb,
|
||||
videoId = videoId,
|
||||
playlistId = playlistId,
|
||||
isAudioOnly = true,
|
||||
tunerSettingValue = "AUTOMIX_SETTING_NORMAL",
|
||||
watchEndpointMusicSupportedConfigs = NextBody.WatchEndpointMusicSupportedConfigs(
|
||||
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
|
||||
),
|
||||
index = index,
|
||||
playlistSetVideoId = playlistSetVideoId,
|
||||
params = params,
|
||||
continuation = continuation
|
||||
)
|
||||
)
|
||||
parameter("key", Key)
|
||||
parameter("prettyPrint", false)
|
||||
}
|
||||
.bodyCatching<NextResponse>()
|
||||
.map { body ->
|
||||
val tabs = body
|
||||
.contents
|
||||
.singleColumnMusicWatchNextResultsRenderer
|
||||
.tabbedRenderer
|
||||
.watchNextTabbedResultsRenderer
|
||||
.tabs
|
||||
|
||||
NextResult(
|
||||
continuation = (tabs
|
||||
.getOrNull(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.musicQueueRenderer
|
||||
?.content ?: body.continuationContents)
|
||||
?.playlistPanelRenderer
|
||||
?.continuations
|
||||
?.get(0)
|
||||
?.nextRadioContinuationData
|
||||
?.continuation,
|
||||
items = (tabs
|
||||
.getOrNull(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.musicQueueRenderer
|
||||
?.content ?: body.continuationContents)
|
||||
?.playlistPanelRenderer
|
||||
?.contents
|
||||
?.mapNotNull { it.playlistPanelVideoRenderer }
|
||||
?.map { renderer ->
|
||||
Item.Song(
|
||||
info = Info(
|
||||
name = renderer.title.text,
|
||||
endpoint = renderer.navigationEndpoint.watchEndpoint
|
||||
),
|
||||
authors = renderer.longBylineText?.splitBySeparator()?.get(0)
|
||||
?.map { run ->
|
||||
Info.from(run)
|
||||
} ?: emptyList(),
|
||||
album = renderer.longBylineText?.splitBySeparator()?.get(1)?.get(0)
|
||||
?.let { run ->
|
||||
Info.from(run)
|
||||
},
|
||||
thumbnail = renderer.thumbnail.thumbnails[0],
|
||||
durationText = renderer.lengthText.text
|
||||
)
|
||||
},
|
||||
lyrics = NextResult.Lyrics(
|
||||
browseId = tabs
|
||||
.getOrNull(1)
|
||||
?.tabRenderer
|
||||
?.endpoint
|
||||
?.browseEndpoint
|
||||
?.browseId
|
||||
),
|
||||
related = NextResult.Related(
|
||||
browseId = tabs
|
||||
.getOrNull(2)
|
||||
?.tabRenderer
|
||||
?.endpoint
|
||||
?.browseEndpoint
|
||||
?.browseId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class NextResult(
|
||||
val continuation: String?,
|
||||
val items: List<Item.Song>?,
|
||||
val lyrics: Lyrics?,
|
||||
val related: Related?,
|
||||
) {
|
||||
class Lyrics(
|
||||
val browseId: String?,
|
||||
) {
|
||||
suspend fun text(): Outcome<String?> {
|
||||
return if (browseId == null) {
|
||||
Outcome.Success(null)
|
||||
} else {
|
||||
browse(browseId).map { body ->
|
||||
body.contents
|
||||
.sectionListRenderer
|
||||
?.contents
|
||||
?.first()
|
||||
?.musicDescriptionShelfRenderer
|
||||
?.description
|
||||
?.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Related(
|
||||
val browseId: String?,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun browse(browseId: String): Outcome<BrowseResponse> {
|
||||
return client.postCatching("/youtubei/v1/browse") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
BrowseBody(
|
||||
browseId = browseId,
|
||||
context = Context.DefaultWeb
|
||||
)
|
||||
)
|
||||
parameter("key", Key)
|
||||
parameter("prettyPrint", false)
|
||||
}.bodyCatching()
|
||||
}
|
||||
|
||||
data class Album(
|
||||
val title: String,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>,
|
||||
val year: String,
|
||||
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail,
|
||||
val items: List<AlbumItem>
|
||||
)
|
||||
|
||||
data class AlbumItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Watch>,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val durationText: String?,
|
||||
)
|
||||
|
||||
suspend fun album(browseId: String): Outcome<Album> {
|
||||
return browse(browseId).map { body ->
|
||||
Album(
|
||||
title = body
|
||||
.header!!
|
||||
.musicDetailHeaderRenderer!!
|
||||
.title
|
||||
.text,
|
||||
thumbnail = body
|
||||
.header
|
||||
.musicDetailHeaderRenderer!!
|
||||
.thumbnail
|
||||
.musicThumbnailRenderer
|
||||
.thumbnail
|
||||
.thumbnails.first(),
|
||||
authors = body
|
||||
.header
|
||||
.musicDetailHeaderRenderer
|
||||
.subtitle.splitBySeparator().getOrNull(1)?.map { run ->
|
||||
Info.from(run)
|
||||
} ?: emptyList(),
|
||||
year = body
|
||||
.header
|
||||
.musicDetailHeaderRenderer
|
||||
.subtitle.splitBySeparator().getOrNull(2)?.first()!!.text,
|
||||
items = body
|
||||
.contents
|
||||
.singleColumnBrowseResultsRenderer!!
|
||||
.tabs
|
||||
.first()
|
||||
.tabRenderer
|
||||
.content!!
|
||||
.sectionListRenderer
|
||||
.contents
|
||||
.first()
|
||||
.musicShelfRenderer!!
|
||||
.contents
|
||||
.map(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
.mapNotNull { renderer ->
|
||||
AlbumItem(
|
||||
info = Info.from(
|
||||
renderer.flexColumns.getOrNull(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.getOrNull(
|
||||
0
|
||||
) ?: return@mapNotNull null
|
||||
),
|
||||
authors = renderer.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs?.map { run ->
|
||||
Info.from<NavigationEndpoint.Endpoint.Browse>(run)
|
||||
}?.takeIf { it.isNotEmpty() },
|
||||
durationText = renderer.fixedColumns?.getOrNull(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.getOrNull(
|
||||
0
|
||||
)?.text
|
||||
)
|
||||
}.filter { item ->
|
||||
item.info.endpoint != null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Artist(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail?,
|
||||
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
|
||||
val radioEndpoint: NavigationEndpoint.Endpoint.Watch?
|
||||
)
|
||||
|
||||
suspend fun artist(browseId: String): Outcome<Artist> {
|
||||
return browse(browseId).map { body ->
|
||||
Artist(
|
||||
name = body
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.title
|
||||
?.text ?: "Unknown",
|
||||
description = body
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.description
|
||||
?.text,
|
||||
thumbnail = body
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.getOrNull(0),
|
||||
shuffleEndpoint = body
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.playButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchEndpoint,
|
||||
radioEndpoint = body
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.startRadioButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchEndpoint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user