This commit is contained in:
2024-02-27 22:09:30 +03:00
parent bfa3231823
commit 38a3141d43
479 changed files with 36348 additions and 10142 deletions

1
providers/innertube/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,28 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.android.lint)
}
dependencies {
implementation(projects.ktorClientBrotli)
implementation(projects.providers.common)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.encoding)
implementation(libs.ktor.client.serialization)
implementation(libs.ktor.serialization.json)
detektPlugins(libs.detekt.compose)
detektPlugins(libs.detekt.formatting)
}
kotlin {
jvmToolchain(libs.versions.jvm.get().toInt())
compilerOptions {
freeCompilerArgs.addAll("-Xcontext-receivers")
}
}

View File

@@ -0,0 +1,251 @@
package it.hamy.innertube
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
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.client.request.headers
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.serialization.kotlinx.json.json
import it.hamy.innertube.models.MusicNavigationButtonRenderer
import it.hamy.innertube.models.NavigationEndpoint
import it.hamy.innertube.models.Runs
import it.hamy.innertube.models.Thumbnail
import it.hamy.innertube.utils.ProxyPreferences
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import java.net.InetSocketAddress
import java.net.Proxy
object Innertube {
val client = HttpClient(OkHttp) {
expectSuccess = true
install(ContentNegotiation) {
@OptIn(ExperimentalSerializationApi::class)
json(
Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
)
}
install(ContentEncoding) {
brotli(1.0f)
gzip(0.9f)
deflate(0.8f)
}
defaultRequest {
url(scheme = "https", host = "music.youtube.com") {
contentType(ContentType.Application.Json)
headers {
append("X-Goog-Api-Key", API_KEY)
append("x-origin", ORIGIN)
}
parameters {
append("prettyPrint", "false")
append("key", API_KEY)
}
}
}
ProxyPreferences.preference?.let {
engine {
proxy = Proxy(
it.proxyMode,
InetSocketAddress(
it.proxyHost,
it.proxyPort
)
)
}
}
}
private const val API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"
private const val ORIGIN = "https://music.youtube.com"
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 SEARCH_SUGGESTIONS = "/youtubei/v1/music/get_search_suggestions"
internal const val MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK =
"musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)"
internal const val MUSIC_TWO_ROW_ITEM_RENDERER_MASK =
"musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)"
internal const val PLAYLIST_PANEL_VIDEO_RENDERER_MASK =
"playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)"
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("EgWKAQIIAWoOEAMQBBAJEAoQBRAQEBU%3D")
val Video = SearchFilter("EgWKAQIQAWoOEAMQBBAJEAoQBRAQEBU%3D")
val Album = SearchFilter("EgWKAQIYAWoOEAMQBBAJEAoQBRAQEBU%3D")
val Artist = SearchFilter("EgWKAQIgAWoOEAMQBBAJEAoQBRAQEBU%3D")
val CommunityPlaylist = SearchFilter("EgeKAQQoAEABag4QAxAEEAkQChAFEBAQFQ%3D%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"
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 description: String?,
val authors: List<Info<NavigationEndpoint.Endpoint.Browse>>?,
val year: String?,
val thumbnail: Thumbnail?,
val url: String?,
val songsPage: ItemsPage<SongItem>?,
val otherVersions: List<AlbumItem>?,
val otherInfo: String?
)
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 DiscoverPage(
val newReleaseAlbums: List<AlbumItem>,
val moods: List<Mood.Item>
)
data class Mood(
val title: String,
val items: List<Item>
) {
data class Item(
val title: String,
val stripeColor: Long,
val endpoint: NavigationEndpoint.Endpoint.Browse
)
}
@Suppress("ReturnCount")
fun MusicNavigationButtonRenderer.toMood(): Mood.Item? {
return Mood.Item(
title = buttonText.runs.firstOrNull()?.text ?: return null,
stripeColor = solid?.leftStripeColor ?: return null,
endpoint = clickCommand.browseEndpoint ?: return null
)
}
data class ItemsPage<T : Item>(
val items: List<T>?,
val continuation: String?
)
}

View File

@@ -0,0 +1,65 @@
package it.hamy.innertube.models
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
@Serializable
data class BrowseResponse(
val contents: Contents?,
val header: Header?,
val microformat: Microformat?
) {
@Serializable
data class Contents(
val singleColumnBrowseResultsRenderer: Tabs?,
val sectionListRenderer: SectionListRenderer?
)
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class Header(
@JsonNames("musicVisualHeaderRenderer")
val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?,
val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?
) {
@Serializable
data class MusicDetailHeaderRenderer(
val title: Runs?,
val description: 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 foregroundThumbnail: 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.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class ButtonRenderer(
val navigationEndpoint: NavigationEndpoint?
)

View File

@@ -0,0 +1,85 @@
package it.hamy.innertube.models
import io.ktor.client.request.headers
import io.ktor.http.HttpMessageBuilder
import io.ktor.http.userAgent
import kotlinx.serialization.Serializable
import java.util.Locale
@Serializable
data class Context(
val client: Client,
val thirdParty: ThirdParty? = null
) {
@Serializable
data class Client(
val clientName: String,
val clientVersion: String,
val platform: String,
val hl: String = "en",
val gl: String = "US",
val visitorData: String = DEFAULT_VISITOR_DATA,
val androidSdkVersion: Int? = null,
val userAgent: String? = null,
val referer: String? = null
)
@Serializable
data class ThirdParty(
val embedUrl: String
)
context(HttpMessageBuilder)
fun apply() {
client.userAgent?.let { userAgent(it) }
headers {
client.referer?.let { append("Referer", it) }
append("X-Youtube-Bootstrap-Logged-In", "false")
append("X-YouTube-Client-Name", client.clientName)
append("X-YouTube-Client-Version", client.clientVersion)
}
}
companion object {
const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D"
val DefaultWeb get() = DefaultWebNoLang.let {
it.copy(
client = it.client.copy(
hl = Locale.getDefault().toLanguageTag(),
gl = Locale.getDefault().country
)
)
}
val DefaultWebNoLang = Context(
client = Client(
clientName = "WEB_REMIX",
clientVersion = "1.20220606.03.00",
platform = "DESKTOP",
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36",
referer = "https://music.youtube.com/"
)
)
val DefaultAndroid = Context(
client = Client(
clientName = "ANDROID_MUSIC",
clientVersion = "5.28.1",
platform = "MOBILE",
androidSdkVersion = 30,
userAgent = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip"
)
)
val DefaultAgeRestrictionBypass = Context(
client = Client(
clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
clientVersion = "2.0",
platform = "TV",
userAgent = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)"
)
)
}
}

View File

@@ -0,0 +1,17 @@
package it.hamy.innertube.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.hamy.innertube.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,15 @@
package it.hamy.innertube.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetQueueResponse(
@SerialName("queueDatas")
val queueData: List<QueueData>?
) {
@Serializable
data class QueueData(
val content: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content?
)
}

View File

@@ -0,0 +1,24 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class GridRenderer(
val items: List<Item>?,
val header: Header?
) {
@Serializable
data class Item(
val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?
)
@Serializable
data class Header(
val gridHeaderRenderer: GridHeaderRenderer?
)
@Serializable
data class GridHeaderRenderer(
val title: Runs?
)
}

View File

@@ -0,0 +1,35 @@
package it.hamy.innertube.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?,
val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? = null
)
@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,26 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicNavigationButtonRenderer(
val buttonText: Runs,
val solid: Solid?,
val iconStyle: IconStyle?,
val clickCommand: NavigationEndpoint
) {
@Serializable
data class Solid(
val leftStripeColor: Long
)
@Serializable
data class IconStyle(
val icon: Icon
)
@Serializable
data class Icon(
val iconType: String
)
}

View File

@@ -0,0 +1,25 @@
package it.hamy.innertube.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,40 @@
package it.hamy.innertube.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
.orEmpty() to
musicResponsiveListItemRenderer
?.flexColumns
?.let { it.getOrNull(1) ?: it.lastOrNull() }
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.splitBySeparator()
.orEmpty()
val thumbnail: Thumbnail?
get() = musicResponsiveListItemRenderer
?.thumbnail
?.musicThumbnailRenderer
?.thumbnail
?.thumbnails
?.firstOrNull()
}
}

View File

@@ -0,0 +1,26 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class MusicTwoRowItemRenderer(
val navigationEndpoint: NavigationEndpoint?,
val thumbnailRenderer: ThumbnailRenderer?,
val title: Runs?,
val subtitle: Runs?,
val thumbnailOverlay: ThumbnailOverlay?
) {
val isPlaylist: Boolean
get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST"
val isAlbum: Boolean
get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ALBUM" ||
navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_AUDIOBOOK"
val isArtist: Boolean
get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs
?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ARTIST"
}

View File

@@ -0,0 +1,77 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
/**
* watchPlaylistEndpoint: params, playlistId
* watchEndpoint: params, playlistId, videoId, index
* browseEndpoint: params, browseId
* searchEndpoint: params, query
*/
@Serializable
data class NavigationEndpoint(
val watchEndpoint: Endpoint.Watch?,
val watchPlaylistEndpoint: Endpoint.WatchPlaylist?,
val browseEndpoint: Endpoint.Browse?,
val searchEndpoint: Endpoint.Search?
) {
val 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() {
@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()
}
}

View File

@@ -0,0 +1,87 @@
package it.hamy.innertube.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,58 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class PlayerResponse(
val playabilityStatus: PlayabilityStatus?,
val playerConfig: PlayerConfig?,
val streamingData: StreamingData?,
val videoDetails: VideoDetails?
) {
@Serializable
data class PlayabilityStatus(
val status: String?
)
@Serializable
data class PlayerConfig(
val audioConfig: AudioConfig?
) {
@Serializable
data class AudioConfig(
private val loudnessDb: Double?
) {
// For music clients only
val normalizedLoudnessDb: Float?
get() = loudnessDb?.plus(7)?.toFloat()
}
}
@Serializable
data class StreamingData(
val adaptiveFormats: List<AdaptiveFormat>?
) {
val highestQualityFormat: AdaptiveFormat?
get() = adaptiveFormats?.findLast { it.itag == 251 || it.itag == 140 }
@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?
)
}
@Serializable
data class VideoDetails(
val videoId: String?
)
}

View File

@@ -0,0 +1,13 @@
package it.hamy.innertube.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,52 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class Runs(
val runs: List<Run> = listOf()
) {
companion object {
const val SEPARATOR = ""
}
val text: String
get() = runs.joinToString("") { it.text.orEmpty() }
fun splitBySeparator(): List<List<Run>> {
return runs.flatMapIndexed { index, run ->
when {
index == 0 || index == runs.lastIndex -> listOf(index)
run.text == SEPARATOR -> 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?
)
}
fun List<Runs.Run>.splitBySeparator(): List<List<Runs.Run>> {
val res = mutableListOf<List<Runs.Run>>()
var tmp = mutableListOf<Runs.Run>()
forEach { run ->
if (run.text == "") {
res.add(tmp)
tmp = mutableListOf()
} else {
tmp.add(run)
}
}
res.add(tmp)
return res
}
fun <T> List<T>.oddElements() = filterIndexed { index, _ -> index % 2 == 0 }

View File

@@ -0,0 +1,13 @@
package it.hamy.innertube.models
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.hamy.innertube.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,28 @@
package it.hamy.innertube.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.hamy.innertube.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,16 @@
package it.hamy.innertube.models
import kotlinx.serialization.Serializable
@Serializable
data class Thumbnail(
val url: String,
val height: Int?,
val width: Int?
) {
fun size(size: Int) = 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,42 @@
package it.hamy.innertube.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.hamy.innertube.models.Thumbnail>?
)
}
}
@Serializable
data class ThumbnailOverlay(
val musicItemThumbnailOverlayRenderer: MusicItemThumbnailOverlayRenderer
) {
@Serializable
data class MusicItemThumbnailOverlayRenderer(
val content: Content
) {
@Serializable
data class Content(
val musicPlayButtonRenderer: MusicPlayButtonRenderer
) {
@Serializable
data class MusicPlayButtonRenderer(
val playNavigationEndpoint: NavigationEndpoint?
)
}
}
}

View File

@@ -0,0 +1,11 @@
package it.hamy.innertube.models.bodies
import it.hamy.innertube.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.hamy.innertube.models.bodies
import it.hamy.innertube.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.hamy.innertube.models.bodies
import it.hamy.innertube.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.hamy.innertube.models.bodies
import it.hamy.innertube.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.hamy.innertube.models.bodies
import it.hamy.innertube.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.hamy.innertube.models.bodies
import it.hamy.innertube.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.hamy.innertube.models.bodies
import it.hamy.innertube.models.Context
import kotlinx.serialization.Serializable
@Serializable
data class SearchSuggestionsBody(
val context: Context = Context.DefaultWeb,
val input: String
)

View File

@@ -0,0 +1,34 @@
package it.hamy.innertube.requests
import io.ktor.http.Url
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.NavigationEndpoint
import it.hamy.innertube.models.bodies.BrowseBody
suspend fun Innertube.albumPage(body: BrowseBody) = 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,127 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.Context
import it.hamy.innertube.models.MusicCarouselShelfRenderer
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.utils.findSectionByTitle
import it.hamy.innertube.utils.from
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
suspend fun Innertube.artistPage(body: BrowseBody) = runCatchingCancellable {
val ctx = currentCoroutineContext()
val response = client.post(BROWSE) {
setBody(body)
mask("contents,header")
}.body<BrowseResponse>()
val responseNoLang by lazy {
CoroutineScope(ctx).async(start = CoroutineStart.LAZY) {
client.post(BROWSE) {
setBody(body.copy(context = Context.DefaultWebNoLang))
mask("contents,header")
}.body<BrowseResponse>()
}
}
suspend fun findSectionByTitle(text: String) = response
.contents
?.singleColumnBrowseResultsRenderer
?.tabs
?.get(0)
?.tabRenderer
?.content
?.sectionListRenderer
?.findSectionByTitle(text) ?: responseNoLang.await()
.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,
thumbnail = (
response
.header
?.musicImmersiveHeaderRenderer
?.foregroundThumbnail
?: 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,68 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.MusicTwoRowItemRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.utils.from
suspend fun Innertube.browse(body: BrowseBody) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
}.body<BrowseResponse>()
BrowseResult(
title = response.header?.musicImmersiveHeaderRenderer?.title?.text ?: response.header
?.musicDetailHeaderRenderer?.title?.text,
items = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()
?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { content ->
when {
content.gridRenderer != null -> BrowseResult.Item(
title = content.gridRenderer.header?.gridHeaderRenderer?.title?.runs
?.firstOrNull()?.text ?: return@mapNotNull null,
items = content.gridRenderer.items?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() }
.orEmpty()
)
content.musicCarouselShelfRenderer != null -> BrowseResult.Item(
title = content
.musicCarouselShelfRenderer
.header
?.musicCarouselShelfBasicHeaderRenderer
?.title
?.runs
?.firstOrNull()
?.text ?: return@mapNotNull null,
items = content
.musicCarouselShelfRenderer
.contents
?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() }
.orEmpty()
)
else -> null
}
}.orEmpty()
)
}
data class BrowseResult(
val title: String?,
val items: List<Item>
) {
data class Item(
val title: String,
val items: List<Innertube.Item>
)
}
fun MusicTwoRowItemRenderer.toItem() = when {
isAlbum -> Innertube.AlbumItem.from(this)
isPlaylist -> Innertube.PlaylistItem.from(this)
isArtist -> Innertube.ArtistItem.from(this)
else -> null
}

View File

@@ -0,0 +1,51 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.MusicTwoRowItemRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.oddElements
import it.hamy.innertube.models.splitBySeparator
suspend fun Innertube.discoverPage() = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(BrowseBody(browseId = "FEmusic_explore"))
mask("contents")
}.body<BrowseResponse>()
Innertube.DiscoverPage(
newReleaseAlbums = response.contents?.singleColumnBrowseResultsRenderer?.tabs
?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find {
it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer
?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint
?.browseId == "FEmusic_new_releases_albums"
}?.musicCarouselShelfRenderer?.contents?.mapNotNull { it.musicTwoRowItemRenderer?.toNewReleaseAlbumPage() }
.orEmpty(),
moods = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()
?.tabRenderer?.content?.sectionListRenderer?.contents?.find {
it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer
?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint
?.browseId == "FEmusic_moods_and_genres"
}?.musicCarouselShelfRenderer?.contents?.mapNotNull { it.musicNavigationButtonRenderer?.toMood() }
.orEmpty()
)
}
fun MusicTwoRowItemRenderer.toNewReleaseAlbumPage() = Innertube.AlbumItem(
info = Innertube.Info(
name = title?.text,
endpoint = navigationEndpoint?.browseEndpoint
),
authors = subtitle?.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map {
Innertube.Info(
name = it.text,
endpoint = it.navigationEndpoint?.browseEndpoint
)
},
year = subtitle?.runs?.lastOrNull()?.text,
thumbnail = thumbnailRenderer?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull()
)

View File

@@ -0,0 +1,93 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.GridRenderer
import it.hamy.innertube.models.MusicResponsiveListItemRenderer
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.MusicTwoRowItemRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.ContinuationBody
suspend fun <T : Innertube.Item> Innertube.itemsPage(
body: BrowseBody,
fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null },
fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }
) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
}.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 }
) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
}.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?
) = when {
musicShelfRenderer != null -> Innertube.ItemsPage(
continuation = musicShelfRenderer
.continuations
?.firstOrNull()
?.nextContinuationData
?.continuation,
items = musicShelfRenderer
.contents
?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer)
?.mapNotNull(fromMusicResponsiveListItemRenderer)
)
gridRenderer != null -> Innertube.ItemsPage(
continuation = null,
items = gridRenderer
.items
?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer)
?.mapNotNull(fromMusicTwoRowItemRenderer)
)
else -> null
}

View File

@@ -0,0 +1,45 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.NextResponse
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.NextBody
suspend fun Innertube.lyrics(body: NextBody) = runCatchingCancellable {
val nextResponse = client.post(NEXT) {
setBody(body)
@Suppress("all")
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@runCatchingCancellable 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.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.NextResponse
import it.hamy.innertube.models.bodies.ContinuationBody
import it.hamy.innertube.models.bodies.NextBody
import it.hamy.innertube.utils.from
suspend fun Innertube.nextPage(body: NextBody): Result<Innertube.NextPage>? =
runCatchingCancellable {
val response = client.post(NEXT) {
setBody(body)
@Suppress("all")
mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$PLAYLIST_PANEL_VIDEO_RENDERER_MASK))")
}.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) = runCatchingCancellable {
val response = client.post(NEXT) {
setBody(body)
@Suppress("all")
mask("continuationContents.playlistPanelContinuation(continuations,contents.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK)")
}.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,67 @@
package it.hamy.innertube.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.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.Context
import it.hamy.innertube.models.PlayerResponse
import it.hamy.innertube.models.bodies.PlayerBody
import kotlinx.serialization.Serializable
suspend fun Innertube.player(body: PlayerBody) = runCatchingCancellable {
val response = client.post(PLAYER) {
setBody(body)
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId")
}.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.copy(
thirdParty = Context.ThirdParty(
embedUrl = "https://www.youtube.com/watch?v=${body.videoId}"
)
)
)
)
mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId")
}.body<PlayerResponse>()
if (safePlayerResponse.playabilityStatus?.status != "OK") {
return@runCatchingCancellable response
}
val audioStreams = client.get("https://pipedapi.adminforge.de/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,110 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.MusicCarouselShelfRenderer
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.ContinuationBody
import it.hamy.innertube.utils.from
suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
body.context.apply()
}.body<BrowseResponse>()
val musicDetailHeaderRenderer = response
.header
?.musicDetailHeaderRenderer
val sectionListRendererContents = response
.contents
?.singleColumnBrowseResultsRenderer
?.tabs
?.firstOrNull()
?.tabRenderer
?.content
?.sectionListRenderer
?.contents
val musicShelfRenderer = sectionListRendererContents
?.firstOrNull()
?.musicShelfRenderer
val musicCarouselShelfRenderer = sectionListRendererContents
?.getOrNull(1)
?.musicCarouselShelfRenderer
Innertube.PlaylistOrAlbumPage(
title = musicDetailHeaderRenderer
?.title
?.text,
description = musicDetailHeaderRenderer
?.description
?.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(),
otherVersions = musicCarouselShelfRenderer
?.contents
?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer)
?.mapNotNull(Innertube.AlbumItem::from),
otherInfo = musicDetailHeaderRenderer
?.secondSubtitle
?.text
)
}
suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingCancellable {
val response = client.post(BROWSE) {
setBody(body)
parameter("continuation", body.continuation)
parameter("ctoken", body.continuation)
parameter("type", "next")
body.context.apply()
}.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.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.GetQueueResponse
import it.hamy.innertube.models.bodies.QueueBody
import it.hamy.innertube.utils.from
suspend fun Innertube.queue(body: QueueBody) = runCatchingCancellable {
val response = client.post(QUEUE) {
setBody(body)
mask("queueDatas.content.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK")
}.body<GetQueueResponse>()
response
.queueData
?.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,84 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.BrowseResponse
import it.hamy.innertube.models.Context
import it.hamy.innertube.models.MusicCarouselShelfRenderer
import it.hamy.innertube.models.NextResponse
import it.hamy.innertube.models.bodies.BrowseBody
import it.hamy.innertube.models.bodies.NextBody
import it.hamy.innertube.utils.findSectionByStrapline
import it.hamy.innertube.utils.findSectionByTitle
import it.hamy.innertube.utils.from
suspend fun Innertube.relatedPage(body: NextBody) = runCatchingCancellable {
val nextResponse = client.post(NEXT) {
setBody(body.copy(context = Context.DefaultWebNoLang))
@Suppress("all")
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@runCatchingCancellable null
val response = client.post(BROWSE) {
setBody(
BrowseBody(
browseId = browseId,
context = Context.DefaultWebNoLang
)
)
@Suppress("all")
mask(
"contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK,$MUSIC_TWO_ROW_ITEM_RENDERER_MASK))"
)
}.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)
?.sortedByDescending { it.channel?.name == "YouTube Music" },
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,65 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.ContinuationResponse
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.models.SearchResponse
import it.hamy.innertube.models.bodies.ContinuationBody
import it.hamy.innertube.models.bodies.SearchBody
suspend fun <T : Innertube.Item> Innertube.searchPage(
body: SearchBody,
fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T?
) = runCatchingCancellable {
val response = client.post(SEARCH) {
setBody(body)
@Suppress("all")
mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)")
}.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?
) = runCatchingCancellable {
val response = client.post(SEARCH) {
setBody(body)
@Suppress("all")
mask("continuationContents.musicShelfContinuation(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)")
}.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,30 @@
package it.hamy.innertube.requests
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import it.hamy.extensions.runCatchingCancellable
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.SearchSuggestionsResponse
import it.hamy.innertube.models.bodies.SearchSuggestionsBody
suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingCancellable {
val response = client.post(SEARCH_SUGGESTIONS) {
setBody(body)
@Suppress("all")
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,53 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.MusicResponsiveListItemRenderer
import it.hamy.innertube.models.NavigationEndpoint
fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) =
Innertube.SongItem(
info = renderer
.flexColumns
.getOrNull(0)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.getOrNull(0)
?.let {
if (it.navigationEndpoint?.endpoint is NavigationEndpoint.Endpoint.Watch) Innertube.Info(
name = it.text,
endpoint = it.navigationEndpoint.endpoint as NavigationEndpoint.Endpoint.Watch
) else null
},
authors = renderer
.flexColumns
.getOrNull(1)
?.musicResponsiveListItemFlexColumnRenderer
?.text
?.runs
?.map { Innertube.Info(name = it.text, endpoint = it.navigationEndpoint?.endpoint) }
?.filterIsInstance<Innertube.Info<NavigationEndpoint.Endpoint.Browse>>()
?.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,143 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.MusicShelfRenderer
import it.hamy.innertube.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
?.takeIf { ':' in it }
?: otherRuns
.getOrNull(otherRuns.size - 2)
?.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 runCatching {
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 }
}.getOrNull()
}
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,71 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.MusicTwoRowItemRenderer
fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer) = 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(
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(
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,33 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.PlaylistPanelVideoRenderer
fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer) = 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,13 @@
package it.hamy.innertube.utils
import java.net.Proxy
object ProxyPreferences {
var preference: ProxyPreferenceItem? = null
}
data class ProxyPreferenceItem(
var proxyHost: String,
var proxyPort: Int,
var proxyMode: Proxy.Type
)

View File

@@ -0,0 +1,38 @@
package it.hamy.innertube.utils
import it.hamy.innertube.Innertube
import it.hamy.innertube.models.SectionListRenderer
internal fun SectionListRenderer.findSectionByTitle(text: String) = contents?.find {
val title = it
.musicCarouselShelfRenderer
?.header
?.musicCarouselShelfBasicHeaderRenderer
?.title
?: it
.musicShelfRenderer
?.title
title
?.runs
?.firstOrNull()
?.text == text
}
internal fun SectionListRenderer.findSectionByStrapline(text: String) = contents?.find {
it
.musicCarouselShelfRenderer
?.header
?.musicCarouselShelfBasicHeaderRenderer
?.strapline
?.runs
?.firstOrNull()
?.text == text
}
infix operator fun <T : Innertube.Item> Innertube.ItemsPage<T>?.plus(other: Innertube.ItemsPage<T>) =
other.copy(
items = (this?.items?.plus(other.items ?: emptyList()) ?: other.items)
?.distinctBy(Innertube.Item::key),
continuation = other.continuation ?: this?.continuation
)