Initial commit
This commit is contained in:
1
youtube-music/.gitignore
vendored
Normal file
1
youtube-music/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
21
youtube-music/build.gradle.kts
Normal file
21
youtube-music/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@Serializable
|
||||
data class ButtonRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
// )
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
youtube-music/src/test/kotlin/Test.kt
Normal file
10
youtube-music/src/test/kotlin/Test.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
|
||||
class Test {
|
||||
@Test
|
||||
@Throws(Exception::class)
|
||||
fun test() = runBlocking {
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user