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

1
youtube-music/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,21 @@
plugins {
kotlin("jvm")
kotlin("plugin.serialization") version "1.6.21"
}
sourceSets.all {
java.srcDir("src/$name/kotlin")
}
dependencies {
implementation(projects.ktorClientBrotli)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.encoding)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.serialization.json)
testImplementation(testLibs.junit)
}

View File

@@ -0,0 +1,42 @@
package it.vfsfitvnm.youtubemusic
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.util.network.*
import io.ktor.utils.io.*
suspend inline fun <reified T> Outcome<HttpResponse>.bodyCatching(): Outcome<T> {
return when (this) {
is Outcome.Success -> value.bodyCatching()
is Outcome.Recovered -> value.bodyCatching()
is Outcome.Initial -> this
is Outcome.Loading -> this
is Outcome.Error -> this
}
}
suspend inline fun HttpClient.postCatching(
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
): Outcome<HttpResponse> {
return runCatching {
Outcome.Success(post(urlString, block))
}.getOrElse { throwable ->
when (throwable) {
is CancellationException -> Outcome.Loading
is UnresolvedAddressException -> Outcome.Error.Network
else -> Outcome.Error.Unhandled(throwable)
}
}
}
suspend inline fun <reified T> HttpResponse.bodyCatching(): Outcome<T> {
return runCatching {
Outcome.Success(body<T>())
}.getOrElse { throwable ->
Outcome.Error.Unhandled(throwable)
}
}

View File

@@ -0,0 +1,68 @@
package it.vfsfitvnm.youtubemusic
sealed class Outcome<out T> {
val valueOrNull: T?
get() = when (this) {
is Success -> value
is Recovered -> value
else -> null
}
fun recoverWith(value: @UnsafeVariance T): Outcome<T> {
return when (this) {
is Error -> Recovered(value, this)
else -> this
}
}
inline fun <R> map(block: (T) -> R): Outcome<R> {
return when (this) {
is Success -> Success(block(value))
is Recovered -> Success(block(value))
is Initial -> this
is Loading -> this
is Error -> this
}
}
inline fun <R> flatMap(block: (T) -> Outcome<R>): Outcome<R> {
return when (this) {
is Success -> block(value)
is Recovered -> block(value)
is Initial -> this
is Loading -> this
is Error -> this
}
}
object Initial : Outcome<Nothing>()
object Loading : Outcome<Nothing>()
sealed class Error : Outcome<Nothing>() {
object Network : Error()
class Unhandled(val throwable: Throwable) : Error()
}
class Recovered<T>(val value: T, val error: Error) : Outcome<T>()
class Success<T>(val value: T) : Outcome<T>()
}
fun <T> Outcome<T>?.toNotNull(): Outcome<T?> {
return when (this) {
null -> Outcome.Success(null)
else -> this
}
}
fun <T> Outcome<T?>.toNullable(): Outcome<T>? {
return valueOrNull?.let {
Outcome.Success(it)
}
}
val Outcome<*>.isEvaluable: Boolean
get() = this !is Outcome.Success && this !is Outcome.Loading

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

View File

@@ -0,0 +1,49 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class BrowseResponse(
val contents: Contents,
val header: Header?
) {
@Serializable
data class Contents(
val singleColumnBrowseResultsRenderer: Tabs?,
val sectionListRenderer: SectionListRenderer?,
)
@Serializable
data class Header(
val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?,
val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?,
) {
@Serializable
data class MusicDetailHeaderRenderer(
val title: Runs,
val subtitle: Runs,
val secondSubtitle: Runs,
val thumbnail: ThumbnailRenderer,
)
@Serializable
data class MusicImmersiveHeaderRenderer(
val description: Runs?,
val playButton: PlayButton?,
val startRadioButton: StartRadioButton?,
val thumbnail: ThumbnailRenderer?,
val title: Runs
) {
@Serializable
data class PlayButton(
val buttonRenderer: ButtonRenderer
)
@Serializable
data class StartRadioButton(
val buttonRenderer: ButtonRenderer
)
}
}
}

View File

@@ -0,0 +1,9 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class ButtonRenderer(
val navigationEndpoint: NavigationEndpoint
)

View File

@@ -0,0 +1,15 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class Continuation(
@JsonNames("nextContinuationData", "nextRadioContinuationData")
val nextRadioContinuationData: Data
) {
@Serializable
data class Data(
val continuation: String
)
}

View File

@@ -0,0 +1,19 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class ContinuationResponse(
val continuationContents: ContinuationContents,
) {
@Serializable
data class ContinuationContents(
val musicShelfContinuation: MusicShelfRenderer
) {
// @Serializable
// data class MusicShelfContinuation(
// val continuations: List<Continuation>?,
// val contents: List<MusicShelfRenderer.Content>
// )
}
}

View File

@@ -0,0 +1,14 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class GetQueueResponse(
val queueDatas: List<QueueData>?,
) {
@Serializable
data class QueueData(
val content: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content?
)
}

View File

@@ -0,0 +1,29 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class GetSearchSuggestionsResponse(
val contents: List<Content>?
) {
@Serializable
data class Content(
val searchSuggestionsSectionRenderer: SearchSuggestionsSectionRenderer
) {
@Serializable
data class SearchSuggestionsSectionRenderer(
val contents: List<Content>
) {
@Serializable
data class Content(
val searchSuggestionRenderer: SearchSuggestionRenderer
) {
@Serializable
data class SearchSuggestionRenderer(
val navigationEndpoint: NavigationEndpoint,
)
}
}
}
}

View File

@@ -0,0 +1,34 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicCarouselShelfRenderer(
val header: Header,
val contents: List<Content>,
) {
@Serializable
data class Content(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?
)
@Serializable
data class Header(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer?
) {
@Serializable
data class MusicCarouselShelfBasicHeaderRenderer(
val moreContentButton: MoreContentButton?,
val title: Runs,
) {
@Serializable
data class MoreContentButton(
val buttonRenderer: ButtonRenderer
)
}
}
}

View File

@@ -0,0 +1,21 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicNavigationButtonRenderer(
val buttonText: Runs,
val solid: Solid?,
val clickCommand: ClickCommand,
) {
@Serializable
data class Solid(
val leftStripeColor: Long
)
@Serializable
data class ClickCommand(
val clickTrackingParams: String,
val browseEndpoint: NavigationEndpoint.Endpoint.Browse
)
}

View File

@@ -0,0 +1,23 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class MusicResponsiveListItemRenderer(
val fixedColumns: List<FlexColumn>?,
val flexColumns: List<FlexColumn>,
val thumbnail: ThumbnailRenderer?,
val navigationEndpoint: NavigationEndpoint?,
) {
@Serializable
data class FlexColumn(
@JsonNames("musicResponsiveListItemFixedColumnRenderer")
val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer
) {
@Serializable
data class MusicResponsiveListItemFlexColumnRenderer(
val text: Runs?
)
}
}

View File

@@ -0,0 +1,23 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicShelfRenderer(
val bottomEndpoint: NavigationEndpoint?,
val contents: List<Content>,
val continuations: List<Continuation>?,
val title: Runs?
) {
@Serializable
data class Content(
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer,
) {
val runs: Pair<List<Runs.Run>, List<List<Runs.Run>>>
get() = (musicResponsiveListItemRenderer.flexColumns.first().musicResponsiveListItemFlexColumnRenderer.text?.runs ?: emptyList()) to
(musicResponsiveListItemRenderer.flexColumns.last().musicResponsiveListItemFlexColumnRenderer.text?.splitBySeparator() ?: emptyList())
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail
get() = musicResponsiveListItemRenderer.thumbnail!!.musicThumbnailRenderer.thumbnail.thumbnails.first()
}
}

View File

@@ -0,0 +1,11 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicTwoRowItemRenderer(
val navigationEndpoint: NavigationEndpoint,
val thumbnailRenderer: ThumbnailRenderer,
val title: Runs,
val subtitle: Runs,
)

View File

@@ -0,0 +1,200 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
/**
* watchPlaylistEndpoint: params, playlistId
* watchEndpoint: params, playlistId, videoId, index
* browseEndpoint: params, browseId
* searchEndpoint: params, query
*/
//@Serializable
//data class NavigationEndpoint(
// @JsonNames("watchEndpoint", "watchPlaylistEndpoint", "navigationEndpoint", "browseEndpoint", "searchEndpoint")
// val endpoint: Endpoint
//) {
// @Serializable
// data class Endpoint(
// val params: String?,
// val playlistId: String?,
// val videoId: String?,
// val index: Int?,
// val browseId: String?,
// val query: String?,
// val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs?,
// val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?,
// ) {
// @Serializable
// data class WatchEndpointMusicSupportedConfigs(
// val watchEndpointMusicConfig: WatchEndpointMusicConfig
// ) {
// @Serializable
// data class WatchEndpointMusicConfig(
// val musicVideoType: String
// )
// }
//
// @Serializable
// data class BrowseEndpointContextSupportedConfigs(
// val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig
// ) {
// @Serializable
// data class BrowseEndpointContextMusicConfig(
// val pageType: String
// )
// }
// }
//}
@Serializable
data class NavigationEndpoint(
val watchEndpoint: Endpoint.Watch?,
val watchPlaylistEndpoint: Endpoint.WatchPlaylist?,
val browseEndpoint: Endpoint.Browse?,
val searchEndpoint: Endpoint.Search?,
) {
val endpoint: Endpoint?
get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint
@Serializable
sealed class Endpoint {
@Serializable
data class Watch(
val params: String? = null,
val playlistId: String? = null,
val videoId: String,
val index: Int? = null,
val playlistSetVideoId: String? = null,
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null,
) : Endpoint() {
@Serializable
data class WatchEndpointMusicSupportedConfigs(
val watchEndpointMusicConfig: WatchEndpointMusicConfig
) {
@Serializable
data class WatchEndpointMusicConfig(
val musicVideoType: String
)
}
}
@Serializable
data class WatchPlaylist(
val params: String?,
val playlistId: String,
) : Endpoint()
@Serializable
data class Browse(
val params: String?,
val browseId: String,
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?,
) : Endpoint() {
@Serializable
data class BrowseEndpointContextSupportedConfigs(
val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig
) {
@Serializable
data class BrowseEndpointContextMusicConfig(
val pageType: String
)
}
}
@Serializable
data class Search(
val params: String?,
val query: String,
) : Endpoint()
}
}
//@Serializable(with = NavigationEndpoint.Serializer::class)
//sealed class NavigationEndpoint {
// @Serializable
// data class Watch(
// val watchEndpoint: Data
// ) : NavigationEndpoint() {
// @Serializable
// data class Data(
// val params: String?,
// val playlistId: String,
// val videoId: String,
//// val index: Int?
// val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs,
// )
//
// @Serializable
// data class WatchEndpointMusicSupportedConfigs(
// val watchEndpointMusicConfig: WatchEndpointMusicConfig
// ) {
// @Serializable
// data class WatchEndpointMusicConfig(
// val musicVideoType: String
// )
// }
// }
//
// @Serializable
// data class WatchPlaylist(
// val watchPlaylistEndpoint: Data
// ) : NavigationEndpoint() {
// @Serializable
// data class Data(
// val params: String?,
// val playlistId: String,
// )
// }
//
// @Serializable
// data class Browse(
// val browseEndpoint: Data
// ) : NavigationEndpoint() {
// @Serializable
// data class Data(
// val params: String?,
// val browseId: String,
// val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs,
// )
//
// @Serializable
// data class BrowseEndpointContextSupportedConfigs(
// val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig
// ) {
// @Serializable
// data class BrowseEndpointContextMusicConfig(
// val pageType: String
// )
// }
// }
//
// @Serializable
// data class Search(
// val searchEndpoint: Data
// ) : NavigationEndpoint() {
// @Serializable
// data class Data(
// val params: String?,
// val query: String,
// )
// }
//
// object Serializer : JsonContentPolymorphicSerializer<NavigationEndpoint>(NavigationEndpoint::class) {
// override fun selectDeserializer(element: JsonElement) = when {
// "watchEndpoint" in element.jsonObject -> Watch.serializer()
// "watchPlaylistEndpoint" in element.jsonObject -> WatchPlaylist.serializer()
// "browseEndpoint" in element.jsonObject -> Browse.serializer()
// "searchEndpoint" in element.jsonObject -> Search.serializer()
// else -> TODO()
// }
// }
//}

View File

@@ -0,0 +1,83 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class NextResponse(
val contents: Contents,
val continuationContents: MusicQueueRenderer.Content?
) {
@Serializable
data class MusicQueueRenderer(
val content: Content?
) {
@Serializable
data class Content(
@JsonNames("playlistPanelContinuation")
val playlistPanelRenderer: PlaylistPanelRenderer
) {
@Serializable
data class PlaylistPanelRenderer(
val contents: List<Content>?,
val continuations: List<Continuation>?,
val playlistId: String?
) {
@Serializable
data class Content(
val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?
) {
@Serializable
data class PlaylistPanelVideoRenderer(
val title: Runs,
val longBylineText: Runs?,
val shortBylineText: Runs,
val lengthText: Runs,
val navigationEndpoint: NavigationEndpoint,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail,
val videoId: String,
val playlistSetVideoId: String?,
)
}
}
}
}
@Serializable
data class Contents(
val singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer
) {
@Serializable
data class SingleColumnMusicWatchNextResultsRenderer(
val tabbedRenderer: TabbedRenderer
) {
@Serializable
data class TabbedRenderer(
val watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer
) {
@Serializable
data class WatchNextTabbedResultsRenderer(
val tabs: List<Tab>
) {
@Serializable
data class Tab(
val tabRenderer: TabRenderer
) {
@Serializable
data class TabRenderer(
val content: Content?,
val endpoint: NavigationEndpoint?,
val title: String
) {
@Serializable
data class Content(
val musicQueueRenderer: MusicQueueRenderer
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class PlayerResponse(
val playabilityStatus: PlayabilityStatus,
val playerConfig: PlayerConfig?,
val streamingData: StreamingData?,
val videoDetails: VideoDetails,
) {
@Serializable
data class PlayabilityStatus(
val status: String
)
@Serializable
data class PlayerConfig(
val audioConfig: AudioConfig?
) {
@Serializable
data class AudioConfig(
val loudnessDb: Double?,
val perceptualLoudnessDb: Double?
)
}
@Serializable
data class StreamingData(
val adaptiveFormats: List<AdaptiveFormat>,
val expiresInSeconds: String
) {
@Serializable
data class AdaptiveFormat(
val itag: Int,
val mimeType: String,
val averageBitrate: Int?,
val contentLength: Long?,
val audioQuality: String?,
val approxDurationMs: Long?,
val loudnessDb: Double?,
val audioSampleRate: Int?,
val url: String,
)
}
@Serializable
data class VideoDetails(
val author: String,
val channelId: String,
val lengthSeconds: String,
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail,
val title: String,
val videoId: String
)
}

View File

@@ -0,0 +1,27 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class Runs(
val runs: List<Run> = listOf()
) {
val text: String
get() = runs.joinToString("") { it.text }
fun splitBySeparator(): List<List<Run>> {
return runs.flatMapIndexed { index, run ->
when {
index == 0 || index == runs.lastIndex -> listOf(index)
run.text == "" -> listOf(index - 1, index + 1)
else -> emptyList()
}
}.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }
}
@Serializable
data class Run(
val text: String,
val navigationEndpoint: NavigationEndpoint?,
)
}

View File

@@ -0,0 +1,13 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class SearchResponse(
val contents: Contents,
) {
@Serializable
data class Contents(
val tabbedSearchResultsRenderer: Tabs
)
}

View File

@@ -0,0 +1,55 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class Tabs(
val tabs: List<Tab>
) {
@Serializable
data class Tab(
val tabRenderer: TabRenderer
) {
@Serializable
data class TabRenderer(
val content: Content?,
val title: String?,
val tabIdentifier: String?,
) {
@Serializable
data class Content(
val sectionListRenderer: SectionListRenderer,
)
}
}
}
@Serializable
data class SectionListRenderer(
val contents: List<Content>,
val continuations: List<Continuation>?
) {
@Serializable
data class Content(
@JsonNames("musicImmersiveCarouselShelfRenderer")
val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?,
val musicShelfRenderer: MusicShelfRenderer?,
val gridRenderer: GridRenderer?,
val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?,
) {
@Serializable
data class GridRenderer(
val items: List<Item>,
) {
@Serializable
data class Item(
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer
)
}
@Serializable
data class MusicDescriptionShelfRenderer(
val description: Runs,
)
}
}

View File

@@ -0,0 +1,47 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class ThumbnailRenderer(
@JsonNames("croppedSquareThumbnailRenderer")
val musicThumbnailRenderer: MusicThumbnailRenderer
) {
@Serializable
data class MusicThumbnailRenderer(
val thumbnail: Thumbnail
) {
@Serializable
data class Thumbnail(
val thumbnails: List<Thumbnail>
) {
@Serializable
data class Thumbnail(
val url: String,
val height: Int,
val width: Int
) {
val isResizable: Boolean
get() = !url.startsWith("https://i.ytimg.com")
fun width(width: Int): String {
return when {
url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$width-h${width * height / this.width}"
url.startsWith("https://yt3.ggpht.com") -> "$url-s$width"
else -> url
}
}
fun size(size: Int): String {
return when {
url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size"
url.startsWith("https://yt3.ggpht.com") -> "$url-s$size"
else -> url
}
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
import kotlinx.coroutines.runBlocking
import org.junit.Test
class Test {
@Test
@Throws(Exception::class)
fun test() = runBlocking {
}
}