Initial commit

This commit is contained in:
vfsfitvnm
2022-06-02 18:59:18 +02:00
commit 1e673ad582
160 changed files with 10800 additions and 0 deletions

View File

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