Rename youtube-music module to innertube and rewrite it
This commit is contained in:
1
innertube/.gitignore
vendored
Normal file
1
innertube/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
23
innertube/build.gradle.kts
Normal file
23
innertube/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
204
innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt
Normal file
204
innertube/src/main/kotlin/it/vfsfitvnm/youtubemusic/Innertube.kt
Normal file
@@ -0,0 +1,204 @@
|
||||
package it.vfsfitvnm.youtubemusic
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.BrowserUserAgent
|
||||
import io.ktor.client.plugins.compression.ContentEncoding
|
||||
import io.ktor.client.plugins.compression.brotli
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.youtubemusic.models.Runs
|
||||
import it.vfsfitvnm.youtubemusic.models.Thumbnail
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object Innertube {
|
||||
val client = HttpClient(OkHttp) {
|
||||
BrowserUserAgent()
|
||||
|
||||
expectSuccess = true
|
||||
|
||||
install(ContentNegotiation) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
})
|
||||
}
|
||||
|
||||
install(ContentEncoding) {
|
||||
brotli()
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url(scheme = "https", host ="music.youtube.com") {
|
||||
headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
headers.append("X-Goog-Api-Key", "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8")
|
||||
parameters.append("prettyPrint", "false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val browse = "/youtubei/v1/browse"
|
||||
internal const val next = "/youtubei/v1/next"
|
||||
internal const val player = "/youtubei/v1/player"
|
||||
internal const val queue = "/youtubei/v1/music/get_queue"
|
||||
internal const val search = "/youtubei/v1/search"
|
||||
internal const val searchSuggestions = "/youtubei/v1/music/get_search_suggestions"
|
||||
|
||||
internal const val musicResponsiveListItemRendererMask = "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)"
|
||||
internal const val musicTwoRowItemRendererMask = "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)"
|
||||
const val playlistPanelVideoRendererMask = "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)"
|
||||
|
||||
// contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail)),gridRenderer(continuations,items.musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)))
|
||||
|
||||
internal fun HttpRequestBuilder.mask(value: String = "*") =
|
||||
header("X-Goog-FieldMask", value)
|
||||
|
||||
data class Info<T : NavigationEndpoint.Endpoint>(
|
||||
val name: String?,
|
||||
val endpoint: T?
|
||||
) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
constructor(run: Runs.Run) : this(
|
||||
name = run.text,
|
||||
endpoint = run.navigationEndpoint?.endpoint as T?
|
||||
)
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class SearchFilter(val value: String) {
|
||||
companion object {
|
||||
val Song = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D")
|
||||
val Video = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
val Album = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
val Artist = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D")
|
||||
val CommunityPlaylist = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF")
|
||||
val FeaturedPlaylist = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Item {
|
||||
abstract val thumbnail: Thumbnail?
|
||||
abstract val key: String
|
||||
}
|
||||
|
||||
data class SongItem(
|
||||
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: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.videoId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class VideoItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Watch>?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val viewsText: String?,
|
||||
val durationText: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.videoId!!
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
data class AlbumItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val year: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.browseId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class ArtistItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val subscribersCountText: String?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.browseId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class PlaylistItem(
|
||||
val info: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val channel: Info<NavigationEndpoint.Endpoint.Browse>?,
|
||||
val songCount: Int?,
|
||||
override val thumbnail: Thumbnail?
|
||||
) : Item() {
|
||||
override val key get() = info!!.endpoint!!.browseId!!
|
||||
|
||||
companion object
|
||||
}
|
||||
|
||||
data class ArtistPage(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
val thumbnail: Thumbnail?,
|
||||
val shuffleEndpoint: NavigationEndpoint.Endpoint.Watch?,
|
||||
val radioEndpoint: NavigationEndpoint.Endpoint.Watch?,
|
||||
val songs: List<SongItem>?,
|
||||
val songsEndpoint: NavigationEndpoint.Endpoint.Browse?,
|
||||
val albums: List<AlbumItem>?,
|
||||
val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?,
|
||||
val singles: List<AlbumItem>?,
|
||||
val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?,
|
||||
)
|
||||
|
||||
data class PlaylistOrAlbumPage(
|
||||
val title: String?,
|
||||
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
|
||||
val year: String?,
|
||||
val thumbnail: Thumbnail?,
|
||||
val url: String?,
|
||||
val songsPage: ItemsPage<SongItem>?
|
||||
)
|
||||
|
||||
data class NextPage(
|
||||
val itemsPage: ItemsPage<SongItem>?,
|
||||
val playlistId: String?,
|
||||
val params: String? = null,
|
||||
val playlistSetVideoId: String? = null
|
||||
)
|
||||
|
||||
data class RelatedPage(
|
||||
val songs: List<SongItem>? = null,
|
||||
val playlists: List<PlaylistItem>? = null,
|
||||
val albums: List<AlbumItem>? = null,
|
||||
val artists: List<ArtistItem>? = null,
|
||||
)
|
||||
|
||||
data class ItemsPage<T : Item>(
|
||||
val items: List<T>?,
|
||||
val continuation: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BrowseResponse(
|
||||
val contents: Contents?,
|
||||
val header: Header?,
|
||||
val microformat: Microformat?
|
||||
) {
|
||||
@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?
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Microformat(
|
||||
val microformatDataRenderer: MicroformatDataRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MicroformatDataRenderer(
|
||||
val urlCanonical: String?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ButtonRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@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 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,
|
||||
)
|
||||
)
|
||||
|
||||
val DefaultAgeRestrictionBypass = Context(
|
||||
client = Client(
|
||||
clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
||||
clientVersion = "2.0",
|
||||
visitorData = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class Continuation(
|
||||
@JsonNames("nextContinuationData", "nextRadioContinuationData")
|
||||
val nextContinuationData: Data?
|
||||
) {
|
||||
@Serializable
|
||||
data class Data(
|
||||
val continuation: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class ContinuationResponse(
|
||||
val continuationContents: ContinuationContents?,
|
||||
) {
|
||||
@Serializable
|
||||
data class ContinuationContents(
|
||||
@JsonNames("musicPlaylistShelfContinuation")
|
||||
val musicShelfContinuation: MusicShelfRenderer?,
|
||||
val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,13 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class GridRenderer(
|
||||
val items: List<Item>?,
|
||||
) {
|
||||
@Serializable
|
||||
data class Item(
|
||||
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?
|
||||
)
|
||||
}
|
||||
@@ -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 musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Header(
|
||||
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
|
||||
val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?,
|
||||
val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicCarouselShelfBasicHeaderRenderer(
|
||||
val moreContentButton: MoreContentButton?,
|
||||
val title: Runs?,
|
||||
val strapline: Runs?,
|
||||
) {
|
||||
@Serializable
|
||||
data class MoreContentButton(
|
||||
val buttonRenderer: ButtonRenderer?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@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,41 @@
|
||||
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
|
||||
?.firstOrNull()
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?: emptyList()) to
|
||||
(musicResponsiveListItemRenderer
|
||||
?.flexColumns
|
||||
?.lastOrNull()
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.splitBySeparator()
|
||||
?: emptyList()
|
||||
)
|
||||
|
||||
val thumbnail: Thumbnail?
|
||||
get() = musicResponsiveListItemRenderer
|
||||
?.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -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,203 @@
|
||||
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? = null,
|
||||
val index: Int? = null,
|
||||
val playlistSetVideoId: String? = null,
|
||||
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null,
|
||||
) : Endpoint() {
|
||||
val type: String?
|
||||
get() = watchEndpointMusicSupportedConfigs
|
||||
?.watchEndpointMusicConfig
|
||||
?.musicVideoType
|
||||
|
||||
@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? = null,
|
||||
val browseId: String? = null,
|
||||
val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null,
|
||||
) : Endpoint() {
|
||||
val type: String?
|
||||
get() = browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig
|
||||
?.pageType
|
||||
|
||||
@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,87 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class NextResponse(
|
||||
val contents: Contents?
|
||||
) {
|
||||
@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>?,
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?,
|
||||
val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?,
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class AutomixPreviewVideoRenderer(
|
||||
val content: Content?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
val automixPlaylistVideoRenderer: AutomixPlaylistVideoRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class AutomixPlaylistVideoRenderer(
|
||||
val navigationEndpoint: NavigationEndpoint?
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,46 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayerResponse(
|
||||
val playabilityStatus: PlayabilityStatus?,
|
||||
val playerConfig: PlayerConfig?,
|
||||
val streamingData: StreamingData?,
|
||||
) {
|
||||
@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>?
|
||||
) {
|
||||
@Serializable
|
||||
data class AdaptiveFormat(
|
||||
val itag: Int,
|
||||
val mimeType: String,
|
||||
val bitrate: Long?,
|
||||
val averageBitrate: Long?,
|
||||
val contentLength: Long?,
|
||||
val audioQuality: String?,
|
||||
val approxDurationMs: Long?,
|
||||
val lastModified: Long?,
|
||||
val loudnessDb: Double?,
|
||||
val audioSampleRate: Int?,
|
||||
val url: String?,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlaylistPanelVideoRenderer(
|
||||
val title: Runs?,
|
||||
val longBylineText: Runs?,
|
||||
val shortBylineText: Runs?,
|
||||
val lengthText: Runs?,
|
||||
val navigationEndpoint: NavigationEndpoint?,
|
||||
val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
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) }.let {
|
||||
it.ifEmpty {
|
||||
listOf(runs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Run(
|
||||
val text: String?,
|
||||
val navigationEndpoint: NavigationEndpoint?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchResponse(
|
||||
val contents: Contents?,
|
||||
) {
|
||||
@Serializable
|
||||
data class Contents(
|
||||
val tabbedSearchResultsRenderer: Tabs?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchSuggestionsResponse(
|
||||
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,29 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class SectionListRenderer(
|
||||
val contents: List<Content>?,
|
||||
val continuations: List<Continuation>?
|
||||
) {
|
||||
@Serializable
|
||||
data class Content(
|
||||
@JsonNames("musicImmersiveCarouselShelfRenderer")
|
||||
val musicCarouselShelfRenderer: MusicCarouselShelfRenderer?,
|
||||
@JsonNames("musicPlaylistShelfRenderer")
|
||||
val musicShelfRenderer: MusicShelfRenderer?,
|
||||
val gridRenderer: GridRenderer?,
|
||||
val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?,
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class MusicDescriptionShelfRenderer(
|
||||
val description: Runs?,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@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?,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Thumbnail(
|
||||
val url: String,
|
||||
val height: Int?,
|
||||
val width: Int?
|
||||
) {
|
||||
val isResizable: Boolean
|
||||
get() = !url.startsWith("https://i.ytimg.com")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package it.vfsfitvnm.youtubemusic.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
data class ThumbnailRenderer(
|
||||
@JsonNames("croppedSquareThumbnailRenderer")
|
||||
val musicThumbnailRenderer: MusicThumbnailRenderer?
|
||||
) {
|
||||
@Serializable
|
||||
data class MusicThumbnailRenderer(
|
||||
val thumbnail: Thumbnail?
|
||||
) {
|
||||
@Serializable
|
||||
data class Thumbnail(
|
||||
val thumbnails: List<it.vfsfitvnm.youtubemusic.models.Thumbnail>?
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BrowseBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val browseId: String,
|
||||
val params: String? = null
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ContinuationBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val continuation: String,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NextBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val videoId: String?,
|
||||
val isAudioOnly: Boolean = true,
|
||||
val playlistId: String? = null,
|
||||
val tunerSettingValue: String = "AUTOMIX_SETTING_NORMAL",
|
||||
val index: Int? = null,
|
||||
val params: String? = null,
|
||||
val playlistSetVideoId: String? = null,
|
||||
val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs = WatchEndpointMusicSupportedConfigs(
|
||||
musicVideoType = "MUSIC_VIDEO_TYPE_ATV"
|
||||
)
|
||||
) {
|
||||
@Serializable
|
||||
data class WatchEndpointMusicSupportedConfigs(
|
||||
val musicVideoType: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayerBody(
|
||||
val context: Context = Context.DefaultAndroid,
|
||||
val videoId: String,
|
||||
val playlistId: String? = null
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class QueueBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val videoIds: List<String>? = null,
|
||||
val playlistId: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val query: String,
|
||||
val params: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package it.vfsfitvnm.youtubemusic.models.bodies
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SearchSuggestionsBody(
|
||||
val context: Context = Context.DefaultWeb,
|
||||
val input: String
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.http.Url
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
|
||||
suspend fun Innertube.albumPage(body: BrowseBody): Result<Innertube.PlaylistOrAlbumPage>? {
|
||||
return playlistPage(body)?.map { album ->
|
||||
album.url?.let { Url(it).parameters["list"] }?.let { playlistId ->
|
||||
playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist ->
|
||||
album.copy(songsPage = playlist.songsPage)
|
||||
}
|
||||
} ?: album
|
||||
}?.map { album ->
|
||||
val albumInfo = Innertube.Info(
|
||||
name = album.title,
|
||||
endpoint = NavigationEndpoint.Endpoint.Browse(
|
||||
browseId = body.browseId,
|
||||
params = body.params
|
||||
)
|
||||
)
|
||||
|
||||
album.copy(
|
||||
songsPage = album.songsPage?.copy(
|
||||
items = album.songsPage.items?.map { song ->
|
||||
song.copy(
|
||||
authors = song.authors ?: album.authors,
|
||||
album = albumInfo,
|
||||
thumbnail = album.thumbnail
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.BrowseResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.SectionListRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.findSectionByTitle
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun Innertube.artistPage(browseId: String): Result<Innertube.ArtistPage>? = runCatchingNonCancellable {
|
||||
val response = client.post(browse) {
|
||||
setBody(BrowseBody(browseId = browseId))
|
||||
mask("contents,header")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
fun findSectionByTitle(text: String): SectionListRenderer.Content? {
|
||||
return response
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.get(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.findSectionByTitle(text)
|
||||
}
|
||||
|
||||
val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer
|
||||
val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer
|
||||
val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer
|
||||
|
||||
Innertube.ArtistPage(
|
||||
name = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.title
|
||||
?.text,
|
||||
description = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.description
|
||||
?.text
|
||||
?.substringBeforeLast("\n\nFrom Wikipedia"),
|
||||
thumbnail = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.getOrNull(0),
|
||||
shuffleEndpoint = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.playButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchEndpoint,
|
||||
radioEndpoint = response
|
||||
.header
|
||||
?.musicImmersiveHeaderRenderer
|
||||
?.startRadioButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchEndpoint,
|
||||
songs = songsSection
|
||||
?.contents
|
||||
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
songsEndpoint = songsSection
|
||||
?.bottomEndpoint
|
||||
?.browseEndpoint,
|
||||
albums = albumsSection
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
albumsEndpoint = albumsSection
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.moreContentButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint,
|
||||
singles = singlesSection
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
singlesEndpoint = singlesSection
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.moreContentButton
|
||||
?.buttonRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.BrowseResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.GridRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.itemsPage(
|
||||
body: BrowseBody,
|
||||
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null },
|
||||
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null },
|
||||
) = runCatchingNonCancellable {
|
||||
val response = client.post(browse) {
|
||||
setBody(body)
|
||||
// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val sectionListRendererContent = response
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.firstOrNull()
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.firstOrNull()
|
||||
|
||||
itemsPageFromMusicShelRendererOrGridRenderer(
|
||||
musicShelfRenderer = sectionListRendererContent
|
||||
?.musicShelfRenderer,
|
||||
gridRenderer = sectionListRendererContent
|
||||
?.gridRenderer,
|
||||
fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer,
|
||||
fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.itemsPage(
|
||||
body: ContinuationBody,
|
||||
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null },
|
||||
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null },
|
||||
) = runCatchingNonCancellable {
|
||||
val response = client.post(browse) {
|
||||
setBody(body)
|
||||
// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))")
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
itemsPageFromMusicShelRendererOrGridRenderer(
|
||||
musicShelfRenderer = response
|
||||
.continuationContents
|
||||
?.musicShelfContinuation,
|
||||
gridRenderer = null,
|
||||
fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer,
|
||||
fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer,
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T : Innertube.Item> itemsPageFromMusicShelRendererOrGridRenderer(
|
||||
musicShelfRenderer: MusicShelfRenderer?,
|
||||
gridRenderer: GridRenderer?,
|
||||
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?,
|
||||
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T?,
|
||||
): Innertube.ItemsPage<T>? {
|
||||
return if (musicShelfRenderer != null) {
|
||||
Innertube.ItemsPage(
|
||||
continuation = musicShelfRenderer
|
||||
.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation,
|
||||
items = musicShelfRenderer
|
||||
.contents
|
||||
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(fromMusicResponsiveListItemRenderer)
|
||||
)
|
||||
} else if (gridRenderer != null) {
|
||||
Innertube.ItemsPage(
|
||||
continuation = null,
|
||||
items = gridRenderer
|
||||
.items
|
||||
?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(fromMusicTwoRowItemRenderer)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.BrowseResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.NextResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.NextBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun Innertube.lyrics(body: NextBody): Result<String?>? = runCatchingNonCancellable {
|
||||
val nextResponse = client.post(next) {
|
||||
setBody(body)
|
||||
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)")
|
||||
}.body<NextResponse>()
|
||||
|
||||
val browseId = nextResponse
|
||||
.contents
|
||||
?.singleColumnMusicWatchNextResultsRenderer
|
||||
?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer
|
||||
?.tabs
|
||||
?.getOrNull(1)
|
||||
?.tabRenderer
|
||||
?.endpoint
|
||||
?.browseEndpoint
|
||||
?.browseId
|
||||
?: return@runCatchingNonCancellable null
|
||||
|
||||
val response = client.post(browse) {
|
||||
setBody(BrowseBody(browseId = browseId))
|
||||
mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
response.contents
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.firstOrNull()
|
||||
?.musicDescriptionShelfRenderer
|
||||
?.description
|
||||
?.text
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.NextResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.NextBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
|
||||
|
||||
suspend fun Innertube.nextPage(body: NextBody): Result<Innertube.NextPage>? =
|
||||
runCatchingNonCancellable {
|
||||
val response = client.post(next) {
|
||||
setBody(body)
|
||||
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$playlistPanelVideoRendererMask))")
|
||||
}.body<NextResponse>()
|
||||
|
||||
val tabs = response
|
||||
.contents
|
||||
?.singleColumnMusicWatchNextResultsRenderer
|
||||
?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer
|
||||
?.tabs
|
||||
|
||||
val playlistPanelRenderer = tabs
|
||||
?.getOrNull(0)
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.musicQueueRenderer
|
||||
?.content
|
||||
?.playlistPanelRenderer
|
||||
|
||||
if (body.playlistId == null) {
|
||||
val endpoint = playlistPanelRenderer
|
||||
?.contents
|
||||
?.lastOrNull()
|
||||
?.automixPreviewVideoRenderer
|
||||
?.content
|
||||
?.automixPlaylistVideoRenderer
|
||||
?.navigationEndpoint
|
||||
?.watchPlaylistEndpoint
|
||||
|
||||
if (endpoint != null) {
|
||||
return nextPage(
|
||||
body.copy(
|
||||
playlistId = endpoint.playlistId,
|
||||
params = endpoint.params
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Innertube.NextPage(
|
||||
playlistId = body.playlistId,
|
||||
playlistSetVideoId = body.playlistSetVideoId,
|
||||
params = body.params,
|
||||
itemsPage = playlistPanelRenderer
|
||||
?.toSongsPage()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingNonCancellable {
|
||||
val response = client.post(next) {
|
||||
setBody(body)
|
||||
mask("continuationContents.playlistPanelContinuation(continuations,contents.$playlistPanelVideoRendererMask)")
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
response
|
||||
.continuationContents
|
||||
?.playlistPanelContinuation
|
||||
?.toSongsPage()
|
||||
}
|
||||
|
||||
private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSongsPage() =
|
||||
Innertube.ItemsPage(
|
||||
items = this
|
||||
?.contents
|
||||
?.mapNotNull(NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content::playlistPanelVideoRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
continuation = this
|
||||
?.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.Context
|
||||
import it.vfsfitvnm.youtubemusic.models.PlayerResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.PlayerBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable {
|
||||
val response = client.post(player) {
|
||||
setBody(body)
|
||||
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats")
|
||||
}.body<PlayerResponse>()
|
||||
|
||||
if (response.playabilityStatus?.status == "OK") {
|
||||
response
|
||||
} else {
|
||||
@Serializable
|
||||
data class AudioStream(
|
||||
val url: String,
|
||||
val bitrate: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PipedResponse(
|
||||
val audioStreams: List<AudioStream>
|
||||
)
|
||||
|
||||
val safePlayerResponse = client.post(player) {
|
||||
setBody(body.copy(context = Context.DefaultAgeRestrictionBypass))
|
||||
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats")
|
||||
}.body<PlayerResponse>()
|
||||
|
||||
if (safePlayerResponse.playabilityStatus?.status != "OK") {
|
||||
return@runCatchingNonCancellable response
|
||||
}
|
||||
|
||||
val audioStreams = client.get("https://watchapi.whatever.social/streams/${body.videoId}") {
|
||||
contentType(ContentType.Application.Json)
|
||||
}.body<PipedResponse>().audioStreams
|
||||
|
||||
safePlayerResponse.copy(
|
||||
streamingData = safePlayerResponse.streamingData?.copy(
|
||||
adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat ->
|
||||
adaptiveFormat.copy(
|
||||
url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.BrowseResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable {
|
||||
val response = client.post(browse) {
|
||||
setBody(body)
|
||||
mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),header.musicDetailHeaderRenderer(title,subtitle,thumbnail),microformat")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val musicDetailHeaderRenderer = response
|
||||
.header
|
||||
?.musicDetailHeaderRenderer
|
||||
|
||||
val musicShelfRenderer = response
|
||||
.contents
|
||||
?.singleColumnBrowseResultsRenderer
|
||||
?.tabs
|
||||
?.firstOrNull()
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.firstOrNull()
|
||||
?.musicShelfRenderer
|
||||
|
||||
Innertube.PlaylistOrAlbumPage(
|
||||
title = musicDetailHeaderRenderer
|
||||
?.title
|
||||
?.text,
|
||||
thumbnail = musicDetailHeaderRenderer
|
||||
?.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull(),
|
||||
authors = musicDetailHeaderRenderer
|
||||
?.subtitle
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(1)
|
||||
?.map(Innertube::Info),
|
||||
year = musicDetailHeaderRenderer
|
||||
?.subtitle
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(2)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
url = response
|
||||
.microformat
|
||||
?.microformatDataRenderer
|
||||
?.urlCanonical,
|
||||
songsPage = musicShelfRenderer
|
||||
?.toSongsPage()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingNonCancellable {
|
||||
val response = client.post(browse) {
|
||||
setBody(body)
|
||||
mask("continuationContents.musicPlaylistShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)")
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
response
|
||||
.continuationContents
|
||||
?.musicShelfContinuation
|
||||
?.toSongsPage()
|
||||
}
|
||||
|
||||
private fun MusicShelfRenderer?.toSongsPage() =
|
||||
Innertube.ItemsPage(
|
||||
items = this
|
||||
?.contents
|
||||
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
continuation = this
|
||||
?.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.GetQueueResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.QueueBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun Innertube.queue(body: QueueBody) = runCatchingNonCancellable {
|
||||
val response = client.post(queue) {
|
||||
setBody(body)
|
||||
mask("queueDatas.content.$playlistPanelVideoRendererMask")
|
||||
}.body<GetQueueResponse>()
|
||||
|
||||
response
|
||||
.queueDatas
|
||||
?.mapNotNull { queueData ->
|
||||
queueData
|
||||
.content
|
||||
?.playlistPanelVideoRenderer
|
||||
?.let(Innertube.SongItem::from)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Innertube.song(videoId: String): Result<Innertube.SongItem?>? =
|
||||
queue(QueueBody(videoIds = listOf(videoId)))?.map { it?.firstOrNull() }
|
||||
@@ -0,0 +1,71 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.BrowseResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicCarouselShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.NextResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.BrowseBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.NextBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.findSectionByStrapline
|
||||
import it.vfsfitvnm.youtubemusic.utils.findSectionByTitle
|
||||
import it.vfsfitvnm.youtubemusic.utils.from
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable {
|
||||
val nextResponse = client.post(next) {
|
||||
setBody(body)
|
||||
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)")
|
||||
}.body<NextResponse>()
|
||||
|
||||
val browseId = nextResponse
|
||||
.contents
|
||||
?.singleColumnMusicWatchNextResultsRenderer
|
||||
?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer
|
||||
?.tabs
|
||||
?.getOrNull(2)
|
||||
?.tabRenderer
|
||||
?.endpoint
|
||||
?.browseEndpoint
|
||||
?.browseId
|
||||
?: return@runCatchingNonCancellable null
|
||||
|
||||
val response = client.post(browse) {
|
||||
setBody(BrowseBody(browseId = browseId))
|
||||
mask("contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($musicResponsiveListItemRendererMask,$musicTwoRowItemRendererMask))")
|
||||
}.body<BrowseResponse>()
|
||||
|
||||
val sectionListRenderer = response
|
||||
.contents
|
||||
?.sectionListRenderer
|
||||
|
||||
Innertube.RelatedPage(
|
||||
songs = sectionListRenderer
|
||||
?.findSectionByTitle("You might also like")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicResponsiveListItemRenderer)
|
||||
?.mapNotNull(Innertube.SongItem::from),
|
||||
playlists = sectionListRenderer
|
||||
?.findSectionByTitle("Recommended playlists")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.PlaylistItem::from),
|
||||
albums = sectionListRenderer
|
||||
?.findSectionByStrapline("MORE FROM")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.AlbumItem::from),
|
||||
artists = sectionListRenderer
|
||||
?.findSectionByTitle("Similar artists")
|
||||
?.musicCarouselShelfRenderer
|
||||
?.contents
|
||||
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
|
||||
?.mapNotNull(Innertube.ArtistItem::from),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.ContinuationResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.SearchResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.ContinuationBody
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.SearchBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.searchPage(
|
||||
body: SearchBody,
|
||||
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
|
||||
) = runCatchingNonCancellable {
|
||||
val response = client.post(search) {
|
||||
setBody(body)
|
||||
mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask)")
|
||||
}.body<SearchResponse>()
|
||||
|
||||
response
|
||||
.contents
|
||||
?.tabbedSearchResultsRenderer
|
||||
?.tabs
|
||||
?.firstOrNull()
|
||||
?.tabRenderer
|
||||
?.content
|
||||
?.sectionListRenderer
|
||||
?.contents
|
||||
?.lastOrNull()
|
||||
?.musicShelfRenderer
|
||||
?.toItemsPage(fromMusicShelfRendererContent)
|
||||
}
|
||||
|
||||
suspend fun <T : Innertube.Item> Innertube.searchPage(
|
||||
body: ContinuationBody,
|
||||
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
|
||||
) = runCatchingNonCancellable {
|
||||
val response = client.post(search) {
|
||||
setBody(body)
|
||||
mask("continuationContents.musicShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)")
|
||||
}.body<ContinuationResponse>()
|
||||
|
||||
response
|
||||
.continuationContents
|
||||
?.musicShelfContinuation
|
||||
?.toItemsPage(fromMusicShelfRendererContent)
|
||||
}
|
||||
|
||||
private fun <T : Innertube.Item> MusicShelfRenderer?.toItemsPage(mapper: (MusicShelfRenderer.Content) -> T?) =
|
||||
Innertube.ItemsPage(
|
||||
items = this
|
||||
?.contents
|
||||
?.mapNotNull(mapper),
|
||||
continuation = this
|
||||
?.continuations
|
||||
?.firstOrNull()
|
||||
?.nextContinuationData
|
||||
?.continuation
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package it.vfsfitvnm.youtubemusic.requests
|
||||
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.SearchSuggestionsResponse
|
||||
import it.vfsfitvnm.youtubemusic.models.bodies.SearchSuggestionsBody
|
||||
import it.vfsfitvnm.youtubemusic.utils.runCatchingNonCancellable
|
||||
|
||||
suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingNonCancellable {
|
||||
val response = client.post(searchSuggestions) {
|
||||
setBody(body)
|
||||
mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query")
|
||||
}.body<SearchSuggestionsResponse>()
|
||||
|
||||
response
|
||||
.contents
|
||||
?.firstOrNull()
|
||||
?.searchSuggestionsSectionRenderer
|
||||
?.contents
|
||||
?.mapNotNull { content ->
|
||||
content
|
||||
.searchSuggestionRenderer
|
||||
?.navigationEndpoint
|
||||
?.searchEndpoint
|
||||
?.query
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package it.vfsfitvnm.youtubemusic.utils
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicResponsiveListItemRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
import it.vfsfitvnm.youtubemusic.models.Runs
|
||||
|
||||
fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer): Innertube.SongItem? {
|
||||
return Innertube.SongItem(
|
||||
info = renderer
|
||||
.flexColumns
|
||||
.getOrNull(0)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.getOrNull(0)
|
||||
?.let(Innertube::Info),
|
||||
authors = renderer
|
||||
.flexColumns
|
||||
.getOrNull(1)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.map<Runs.Run, Innertube.Info<NavigationEndpoint.Endpoint.Browse>>(Innertube::Info)
|
||||
?.takeIf(List<Any>::isNotEmpty),
|
||||
durationText = renderer
|
||||
.fixedColumns
|
||||
?.getOrNull(0)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.getOrNull(0)
|
||||
?.text,
|
||||
album = renderer
|
||||
.flexColumns
|
||||
.getOrNull(2)
|
||||
?.musicResponsiveListItemFlexColumnRenderer
|
||||
?.text
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
thumbnail = renderer
|
||||
.thumbnail
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package it.vfsfitvnm.youtubemusic.utils
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicShelfRenderer
|
||||
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
||||
|
||||
fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.SongItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
// Possible configurations:
|
||||
// "song" • author(s) • album • duration
|
||||
// "song" • author(s) • duration
|
||||
// author(s) • album • duration
|
||||
// author(s) • duration
|
||||
|
||||
val album: Innertube.Info<NavigationEndpoint.Endpoint.Browse>? = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.firstOrNull()
|
||||
?.takeIf { run ->
|
||||
run
|
||||
.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
?.type == "MUSIC_PAGE_TYPE_ALBUM"
|
||||
}
|
||||
?.let(Innertube::Info)
|
||||
|
||||
return Innertube.SongItem(
|
||||
info = mainRuns
|
||||
.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
authors = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - if (album == null) 1 else 2)
|
||||
?.map(Innertube::Info),
|
||||
album = album,
|
||||
durationText = otherRuns
|
||||
.lastOrNull()
|
||||
?.firstOrNull()?.text,
|
||||
thumbnail = content
|
||||
.thumbnail
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
}
|
||||
|
||||
fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.VideoItem(
|
||||
info = mainRuns
|
||||
.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
authors = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 2)
|
||||
?.map(Innertube::Info),
|
||||
viewsText = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
durationText = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = content
|
||||
.thumbnail
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
}
|
||||
|
||||
fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.AlbumItem(
|
||||
info = Innertube.Info(
|
||||
name = mainRuns
|
||||
.firstOrNull()
|
||||
?.text,
|
||||
endpoint = content
|
||||
.musicResponsiveListItemRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
),
|
||||
authors = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex - 1)
|
||||
?.map(Innertube::Info),
|
||||
year = otherRuns
|
||||
.getOrNull(otherRuns.lastIndex)
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = content
|
||||
.thumbnail
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
|
||||
fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.ArtistItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.ArtistItem(
|
||||
info = Innertube.Info(
|
||||
name = mainRuns
|
||||
.firstOrNull()
|
||||
?.text,
|
||||
endpoint = content
|
||||
.musicResponsiveListItemRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
),
|
||||
subscribersCountText = otherRuns
|
||||
.lastOrNull()
|
||||
?.last()
|
||||
?.text,
|
||||
thumbnail = content
|
||||
.thumbnail
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
|
||||
fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.PlaylistItem? {
|
||||
val (mainRuns, otherRuns) = content.runs
|
||||
|
||||
return Innertube.PlaylistItem(
|
||||
info = Innertube.Info(
|
||||
name = mainRuns
|
||||
.firstOrNull()
|
||||
?.text,
|
||||
endpoint = content
|
||||
.musicResponsiveListItemRenderer
|
||||
?.navigationEndpoint
|
||||
?.browseEndpoint
|
||||
),
|
||||
channel = otherRuns
|
||||
.firstOrNull()
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
songCount = otherRuns
|
||||
.lastOrNull()
|
||||
?.firstOrNull()
|
||||
?.text
|
||||
?.split(' ')
|
||||
?.firstOrNull()
|
||||
?.toIntOrNull(),
|
||||
thumbnail = content
|
||||
.thumbnail
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package it.vfsfitvnm.youtubemusic.utils
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.MusicTwoRowItemRenderer
|
||||
|
||||
fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.AlbumItem? {
|
||||
return Innertube.AlbumItem(
|
||||
info = renderer
|
||||
.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
authors = null,
|
||||
year = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.lastOrNull()
|
||||
?.text,
|
||||
thumbnail = renderer
|
||||
.thumbnailRenderer
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
|
||||
fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.ArtistItem? {
|
||||
return Innertube.ArtistItem(
|
||||
info = renderer
|
||||
.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
subscribersCountText = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text,
|
||||
thumbnail = renderer
|
||||
.thumbnailRenderer
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
|
||||
fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.PlaylistItem? {
|
||||
return Innertube.PlaylistItem(
|
||||
info = renderer
|
||||
.title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.let(Innertube::Info),
|
||||
channel = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.getOrNull(2)
|
||||
?.let(Innertube::Info),
|
||||
songCount = renderer
|
||||
.subtitle
|
||||
?.runs
|
||||
?.getOrNull(4)
|
||||
?.text
|
||||
?.split(' ')
|
||||
?.firstOrNull()
|
||||
?.toIntOrNull(),
|
||||
thumbnail = renderer
|
||||
.thumbnailRenderer
|
||||
?.musicThumbnailRenderer
|
||||
?.thumbnail
|
||||
?.thumbnails
|
||||
?.firstOrNull()
|
||||
).takeIf { it.info?.endpoint?.browseId != null }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package it.vfsfitvnm.youtubemusic.utils
|
||||
|
||||
import it.vfsfitvnm.youtubemusic.Innertube
|
||||
import it.vfsfitvnm.youtubemusic.models.PlaylistPanelVideoRenderer
|
||||
|
||||
fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer): Innertube.SongItem? {
|
||||
return Innertube.SongItem(
|
||||
info = Innertube.Info(
|
||||
name = renderer
|
||||
.title
|
||||
?.text,
|
||||
endpoint = renderer
|
||||
.navigationEndpoint
|
||||
?.watchEndpoint
|
||||
),
|
||||
authors = renderer
|
||||
.longBylineText
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(0)
|
||||
?.map(Innertube::Info),
|
||||
album = renderer
|
||||
.longBylineText
|
||||
?.splitBySeparator()
|
||||
?.getOrNull(1)
|
||||
?.getOrNull(0)
|
||||
?.let(Innertube::Info),
|
||||
thumbnail = renderer
|
||||
.thumbnail
|
||||
?.thumbnails
|
||||
?.getOrNull(0),
|
||||
durationText = renderer
|
||||
.lengthText
|
||||
?.text
|
||||
).takeIf { it.info?.endpoint?.videoId != null }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package it.vfsfitvnm.youtubemusic.utils
|
||||
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import it.vfsfitvnm.youtubemusic.models.SectionListRenderer
|
||||
|
||||
internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? {
|
||||
return contents?.find { content ->
|
||||
val title = content
|
||||
.musicCarouselShelfRenderer
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.title
|
||||
?: content
|
||||
.musicShelfRenderer
|
||||
?.title
|
||||
|
||||
title
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text == text
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionListRenderer.Content? {
|
||||
return contents?.find { content ->
|
||||
content
|
||||
.musicCarouselShelfRenderer
|
||||
?.header
|
||||
?.musicCarouselShelfBasicHeaderRenderer
|
||||
?.strapline
|
||||
?.runs
|
||||
?.firstOrNull()
|
||||
?.text == text
|
||||
}
|
||||
}
|
||||
|
||||
internal inline fun <R> runCatchingNonCancellable(block: () -> R): Result<R>? {
|
||||
return Result.success(block())
|
||||
// val result = runCatching(block)
|
||||
// return when (val ex = result.exceptionOrNull()) {
|
||||
// is CancellationException -> null
|
||||
// else -> result
|
||||
// }
|
||||
}
|
||||
10
innertube/src/test/kotlin/Test.kt
Normal file
10
innertube/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