Rename youtube-music module to innertube and rewrite it

This commit is contained in:
vfsfitvnm
2022-10-02 15:25:07 +02:00
parent 4bc3671be1
commit 917e194d63
126 changed files with 2210 additions and 2145 deletions

1
innertube/.gitignore vendored Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
package it.vfsfitvnm.youtubemusic.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicCarouselShelfRenderer(
val header: Header?,
val contents: List<Content>,
) {
@Serializable
data class Content(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?,
val 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?
)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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()
// }
// }
//}

View File

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

View File

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

View File

@@ -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?,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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